diff --git a/.gitignore b/.gitignore index a7905e501a2..54f1cf32c30 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,6 @@ cli/azd/extensions/microsoft.azd.ai.builder/microsoft.azd.ai.builder cli/azd/extensions/microsoft.azd.ai.builder/microsoft.azd.ai.builder.exe cli/azd/extensions/microsoft.azd.demo/microsoft.azd.demo cli/azd/extensions/microsoft.azd.demo/microsoft.azd.demo.exe -cli/azd/extensions/microsoft.azd.concurx/concurx -cli/azd/extensions/microsoft.azd.concurx/concurx.exe +cli/azd/extensions/microsoft.azd.concurx/microsoft.azd.concurx +cli/azd/extensions/microsoft.azd.concurx/microsoft.azd.concurx.exe cli/azd/azd-test diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index f369c560619..01588050330 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -45,6 +45,9 @@ words: - jsonschema - rustc - figspec + - bubbletea + - lipgloss + - gopxl languageSettings: - languageId: go ignoreRegExpList: diff --git a/cli/azd/extensions/microsoft.azd.concurx/go.mod b/cli/azd/extensions/microsoft.azd.concurx/go.mod index ef5bf2a73cd..4e02b062c7d 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/go.mod +++ b/cli/azd/extensions/microsoft.azd.concurx/go.mod @@ -6,6 +6,9 @@ replace github.com/azure/azure-dev/cli/azd => ../../ require ( github.com/azure/azure-dev/cli/azd v0.0.0-00010101000000-000000000000 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/fatih/color v1.18.0 github.com/spf13/cobra v1.10.1 ) @@ -21,7 +24,6 @@ require ( github.com/blang/semver/v4 v4.0.0 // indirect github.com/charmbracelet/colorprofile v0.3.2 // indirect github.com/charmbracelet/glamour v0.10.0 // indirect - github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect github.com/charmbracelet/x/ansi v0.10.2 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20251008171431-5d3777519489 // indirect @@ -31,6 +33,7 @@ require ( github.com/dlclark/regexp2 v1.11.5 // indirect github.com/drone/envsubst v1.0.3 // indirect github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect @@ -42,10 +45,13 @@ require ( github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/microsoft/ApplicationInsights-Go v0.4.4 // indirect github.com/microsoft/go-deviceid v1.0.0 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d // indirect @@ -64,9 +70,9 @@ require ( go.opentelemetry.io/otel/trace v1.38.0 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/sys v0.38.0 // indirect + golang.org/x/sys v0.39.0 // indirect golang.org/x/term v0.37.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/text v0.32.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff // indirect google.golang.org/grpc v1.76.0 // indirect google.golang.org/protobuf v1.36.10 // indirect diff --git a/cli/azd/extensions/microsoft.azd.concurx/go.sum b/cli/azd/extensions/microsoft.azd.concurx/go.sum index b8f257e3601..17b3ab42796 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/go.sum +++ b/cli/azd/extensions/microsoft.azd.concurx/go.sum @@ -31,6 +31,10 @@ github.com/braydonk/yaml v0.9.0 h1:ewGMrVmEVpsm3VwXQDR388sLg5+aQ8Yihp6/hc4m+h4= github.com/braydonk/yaml v0.9.0/go.mod h1:hcm3h581tudlirk8XEUPDBAimBPbmnL0Y45hCRl47N4= github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY= github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= @@ -41,8 +45,8 @@ github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvA github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/slice v0.0.0-20251008171431-5d3777519489 h1:a5q2sWiet6kgqucSGjYN1jhT2cn4bMKUwprtm2IGRto= github.com/charmbracelet/x/exp/slice v0.0.0-20251008171431-5d3777519489/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= @@ -60,6 +64,8 @@ github.com/drone/envsubst v1.0.3 h1:PCIBwNDYjs50AsLZPYdfhSATKaRg/FJmDc2D6+C2x8g= github.com/drone/envsubst v1.0.3/go.mod h1:N2jZmlMufstn1KEqvbHjw40h1KyTmnVzHcSc9bFiJ2g= github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 h1:XBBHcIb256gUJtLmY22n99HaZTz+r2Z51xUPi01m3wg= github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203/go.mod h1:E1jcSv8FaEny+OP/5k9UxZVw9YFWGj7eI4KR/iOBqCg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -105,6 +111,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= @@ -116,6 +124,10 @@ github.com/microsoft/ApplicationInsights-Go v0.4.4 h1:G4+H9WNs6ygSCe6sUyxRc2U81T github.com/microsoft/ApplicationInsights-Go v0.4.4/go.mod h1:fKRUseBqkw6bDiXTs3ESTiU/4YTIHsQS4W3fP2ieF4U= github.com/microsoft/go-deviceid v1.0.0 h1:i5AQ654Xk9kfvwJeKQm3w2+eT1+ImBDVEpAR0AjpP40= github.com/microsoft/go-deviceid v1.0.0/go.mod h1:KY13FeVdHkzD8gy+6T8+kVmD/7RMpTaWW75K+T4uZWg= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= @@ -183,15 +195,16 @@ golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff h1:A90eA31Wq6HOMIQlLfzFwzqGKBTuaVztYu/g8sn+8Zc= diff --git a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/concurrent_deployer.go b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/concurrent_deployer.go new file mode 100644 index 00000000000..a781654eb51 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/concurrent_deployer.go @@ -0,0 +1,553 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + tea "github.com/charmbracelet/bubbletea" +) + +// ConcurrentDeployer orchestrates the concurrent deployment of multiple services +type ConcurrentDeployer struct { + ctx context.Context + services map[string]*azdext.ServiceConfig + logsDir string + provisionLogPath string + ui *tea.Program + errChan chan error + wg sync.WaitGroup + activeDeployments atomic.Int32 + buildGate *buildGate + provision *provisionState + finalSummaryMu sync.Mutex + finalSummary string + debug bool +} + +// NewConcurrentDeployer creates a new concurrent deployer +func NewConcurrentDeployer( + ctx context.Context, + _ azdext.WorkflowServiceClient, + services map[string]*azdext.ServiceConfig, + ui *tea.Program, + debug bool, +) (*ConcurrentDeployer, error) { + // Create logs directory with unique timestamp + timestamp := time.Now().Format("20060102-150405") + logsDir := filepath.Join(".azure", "logs", "deploy", timestamp) + if err := os.MkdirAll(logsDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create logs directory: %w", err) + } + + // Create provision log file path + provisionLogPath := filepath.Join(logsDir, "provision.log") + absProvisionLogPath, _ := filepath.Abs(provisionLogPath) + + return &ConcurrentDeployer{ + ctx: ctx, + services: services, + logsDir: logsDir, + provisionLogPath: absProvisionLogPath, + ui: ui, + errChan: make(chan error, len(services)), + buildGate: newBuildGate(), + provision: newProvisionState(), + debug: debug, + }, nil +} + +// Deploy runs the provision and deployment workflow +func (cd *ConcurrentDeployer) Deploy() error { + // Start provision in background + go cd.runProvision() + + // Start all service deployments + cd.startServiceDeployments() + + // Wait for all deployments to complete + go cd.waitForCompletion() + + // Run the UI and collect results + return cd.collectResults() +} + +// FinalSummary returns a plain-text summary suitable for printing after the TUI exits. +func (cd *ConcurrentDeployer) FinalSummary() string { + cd.finalSummaryMu.Lock() + defer cd.finalSummaryMu.Unlock() + return cd.finalSummary +} + +// runProvision executes the provision workflow +func (cd *ConcurrentDeployer) runProvision() { + cd.ui.Send(provisionUpdateMsg{ + status: "running", + message: "Provisioning infrastructure...", + logPath: cd.provisionLogPath, + }) + + // Create provision log file + logFile, err := os.Create(cd.provisionLogPath) + if err != nil { + cd.ui.Send(provisionUpdateMsg{ + status: "failed", + message: "Failed to create provision log file", + err: err, + logPath: cd.provisionLogPath, + }) + cd.provision.Fail(err) + return + } + defer logFile.Close() + + // Run azd provision as a subprocess to capture output + args := []string{"provision"} + if cd.debug { + args = append(args, "--debug") + } + cmd := exec.CommandContext(cd.ctx, "azd", args...) + cmd.Stdout = logFile + cmd.Stderr = logFile + cmd.Dir, _ = os.Getwd() + cmd.Env = append(os.Environ(), "NO_COLOR=1", "AZD_FORCE_TTY=false") + + err = cmd.Run() + if err != nil { + cd.ui.Send(provisionUpdateMsg{ + status: "failed", + message: "Provision failed", + err: err, + logPath: cd.provisionLogPath, + }) + cd.provision.Fail(err) + return + } + + cd.ui.Send(provisionUpdateMsg{ + status: "completed", + message: "Infrastructure provisioned", + logPath: cd.provisionLogPath, + }) + cd.provision.Succeed() +} + +// startServiceDeployments starts all service deployment goroutines +func (cd *ConcurrentDeployer) startServiceDeployments() { + for serviceName, service := range cd.services { + cd.wg.Add(1) + cd.activeDeployments.Add(1) + + deployer := newServiceDeployer( + cd.ctx, + serviceName, + service, + cd.logsDir, + cd.ui, + cd.buildGate, + cd.provision, + cd.errChan, + cd.debug, + ) + + go func() { + defer cd.wg.Done() + defer cd.activeDeployments.Add(-1) + deployer.Deploy() + }() + } +} + +// waitForCompletion waits for all deployments and signals completion +func (cd *ConcurrentDeployer) waitForCompletion() { + cd.wg.Wait() + close(cd.errChan) + cd.ui.Send(deploymentCompleteMsg{}) +} + +// collectResults runs the UI and collects deployment results +func (cd *ConcurrentDeployer) collectResults() error { + finalModel, err := cd.ui.Run() + if err != nil { + return fmt.Errorf("UI error: %w", err) + } + + if m, ok := finalModel.(deploymentModel); ok { + // Only render summary if deployment was not cancelled by user + if !m.cancelled { + cd.finalSummaryMu.Lock() + cd.finalSummary = renderPersistedSummary(&m) + cd.finalSummaryMu.Unlock() + } + } + + // Check if user cancelled the deployment + if m, ok := finalModel.(deploymentModel); ok && m.cancelled { + return fmt.Errorf("deployment cancelled by user") + } + + // Collect deployment errors + var deployErrors []error + for err := range cd.errChan { + deployErrors = append(deployErrors, err) + } + + // Check final model for errors + if m, ok := finalModel.(deploymentModel); ok && m.err != nil { + return m.err + } + + if len(deployErrors) > 0 { + return fmt.Errorf("%d service(s) failed to deploy", len(deployErrors)) + } + + return nil +} + +// buildGate manages the Aspire build synchronization +type buildGate struct { + firstAspire bool + firstAspireMu sync.Mutex + openOnce sync.Once + failOnce sync.Once + readyCh chan struct{} + failCh chan struct{} + errMu sync.Mutex + err error +} + +func newBuildGate() *buildGate { + return &buildGate{ + firstAspire: true, + readyCh: make(chan struct{}), + failCh: make(chan struct{}), + } +} + +// ClaimFirstAspire attempts to claim the first Aspire service slot +func (bg *buildGate) ClaimFirstAspire() bool { + bg.firstAspireMu.Lock() + defer bg.firstAspireMu.Unlock() + + if bg.firstAspire { + bg.firstAspire = false + return true + } + return false +} + +// Open releases the build gate for other Aspire services. +func (bg *buildGate) Open() { + bg.openOnce.Do(func() { + close(bg.readyCh) + }) +} + +// Fail marks the gate as failed and unblocks waiters with an error. +func (bg *buildGate) Fail(err error) { + bg.failOnce.Do(func() { + bg.errMu.Lock() + bg.err = err + bg.errMu.Unlock() + close(bg.failCh) + }) +} + +func (bg *buildGate) failure() error { + bg.errMu.Lock() + defer bg.errMu.Unlock() + if bg.err == nil { + return fmt.Errorf("Aspire build gate failed") + } + return bg.err +} + +// Wait blocks until the gate is opened, fails, or the context is canceled. +func (bg *buildGate) Wait(ctx context.Context) error { + select { + case <-bg.readyCh: + return nil + case <-bg.failCh: + return bg.failure() + case <-ctx.Done(): + return ctx.Err() + } +} + +// serviceDeployer handles deployment of a single service +type serviceDeployer struct { + ctx context.Context + serviceName string + service *azdext.ServiceConfig + logsDir string + ui *tea.Program + buildGate *buildGate + provision *provisionState + errChan chan error + logFile *os.File + logPath string + debug bool +} + +func newServiceDeployer( + ctx context.Context, + serviceName string, + service *azdext.ServiceConfig, + logsDir string, + ui *tea.Program, + buildGate *buildGate, + provision *provisionState, + errChan chan error, + debug bool, +) *serviceDeployer { + return &serviceDeployer{ + ctx: ctx, + serviceName: serviceName, + service: service, + logsDir: logsDir, + ui: ui, + buildGate: buildGate, + provision: provision, + errChan: errChan, + debug: debug, + } +} + +// Deploy executes the deployment for this service +func (sd *serviceDeployer) Deploy() { + if err := sd.setup(); err != nil { + sd.errChan <- err + return + } + defer sd.cleanup() + + // Wait for provision to complete + sd.updateStatus(StatusWaiting) + if ok, err := sd.provision.Wait(); !ok { + sd.handleError(fmt.Errorf("provision failed: %w", err)) + return + } + + // Handle Aspire service synchronization + isFirstAspire, err := sd.handleAspireGate() + if err != nil { + sd.handleError(err) + return + } + + // Run the deployment + if err := sd.runDeployment(isFirstAspire); err != nil { + sd.handleError(err) + return + } + + sd.updateStatus(StatusCompleted) +} + +// setup prepares log files and initial state +func (sd *serviceDeployer) setup() error { + logFileName := fmt.Sprintf("deploy-%s.log", sd.serviceName) + logFilePath := filepath.Join(sd.logsDir, logFileName) + sd.logPath, _ = filepath.Abs(logFilePath) + + var err error + sd.logFile, err = os.Create(logFilePath) + if err != nil { + return fmt.Errorf("failed to create log file for service %s: %w", sd.serviceName, err) + } + + return nil +} + +// cleanup closes log files +func (sd *serviceDeployer) cleanup() { + if sd.logFile != nil { + sd.logFile.Close() + } +} + +// isAspireService determines if this is an Aspire service +func (sd *serviceDeployer) isAspireService() bool { + return (sd.service.Host == "containerapp-dotnet" || sd.service.Host == "containerapp") && + (sd.service.Language == "dotnet" || sd.service.Language == "csharp") +} + +// handleAspireGate manages Aspire build gate synchronization +func (sd *serviceDeployer) handleAspireGate() (bool, error) { + if !sd.isAspireService() { + sd.updateStatus(StatusDeploying) + return false, nil + } + + if sd.buildGate.ClaimFirstAspire() { + sd.updateStatus(StatusDeploying) + return true, nil + } + + sd.updateStatus(StatusWaitingForGate) + if err := sd.buildGate.Wait(sd.ctx); err != nil { + return false, fmt.Errorf("waiting for Aspire build gate: %w", err) + } + sd.updateStatus(StatusDeploying) + return false, nil +} + +// runDeployment executes the actual deployment command +func (sd *serviceDeployer) runDeployment(isFirstAspire bool) error { + var outputWriter io.Writer + gateReleased := &atomic.Bool{} + + if isFirstAspire { + outputWriter = newBuildGateWriter(sd.logFile, sd.buildGate, gateReleased, sd.serviceName) + } else { + outputWriter = sd.logFile + } + + // #nosec G204 - serviceName is from validated azd context, not user input + args := []string{"deploy", sd.serviceName} + if sd.debug { + args = append(args, "--debug") + } + // #nosec G204 - args constructed from validated inputs + cmd := exec.CommandContext(sd.ctx, "azd", args...) + cmd.Stdout = outputWriter + cmd.Stderr = outputWriter + cmd.Dir, _ = os.Getwd() + cmd.Env = append(os.Environ(), "NO_COLOR=1", "AZD_FORCE_TTY=false") + + err := cmd.Run() + if isFirstAspire { + // If we never saw the marker, still make a decision so other Aspire services don't hang. + if gateReleased.Load() { + sd.buildGate.Open() + } else if err != nil { + sd.buildGate.Fail(fmt.Errorf("first Aspire service failed before parallel-safe point: %w", err)) + } else { + // Deploy succeeded; allow others to proceed even if marker wasn't detected. + sd.buildGate.Open() + } + } + + return err +} + +type provisionState struct { + done chan struct{} + succeeded atomic.Bool + errMu sync.Mutex + err error +} + +func newProvisionState() *provisionState { + return &provisionState{done: make(chan struct{})} +} + +func (p *provisionState) Fail(err error) { + p.errMu.Lock() + p.err = err + p.errMu.Unlock() + close(p.done) +} + +func (p *provisionState) Succeed() { + p.succeeded.Store(true) + close(p.done) +} + +func (p *provisionState) Wait() (bool, error) { + <-p.done + if p.succeeded.Load() { + return true, nil + } + p.errMu.Lock() + defer p.errMu.Unlock() + if p.err == nil { + return false, fmt.Errorf("provision did not complete") + } + return false, p.err +} + +func renderPersistedSummary(m *deploymentModel) string { + var b strings.Builder + + b.WriteString("Concurx summary\n") + b.WriteString("==============\n") + + if m.provisionStatus != "" { + b.WriteString(fmt.Sprintf("Provision: %s\n", m.provisionStatus)) + if m.provisionLogPath != "" { + b.WriteString(fmt.Sprintf(" Logs: %s\n", m.provisionLogPath)) + } + if m.provisionErr != nil { + b.WriteString(fmt.Sprintf(" Error: %v\n", m.provisionErr)) + } + } + + b.WriteString("\nServices:\n") + for _, name := range m.serviceOrder { + svc := m.services[name] + status := "unknown" + switch svc.Status { + case StatusWaiting: + status = "waiting" + case StatusWaitingForGate: + status = "waiting-for-gate" + case StatusDeploying: + status = "deploying" + case StatusCompleted: + status = "completed" + case StatusFailed: + status = "failed" + } + + b.WriteString(fmt.Sprintf("- %s: %s\n", name, status)) + if !svc.StartTime.IsZero() { + end := svc.EndTime + if end.IsZero() { + end = time.Now() + } + b.WriteString(fmt.Sprintf(" Duration: %s\n", end.Sub(svc.StartTime).Round(time.Second))) + } + if svc.LogPath != "" { + b.WriteString(fmt.Sprintf(" Logs: %s\n", svc.LogPath)) + } + if svc.Error != nil { + b.WriteString(fmt.Sprintf(" Error: %v\n", svc.Error)) + } + } + + b.WriteString("\n") + return b.String() +} + +// updateStatus sends a status update to the UI +func (sd *serviceDeployer) updateStatus(status ServiceStatus) { + sd.ui.Send(serviceUpdateMsg{ + name: sd.serviceName, + status: status, + logPath: sd.logPath, + }) +} + +// handleError processes deployment errors +func (sd *serviceDeployer) handleError(err error) { + deployErr := fmt.Errorf("failed to deploy service %s: %w", sd.serviceName, err) + sd.errChan <- deployErr + sd.ui.Send(serviceUpdateMsg{ + name: sd.serviceName, + status: StatusFailed, + logPath: sd.logPath, + err: deployErr, + }) +} diff --git a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/deployment_model.go b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/deployment_model.go new file mode 100644 index 00000000000..565382fab91 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/deployment_model.go @@ -0,0 +1,690 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// ServiceStatus represents the current state of a service deployment +type ServiceStatus int + +const ( + StatusWaiting ServiceStatus = iota + StatusWaitingForGate + StatusDeploying + StatusCompleted + StatusFailed +) + +// ServiceState tracks the state of a single service deployment +type ServiceState struct { + Name string + Status ServiceStatus + LogPath string + Error error + StartTime time.Time + EndTime time.Time +} + +// viewMode represents the current view +type viewMode int + +const ( + viewDeployment viewMode = iota + viewLogs +) + +// deploymentModel is the Bubble Tea model for deployment visualization +type deploymentModel struct { + services map[string]*ServiceState + serviceOrder []string + spinner spinner.Model + quitting bool + cancelled bool // True if user cancelled with Ctrl+C + err error + provisionStatus string // "running", "completed", "failed" + provisionMsg string + provisionLogPath string + provisionErr error + cancel context.CancelFunc // Cancel function to stop all deployments + // Logs view state + viewMode viewMode + selectedTab int + tabNames []string // "provision" followed by service names + logContents map[string]string + viewport viewport.Model + width int + height int + ready bool + autoRefresh bool // Auto-refresh logs when enabled +} + +// Messages that can be sent to the Bubble Tea program +type serviceUpdateMsg struct { + name string + status ServiceStatus + logPath string + err error +} + +type provisionUpdateMsg struct { + status string // "running", "completed", "failed" + message string + logPath string + err error +} + +type deploymentCompleteMsg struct{} + +type tickMsg time.Time + +type logRefreshMsg time.Time + +// Styles +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("39")). + MarginBottom(1) + + statusWaitingStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")) + + statusWaitingGateStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("214")) + + statusDeployingStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("45")) + + statusCompletedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("46")) + + statusFailedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")) + + logPathStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("245")). + Italic(true) +) + +func newDeploymentModel(serviceNames []string, cancel context.CancelFunc) deploymentModel { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + + services := make(map[string]*ServiceState) + for _, name := range serviceNames { + services[name] = &ServiceState{ + Name: name, + Status: StatusWaiting, + } + } + + // Initialize tab names: provision first, then services + tabNames := make([]string, 0, len(serviceNames)+1) + tabNames = append(tabNames, "provision") + tabNames = append(tabNames, serviceNames...) + + // Create viewport with default key mappings + vp := viewport.New(120, 25) + + return deploymentModel{ + services: services, + serviceOrder: serviceNames, + spinner: s, + cancel: cancel, + viewMode: viewDeployment, + selectedTab: 0, + tabNames: tabNames, + logContents: make(map[string]string), + viewport: vp, + } +} + +func (m deploymentModel) Init() tea.Cmd { + return tea.Batch( + m.spinner.Tick, + tickCmd(), + ) +} + +func tickCmd() tea.Cmd { + return tea.Tick(time.Millisecond*100, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +func logRefreshCmd() tea.Cmd { + return tea.Tick(time.Millisecond*500, func(t time.Time) tea.Msg { + return logRefreshMsg(t) + }) +} + +func (m deploymentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + if !m.ready { + m.ready = true + } + // Update viewport size when in logs view + if m.viewMode == viewLogs { + headerHeight := 7 // title + instructions + tabs + spacing + m.viewport.Width = msg.Width - 4 + m.viewport.Height = msg.Height - headerHeight + m.updateViewportContent() + } + return m, nil + + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + m.quitting = true + m.cancelled = true + // Cancel context to stop all running deployments + if m.cancel != nil { + m.cancel() + } + return m, tea.Quit + case "l", "L": + // Toggle to logs view + if m.viewMode == viewDeployment { + m.viewMode = viewLogs + m.refreshLogContents() + // Update viewport size based on current terminal size + if m.ready { + headerHeight := 7 + m.viewport.Width = m.width - 4 + m.viewport.Height = m.height - headerHeight + } + m.updateViewportContent() + } + return m, nil + case "b", "B": + // Back to deployment view + if m.viewMode == viewLogs { + m.viewMode = viewDeployment + } + return m, nil + case "left": + // Previous tab + if m.viewMode == viewLogs && m.selectedTab > 0 { + m.selectedTab-- + m.refreshLogContents() + m.updateViewportContent() + } + return m, nil + case "right": + // Next tab + if m.viewMode == viewLogs && m.selectedTab < len(m.tabNames)-1 { + m.selectedTab++ + m.refreshLogContents() + m.updateViewportContent() + } + return m, nil + case "i", "I": + // Jump to beginning of log + if m.viewMode == viewLogs { + m.viewport.GotoTop() + } + return m, nil + case "o", "O": + // Open log file in default editor + if m.viewMode == viewLogs { + m.openCurrentLogFile() + } + return m, nil + case "a", "A": + // Toggle auto-refresh + if m.viewMode == viewLogs { + m.autoRefresh = !m.autoRefresh + if m.autoRefresh { + // Start refresh ticker + return m, logRefreshCmd() + } + } + return m, nil + } + + // Handle viewport scrolling when in logs view + if m.viewMode == viewLogs { + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + + case provisionUpdateMsg: + m.provisionStatus = msg.status + m.provisionMsg = msg.message + if msg.logPath != "" { + m.provisionLogPath = msg.logPath + } + if msg.err != nil { + m.provisionErr = msg.err + } + return m, nil + + case serviceUpdateMsg: + if svc, ok := m.services[msg.name]; ok { + svc.Status = msg.status + if msg.logPath != "" { + svc.LogPath = msg.logPath + } + if msg.err != nil { + svc.Error = msg.err + } + if msg.status == StatusDeploying && svc.StartTime.IsZero() { + svc.StartTime = time.Now() + } + if (msg.status == StatusCompleted || msg.status == StatusFailed) && svc.EndTime.IsZero() { + svc.EndTime = time.Now() + } + } + return m, nil + + case deploymentCompleteMsg: + m.quitting = true + return m, tea.Quit + + case tickMsg: + // Continue ticking for spinner animation + return m, tickCmd() + + case logRefreshMsg: + // Auto-refresh logs if enabled + if m.viewMode == viewLogs && m.autoRefresh { + // Check if we're at the bottom before refreshing + atBottom := m.viewport.AtBottom() + m.refreshLogContents() + // Update viewport content + if m.selectedTab >= 0 && m.selectedTab < len(m.tabNames) { + tabName := m.tabNames[m.selectedTab] + if content, ok := m.logContents[tabName]; ok { + m.viewport.SetContent(content) + // Stay at bottom if we were there + if atBottom { + m.viewport.GotoBottom() + } + } + } + // Continue refresh ticker + return m, logRefreshCmd() + } + return m, nil + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case error: + m.err = msg + m.quitting = true + return m, tea.Quit + } + + return m, nil +} + +func (m deploymentModel) View() string { + if m.err != nil { + return statusFailedStyle.Render(fmt.Sprintf("Error: %v\n", m.err)) + } + + if m.quitting { + return m.renderFinalView() + } + + if m.viewMode == viewLogs { + return m.renderLogsView() + } + + var b strings.Builder + + // Title + b.WriteString(titleStyle.Render("🚀 Azure Developer CLI - ConcurX")) + b.WriteString("\n\n") + + // Provision status + if m.provisionStatus != "" { + switch m.provisionStatus { + case "running": + msg := "Provisioning infrastructure..." + b.WriteString(fmt.Sprintf(" %s %s\n", m.spinner.View(), statusDeployingStyle.Render(msg))) + if m.provisionLogPath != "" { + b.WriteString(fmt.Sprintf(" %s\n\n", logPathStyle.Render(fmt.Sprintf("Logs: %s", m.provisionLogPath)))) + } else { + b.WriteString("\n") + } + case "completed": + b.WriteString(fmt.Sprintf(" %s %s\n\n", "✓", statusCompletedStyle.Render("Infrastructure provisioned"))) + case "failed": + b.WriteString(fmt.Sprintf(" %s %s\n", "✗", statusFailedStyle.Render("Provision failed"))) + if m.provisionErr != nil { + b.WriteString(fmt.Sprintf(" %s\n", statusFailedStyle.Render(m.provisionErr.Error()))) + } + if m.provisionLogPath != "" { + b.WriteString(fmt.Sprintf(" %s\n\n", logPathStyle.Render(fmt.Sprintf("Logs: %s", m.provisionLogPath)))) + } else { + b.WriteString("\n") + } + } + } + + // Only show deployment section if provision is completed + if m.provisionStatus == "completed" || len(m.services) > 0 { + b.WriteString(fmt.Sprintf("Deploying %d services in parallel:\n\n", len(m.services))) + } + + // Service statuses + for _, name := range m.serviceOrder { + svc := m.services[name] + b.WriteString(m.renderServiceStatus(svc)) + b.WriteString("\n") + } + + // Help text + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + Render("Press L to see logs • Press q or Ctrl+C to quit")) + + return b.String() +} + +func (m deploymentModel) renderServiceStatus(svc *ServiceState) string { + var icon, status string + var statusStyle lipgloss.Style + + switch svc.Status { + case StatusWaiting: + icon = "⏳" + status = "Waiting to deploy" + statusStyle = statusWaitingStyle + case StatusWaitingForGate: + icon = "🔒" + status = "Waiting for first Aspire service to build..." + statusStyle = statusWaitingGateStyle + case StatusDeploying: + icon = m.spinner.View() + elapsed := time.Since(svc.StartTime) + status = fmt.Sprintf("Deploying... (%s)", formatDuration(elapsed)) + statusStyle = statusDeployingStyle + case StatusCompleted: + icon = "✓" + duration := svc.EndTime.Sub(svc.StartTime) + status = fmt.Sprintf("Completed (%s)", formatDuration(duration)) + statusStyle = statusCompletedStyle + case StatusFailed: + icon = "✗" + status = fmt.Sprintf("Failed: %v", svc.Error) + statusStyle = statusFailedStyle + } + + result := fmt.Sprintf(" %s %s", icon, statusStyle.Render(fmt.Sprintf("%-30s", svc.Name))) + result += " " + statusStyle.Render(status) + + if svc.LogPath != "" && (svc.Status == StatusDeploying || svc.Status == StatusFailed) { + result += "\n " + logPathStyle.Render(fmt.Sprintf("Logs: %s", svc.LogPath)) + } + + return result +} + +func (m deploymentModel) renderFinalView() string { + var b strings.Builder + + // Count results + completed := 0 + failed := 0 + for _, svc := range m.services { + if svc.Status == StatusCompleted { + completed++ + } else if svc.Status == StatusFailed { + failed++ + } + } + + b.WriteString("\n") + b.WriteString(titleStyle.Render("Deployment Summary")) + b.WriteString("\n\n") + + if failed > 0 { + b.WriteString(statusFailedStyle.Render(fmt.Sprintf("✗ %d service(s) failed", failed))) + b.WriteString("\n") + b.WriteString(statusCompletedStyle.Render(fmt.Sprintf("✓ %d service(s) completed", completed))) + b.WriteString("\n\n") + + // List failed services + b.WriteString("Failed services:\n") + for _, name := range m.serviceOrder { + svc := m.services[name] + if svc.Status == StatusFailed { + b.WriteString(fmt.Sprintf(" - %s: %v\n", name, svc.Error)) + if svc.LogPath != "" { + b.WriteString(fmt.Sprintf(" Logs: %s\n", svc.LogPath)) + } + } + } + } else { + b.WriteString(statusCompletedStyle.Render(fmt.Sprintf("✓ All %d services deployed successfully!", completed))) + b.WriteString("\n") + } + + return b.String() +} + +func formatDuration(d time.Duration) string { + d = d.Round(time.Second) + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + minutes := int(d.Minutes()) + seconds := int(d.Seconds()) % 60 + return fmt.Sprintf("%dm%ds", minutes, seconds) +} + +// refreshLogContents reads log files and updates the log contents map +func (m *deploymentModel) refreshLogContents() { + // Only refresh the currently selected tab to avoid reading all files + if m.selectedTab < 0 || m.selectedTab >= len(m.tabNames) { + return + } + + tabName := m.tabNames[m.selectedTab] + var logPath string + + if tabName == "provision" { + logPath = m.provisionLogPath + } else if svc, ok := m.services[tabName]; ok { + logPath = svc.LogPath + } + + if logPath == "" { + m.logContents[tabName] = "No log file available yet" + return + } + + content, err := readLogFile(logPath) + if err != nil { + m.logContents[tabName] = fmt.Sprintf("Error reading log file: %v", err) + return + } + + m.logContents[tabName] = content +} + +// readLogFile reads the entire content of a log file +func readLogFile(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + + content, err := io.ReadAll(file) + if err != nil { + return "", err + } + + return string(content), nil +} + +// renderLogsView renders the logs viewer with tabs +func (m deploymentModel) renderLogsView() string { + var b strings.Builder + + // Title + b.WriteString(titleStyle.Render("📋 Deployment Logs")) + b.WriteString("\n\n") + + // Instructions with auto-refresh status + autoRefreshStatus := "" + if m.autoRefresh { + autoRefreshStatus = lipgloss.NewStyle(). + Foreground(lipgloss.Color("46")). + Bold(true). + Render(" [AUTO-REFRESH ON]") + } + helpText := "← → tabs • ↑↓ scroll • I top • A toggle refresh • O open • B back • q quit" + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(helpText)) + b.WriteString(autoRefreshStatus) + b.WriteString("\n\n") + + // Render tabs + b.WriteString(m.renderTabs()) + b.WriteString("\n\n") + + // Render viewport with scrollable log content + b.WriteString(m.viewport.View()) + + return b.String() +} + +// updateViewportContent updates the viewport with current log content +func (m *deploymentModel) updateViewportContent() { + if m.selectedTab < 0 || m.selectedTab >= len(m.tabNames) { + return + } + + tabName := m.tabNames[m.selectedTab] + content, ok := m.logContents[tabName] + if !ok || content == "" { + content = "Loading logs..." + } + + // Remember if we were at bottom before updating + atBottom := m.viewport.AtBottom() + m.viewport.SetContent(content) + + // Scroll to bottom by default to show latest logs, or maintain position + if atBottom || !m.autoRefresh { + m.viewport.GotoBottom() + } +} + +// renderTabs renders the tab navigation bar +func (m deploymentModel) renderTabs() string { + var tabs []string + + activeTabStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("15")). + Background(lipgloss.Color("39")). + Padding(0, 2) + + inactiveTabStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + Background(lipgloss.Color("236")). + Padding(0, 2) + + for i, tabName := range m.tabNames { + var style lipgloss.Style + if i == m.selectedTab { + style = activeTabStyle + } else { + style = inactiveTabStyle + } + tabs = append(tabs, style.Render(tabName)) + } + + return lipgloss.JoinHorizontal(lipgloss.Top, tabs...) +} + +// openCurrentLogFile opens the current log file in the default editor +func (m *deploymentModel) openCurrentLogFile() { + if m.selectedTab < 0 || m.selectedTab >= len(m.tabNames) { + return + } + + tabName := m.tabNames[m.selectedTab] + var logPath string + + if tabName == "provision" { + logPath = m.provisionLogPath + } else if svc, ok := m.services[tabName]; ok { + logPath = svc.LogPath + } + + if logPath == "" { + return + } + + // Try to open in VS Code first + if m.openInVSCode(logPath) { + return + } + + // Fallback: Open file with default application based on OS + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + // #nosec G204 - logPath comes from internally controlled log file paths + cmd = exec.Command("open", logPath) + case "windows": + // #nosec G204 - logPath comes from internally controlled log file paths + cmd = exec.Command("cmd", "/c", "start", logPath) + default: // linux, bsd, etc. + // #nosec G204 - logPath comes from internally controlled log file paths + cmd = exec.Command("xdg-open", logPath) + } + + // Run asynchronously - we don't care about errors here + _ = cmd.Start() +} + +// openInVSCode attempts to open a file in VS Code, returns true if successful +func (m *deploymentModel) openInVSCode(filePath string) bool { + // Determine the VS Code command name based on OS + codeCmd := "code" + if runtime.GOOS == "windows" { + codeCmd = "code.exe" + } + + // Check if VS Code is available in PATH + _, err := exec.LookPath(codeCmd) + if err != nil { + return false + } + + // Try to open the file in VS Code + // #nosec G204 - filePath comes from internally controlled log file paths + cmd := exec.Command(codeCmd, filePath) + err = cmd.Run() + return err == nil +} diff --git a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/up.go b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/up.go index 4c03e3bbac6..2b2c422c632 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/up.go +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/up.go @@ -4,18 +4,15 @@ package cmd import ( + "context" "fmt" "io" - "os" - "os/exec" - "path/filepath" "strings" "sync" "sync/atomic" - "time" "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "github.com/fatih/color" + tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" ) @@ -26,16 +23,16 @@ import ( // building in parallel. type buildGateWriter struct { writer io.Writer - gateChannel chan struct{} + gate *buildGate gateReleased *atomic.Bool serviceName string releaseMu sync.Mutex } -func newBuildGateWriter(w io.Writer, gateChannel chan struct{}, gateReleased *atomic.Bool, serviceName string) *buildGateWriter { +func newBuildGateWriter(w io.Writer, gate *buildGate, gateReleased *atomic.Bool, serviceName string) *buildGateWriter { return &buildGateWriter{ writer: w, - gateChannel: gateChannel, + gate: gate, gateReleased: gateReleased, serviceName: serviceName, } @@ -55,11 +52,7 @@ func (bw *buildGateWriter) Write(p []byte) (n int, err error) { // This indicates the Aspire manifest has been generated and the dotnet build is complete if strings.Contains(content, "Deploying services (azd deploy)") { bw.gateReleased.Store(true) - close(bw.gateChannel) - color.New(color.FgCyan).Printf( - " [%s] Build complete, releasing gate for other services\n", - bw.serviceName, - ) + bw.gate.Open() } } @@ -67,221 +60,85 @@ func (bw *buildGateWriter) Write(p []byte) (n int, err error) { } func newUpCommand() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "up", Short: "Runs azd up in concurrent mode", - RunE: func(cmd *cobra.Command, args []string) error { - // Create a new context that includes the AZD access token - ctx := azdext.WithAccessToken(cmd.Context()) - - // Create a new AZD client - azdClient, err := azdext.NewAzdClient() - if err != nil { - return fmt.Errorf("failed to create azd client: %w", err) - } - - defer azdClient.Close() - - // Get the original project configuration (services as defined in azure.yaml) - projectClient := azdClient.Project() - - // Get the resolved services (includes services generated by importers like Aspire) - resolvedResp, err := projectClient.GetResolvedServices(ctx, &azdext.EmptyRequest{}) - if err != nil { - return fmt.Errorf("failed to get resolved services: %w", err) - } - - services := resolvedResp.GetServices() - fmt.Printf("\nResolved services after import processing (%d):\n", len(services)) - for name, svc := range services { - fmt.Printf(" - %s (host: %s, language: %s)\n", name, svc.Host, svc.Language) - } - - // Get the workflow client - workflowClient := azdClient.Workflow() - - // Step 1: Run provision - color.New(color.FgCyan, color.Bold).Println("\n==> Running provision...") - _, err = workflowClient.Run(ctx, &azdext.RunWorkflowRequest{ - Workflow: &azdext.Workflow{ - Name: "provision", - Steps: []*azdext.WorkflowStep{ - { - Command: &azdext.WorkflowCommand{ - Args: []string{"provision"}, - }, - }, - }, - }, - }) - if err != nil { - return fmt.Errorf("failed to run provision: %w", err) - } - fmt.Println("Provision completed") - - // Step 2: Run deploy concurrently for each service - // Strategy: For Aspire services (containerapp-dotnet host with dotnet language), - // we need to ensure one service builds first to generate the manifest and compile - // shared dependencies. We pick the first Aspire service and wait until it shows - // "Deploying services (azd deploy)" which indicates the AppHost has been built - // and manifest generated. After that, all other services can deploy in parallel safely. - color.New(color.FgCyan, color.Bold).Printf("\n==> Deploying %d services concurrently...\n", len(services)) - - // Create logs directory with unique timestamp - timestamp := time.Now().Format("20060102-150405") - logsDir := filepath.Join(".azure", "logs", "deploy", timestamp) - if err := os.MkdirAll(logsDir, 0755); err != nil { - return fmt.Errorf("failed to create logs directory: %w", err) - } - - var wg sync.WaitGroup - errChan := make(chan error, len(services)) - var activeDeployments atomic.Int32 - - // Build gate for Aspire services (containerapp-dotnet host + dotnet language) - // The first Aspire service will close this channel when "Deploying services (azd deploy)" - // appears, signaling that other Aspire services can start building - buildGateReleased := make(chan struct{}) - - // Track if we need to use the build gate (only for first Aspire service) - firstAspireService := true - var firstAspireMu sync.Mutex - - for serviceName, service := range services { - wg.Add(1) - activeDeployments.Add(1) - - go func(svcName string, svc *azdext.ServiceConfig) { - defer wg.Done() - defer activeDeployments.Add(-1) - - // Create unique log file for this deployment - logFileName := fmt.Sprintf("deploy-%s.log", svcName) - logFilePath := filepath.Join(logsDir, logFileName) - absLogPath, _ := filepath.Abs(logFilePath) - - logFile, err := os.Create(logFilePath) - if err != nil { - errChan <- fmt.Errorf("failed to create log file for service %s: %w", svcName, err) - return - } - defer logFile.Close() - - // Determine if this is an Aspire service (containerapp-dotnet host + dotnet language) - isAspireService := (svc.Host == "containerapp-dotnet" || svc.Host == "containerapp") && - (svc.Language == "dotnet" || svc.Language == "csharp") - - // Track whether this goroutine is the first Aspire service - isFirstAspire := false - gateReleased := &atomic.Bool{} + RunE: runUpCommand, + } + cmd.Flags().Bool("debug", false, "Enable debug logging for azd commands") + return cmd +} - // If Aspire service, check if this is the first one - if isAspireService { - firstAspireMu.Lock() - if firstAspireService { - isFirstAspire = true - firstAspireService = false - firstAspireMu.Unlock() +func runUpCommand(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) - color.New(color.FgYellow).Printf( - " [%s] First Aspire service, waiting for build completion... (logs: %s)\n", - svcName, - absLogPath, - ) - } else { - firstAspireMu.Unlock() + // Create cancellable context for handling Ctrl+C in UI + ctx, cancel := context.WithCancel(ctx) + defer cancel() - color.New(color.FgYellow).Printf( - " [%s] Waiting for first Aspire service to complete build...\n", - svcName, - ) - // Wait for the build gate to be released - <-buildGateReleased + // Initialize AZD client and get services + services, workflowClient, cleanup, err := initializeAzdClient(ctx) + if err != nil { + return err + } + defer cleanup() - color.New(color.FgYellow).Printf( - " [%s] Deploying... (logs: %s)\n", - svcName, - absLogPath, - ) - } - } else { - color.New(color.FgYellow).Printf( - " [%s] Deploying... (logs: %s)\n", - svcName, - absLogPath, - ) - } + // Get debug flag + debug, _ := cmd.Flags().GetBool("debug") - // Ensure gate is released when function exits (if still held) - defer func() { - if isFirstAspire && !gateReleased.Load() { - gateReleased.Store(true) - close(buildGateReleased) - color.New(color.FgCyan).Printf( - " [%s] Build gate released (cleanup)\n", - svcName, - ) - } - }() + // Create Bubble Tea UI with cancel function + ui := createUI(services, cancel) - // Create output writer with build gate monitoring for first Aspire service - var outputWriter io.Writer - if isFirstAspire { - // Use build gate writer that monitors output and releases gate - outputWriter = newBuildGateWriter(logFile, buildGateReleased, gateReleased, svcName) - } else { - outputWriter = logFile - } + // Create and run concurrent deployer + deployer, err := NewConcurrentDeployer(ctx, workflowClient, services, ui, debug) + if err != nil { + return err + } - // Run azd deploy as a subprocess to capture output - cmd := exec.Command("azd", "deploy", svcName) - cmd.Stdout = outputWriter - cmd.Stderr = outputWriter - cmd.Dir, _ = os.Getwd() - // Disable ANSI color escape sequences in logs - // NO_COLOR is respected by the fatih/color library - // AZD_DEBUG_FORCE_NO_TTY ensures terminal detection returns false - cmd.Env = append(os.Environ(), "NO_COLOR=1", "AZD_FORCE_TTY=false") + err = deployer.Deploy() + if summary := deployer.FinalSummary(); summary != "" { + fmt.Print(summary) + } + return err +} - err = cmd.Run() - if err != nil { - color.New(color.FgRed).Printf( - " [%s] Failed (logs: %s)\n", - svcName, - absLogPath, - ) - errChan <- fmt.Errorf("failed to deploy service %s: %w", svcName, err) - return - } +// initializeAzdClient creates an AZD client and retrieves service configuration +func initializeAzdClient(ctx context.Context) ( + map[string]*azdext.ServiceConfig, + azdext.WorkflowServiceClient, + func(), + error, +) { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to create azd client: %w", err) + } - color.New(color.FgGreen).Printf( - " [%s] Completed (logs: %s)\n", - svcName, - absLogPath, - ) - }(serviceName, service) - } + cleanup := func() { azdClient.Close() } - // Wait for all deployments to complete - wg.Wait() - close(errChan) + // Get resolved services (includes services generated by importers like Aspire) + projectClient := azdClient.Project() + resolvedResp, err := projectClient.GetResolvedServices(ctx, &azdext.EmptyRequest{}) + if err != nil { + return nil, nil, cleanup, fmt.Errorf("failed to get resolved services: %w", err) + } - // Check for any errors - var deployErrors []error - for err := range errChan { - deployErrors = append(deployErrors, err) - } + services := resolvedResp.GetServices() + workflowClient := azdClient.Workflow() - if len(deployErrors) > 0 { - color.New(color.FgRed, color.Bold).Println("\n==> Deployment completed with errors:") - for _, err := range deployErrors { - fmt.Printf(" - %v\n", err) - } - return fmt.Errorf("%d service(s) failed to deploy", len(deployErrors)) - } + return services, workflowClient, cleanup, nil +} - color.New(color.FgGreen, color.Bold).Println("\n==> All services deployed successfully!") - return nil - }, +// createUI initializes the Bubble Tea program +func createUI(services map[string]*azdext.ServiceConfig, cancel context.CancelFunc) *tea.Program { + serviceNames := make([]string, 0, len(services)) + for name := range services { + serviceNames = append(serviceNames, name) } + + model := newDeploymentModel(serviceNames, cancel) + return tea.NewProgram( + model, + tea.WithAltScreen(), // Use alternate screen buffer + ) } diff --git a/eng/pipelines/release-ext-microsoft-azd-concurx.yml b/eng/pipelines/release-ext-microsoft-azd-concurx.yml new file mode 100644 index 00000000000..c58fa27cdc4 --- /dev/null +++ b/eng/pipelines/release-ext-microsoft-azd-concurx.yml @@ -0,0 +1,31 @@ +# Continuous deployment trigger +trigger: + branches: + include: + - main + paths: + include: + - cli/azd/extensions/microsoft.azd.concurx + - eng/pipelines/release-azd-extension.yml + - /eng/pipelines/templates/jobs/build-azd-extension.yml + - /eng/pipelines/templates/jobs/cross-build-azd-extension.yml + - /eng/pipelines/templates/variables/image.yml + +pr: + paths: + include: + - cli/azd/extensions/microsoft.azd.concurx + - eng/pipelines/release-azd-extension.yml + - eng/pipelines/templates/steps/publish-cli.yml + exclude: + - cli/azd/docs/** + +extends: + template: /eng/pipelines/templates/stages/1es-redirect.yml + parameters: + stages: + - template: /eng/pipelines/templates/stages/release-azd-extension.yml + parameters: + AzdExtensionId: microsoft.azd.concurx + SanitizedExtensionId: azure-coding-agent + AzdExtensionDirectory: cli/azd/extensions/microsoft.azd.concurx