From 59c7d8e4d974728051ae7df42fdb1e2275bbf53c Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Thu, 20 Nov 2025 08:22:14 +0000 Subject: [PATCH 01/15] Adding extension and grpc layer for services --- .../microsoft.azd.concurx/build.ps1 | 78 +++++++ .../extensions/microsoft.azd.concurx/build.sh | 66 ++++++ .../microsoft.azd.concurx/changelog.md | 3 + .../microsoft.azd.concurx/extension.yaml | 9 + .../extensions/microsoft.azd.concurx/go.mod | 74 ++++++ .../extensions/microsoft.azd.concurx/go.sum | 212 ++++++++++++++++++ .../internal/cmd/root.go | 28 +++ .../microsoft.azd.concurx/internal/cmd/up.go | 55 +++++ .../internal/cmd/version.go | 27 +++ .../extensions/microsoft.azd.concurx/main.go | 31 +++ cli/azd/grpc/proto/project.proto | 10 + .../internal/grpcserver/project_service.go | 64 ++++++ .../grpcserver/project_service_test.go | 15 +- cli/azd/pkg/azdext/project.pb.go | 100 +++++++-- cli/azd/pkg/azdext/project_grpc.pb.go | 46 +++- 15 files changed, 789 insertions(+), 29 deletions(-) create mode 100644 cli/azd/extensions/microsoft.azd.concurx/build.ps1 create mode 100644 cli/azd/extensions/microsoft.azd.concurx/build.sh create mode 100644 cli/azd/extensions/microsoft.azd.concurx/changelog.md create mode 100644 cli/azd/extensions/microsoft.azd.concurx/extension.yaml create mode 100644 cli/azd/extensions/microsoft.azd.concurx/go.mod create mode 100644 cli/azd/extensions/microsoft.azd.concurx/go.sum create mode 100644 cli/azd/extensions/microsoft.azd.concurx/internal/cmd/root.go create mode 100644 cli/azd/extensions/microsoft.azd.concurx/internal/cmd/up.go create mode 100644 cli/azd/extensions/microsoft.azd.concurx/internal/cmd/version.go create mode 100644 cli/azd/extensions/microsoft.azd.concurx/main.go diff --git a/cli/azd/extensions/microsoft.azd.concurx/build.ps1 b/cli/azd/extensions/microsoft.azd.concurx/build.ps1 new file mode 100644 index 00000000000..5ceb60a8bbc --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.concurx/build.ps1 @@ -0,0 +1,78 @@ +# Ensure script fails on any error +$ErrorActionPreference = 'Stop' + +# Get the directory of the script +$EXTENSION_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path + +# Change to the script directory +Set-Location -Path $EXTENSION_DIR + +# Create a safe version of EXTENSION_ID replacing dots with dashes +$EXTENSION_ID_SAFE = $env:EXTENSION_ID -replace '\.', '-' + +# Define output directory +$OUTPUT_DIR = if ($env:OUTPUT_DIR) { $env:OUTPUT_DIR } else { Join-Path $EXTENSION_DIR "bin" } + +# Create output directory if it doesn't exist +if (-not (Test-Path -Path $OUTPUT_DIR)) { + New-Item -ItemType Directory -Path $OUTPUT_DIR | Out-Null +} + +# Get Git commit hash and build date +$COMMIT = git rev-parse HEAD +if ($LASTEXITCODE -ne 0) { + Write-Host "Error: Failed to get git commit hash" + exit 1 +} +$BUILD_DATE = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ") + +# List of OS and architecture combinations +if ($env:EXTENSION_PLATFORM) { + $PLATFORMS = @($env:EXTENSION_PLATFORM) +} +else { + $PLATFORMS = @( + "windows/amd64", + "windows/arm64", + "darwin/amd64", + "darwin/arm64", + "linux/amd64", + "linux/arm64" + ) +} + +$APP_PATH = "$env:EXTENSION_ID/internal/cmd" + +# Loop through platforms and build +foreach ($PLATFORM in $PLATFORMS) { + $OS, $ARCH = $PLATFORM -split '/' + + $OUTPUT_NAME = Join-Path $OUTPUT_DIR "$EXTENSION_ID_SAFE-$OS-$ARCH" + + if ($OS -eq "windows") { + $OUTPUT_NAME += ".exe" + } + + Write-Host "Building for $OS/$ARCH..." + + # Delete the output file if it already exists + if (Test-Path -Path $OUTPUT_NAME) { + Remove-Item -Path $OUTPUT_NAME -Force + } + + # Set environment variables for Go build + $env:GOOS = $OS + $env:GOARCH = $ARCH + + go build ` + -ldflags="-X '$APP_PATH.Version=$env:EXTENSION_VERSION' -X '$APP_PATH.Commit=$COMMIT' -X '$APP_PATH.BuildDate=$BUILD_DATE'" ` + -o $OUTPUT_NAME + + if ($LASTEXITCODE -ne 0) { + Write-Host "An error occurred while building for $OS/$ARCH" + exit 1 + } +} + +Write-Host "Build completed successfully!" +Write-Host "Binaries are located in the $OUTPUT_DIR directory." diff --git a/cli/azd/extensions/microsoft.azd.concurx/build.sh b/cli/azd/extensions/microsoft.azd.concurx/build.sh new file mode 100644 index 00000000000..f1a995ec5e9 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.concurx/build.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +# Get the directory of the script +EXTENSION_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Change to the script directory +cd "$EXTENSION_DIR" || exit + +# Create a safe version of EXTENSION_ID replacing dots with dashes +EXTENSION_ID_SAFE="${EXTENSION_ID//./-}" + +# Define output directory +OUTPUT_DIR="${OUTPUT_DIR:-$EXTENSION_DIR/bin}" + +# Create output and target directories if they don't exist +mkdir -p "$OUTPUT_DIR" + +# Get Git commit hash and build date +COMMIT=$(git rev-parse HEAD) +BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) + +# List of OS and architecture combinations +if [ -n "$EXTENSION_PLATFORM" ]; then + PLATFORMS=("$EXTENSION_PLATFORM") +else + PLATFORMS=( + "windows/amd64" + "windows/arm64" + "darwin/amd64" + "darwin/arm64" + "linux/amd64" + "linux/arm64" + ) +fi + +APP_PATH="$EXTENSION_ID/internal/cmd" + +# Loop through platforms and build +for PLATFORM in "${PLATFORMS[@]}"; do + OS=$(echo "$PLATFORM" | cut -d'/' -f1) + ARCH=$(echo "$PLATFORM" | cut -d'/' -f2) + + OUTPUT_NAME="$OUTPUT_DIR/$EXTENSION_ID_SAFE-$OS-$ARCH" + + if [ "$OS" = "windows" ]; then + OUTPUT_NAME+='.exe' + fi + + echo "Building for $OS/$ARCH..." + + # Delete the output file if it already exists + [ -f "$OUTPUT_NAME" ] && rm -f "$OUTPUT_NAME" + + # Set environment variables for Go build + GOOS=$OS GOARCH=$ARCH go build \ + -ldflags="-X '$APP_PATH.Version=$EXTENSION_VERSION' -X '$APP_PATH.Commit=$COMMIT' -X '$APP_PATH.BuildDate=$BUILD_DATE'" \ + -o "$OUTPUT_NAME" + + if [ $? -ne 0 ]; then + echo "An error occurred while building for $OS/$ARCH" + exit 1 + fi +done + +echo "Build completed successfully!" +echo "Binaries are located in the $OUTPUT_DIR directory." diff --git a/cli/azd/extensions/microsoft.azd.concurx/changelog.md b/cli/azd/extensions/microsoft.azd.concurx/changelog.md new file mode 100644 index 00000000000..b88d613cce0 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.concurx/changelog.md @@ -0,0 +1,3 @@ +# Release History + +## 0.0.1 - Initial Version \ No newline at end of file diff --git a/cli/azd/extensions/microsoft.azd.concurx/extension.yaml b/cli/azd/extensions/microsoft.azd.concurx/extension.yaml new file mode 100644 index 00000000000..60db89236af --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.concurx/extension.yaml @@ -0,0 +1,9 @@ +capabilities: + - custom-commands +description: Concurrent execution for azd deployment +displayName: Concurx +id: microsoft.azd.concurx +language: go +namespace: concurx +usage: azd concurx [options] +version: 0.0.1 diff --git a/cli/azd/extensions/microsoft.azd.concurx/go.mod b/cli/azd/extensions/microsoft.azd.concurx/go.mod new file mode 100644 index 00000000000..d968cd6a711 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.concurx/go.mod @@ -0,0 +1,74 @@ +module concurx + +go 1.25 + +replace github.com/azure/azure-dev/cli/azd => ../../ + +require ( + github.com/azure/azure-dev/cli/azd v0.0.0-00010101000000-000000000000 + github.com/fatih/color v1.18.0 + github.com/spf13/cobra v1.10.1 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/alecthomas/chroma/v2 v2.20.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + 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 + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + 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/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 + github.com/golobby/container/v3 v3.3.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect + 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-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/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 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/sdk v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/term v0.36.0 // indirect + golang.org/x/text v0.30.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 + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/cli/azd/extensions/microsoft.azd.concurx/go.sum b/cli/azd/extensions/microsoft.azd.concurx/go.sum new file mode 100644 index 00000000000..a1cfd953fd8 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.concurx/go.sum @@ -0,0 +1,212 @@ +code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= +github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b h1:g9SuFmxM/WucQFKTMSP+irxyf5m0RiUJreBDhGI6jSA= +github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b/go.mod h1:XjvqMUpGd3Xn9Jtzk/4GEBCSoBX0eB2RyriXgne0IdM= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= +github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +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/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= +github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= +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/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= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +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/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= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golobby/container/v3 v3.3.2 h1:7u+RgNnsdVlhGoS8gY4EXAG601vpMMzLZlYqSp77Quw= +github.com/golobby/container/v3 v3.3.2/go.mod h1:RDdKpnKpV1Of11PFBe7Dxc2C1k2KaLE4FD47FflAmj0= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +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-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= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/microsoft/ApplicationInsights-Go v0.4.4 h1:G4+H9WNs6ygSCe6sUyxRc2U81TI5Es90b2t/MwX5KqY= +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/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= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d h1:NqRhLdNVlozULwM1B3VaHhcXYSgrOAv8V5BE65om+1Q= +github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d/go.mod h1:cxIIfNMTwff8f/ZvRouvWYF6wOoO7nj99neWSx2q/Es= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tedsuo/ifrit v0.0.0-20180802180643-bea94bb476cc/go.mod h1:eyZnKCc955uh98WQvzOm0dgAeLnf2O0Rz0LPoC5ze+0= +github.com/theckman/yacspin v0.13.12 h1:CdZ57+n0U6JMuh2xqjnjRq5Haj6v1ner2djtLQRzJr4= +github.com/theckman/yacspin v0.13.12/go.mod h1:Rd2+oG2LmQi5f3zC3yeZAOl245z8QOvrH4OPOJNZxLg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= +golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +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.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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +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= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= +google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/root.go b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/root.go new file mode 100644 index 00000000000..1097db51e80 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/root.go @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "github.com/spf13/cobra" +) + +func NewRootCommand() *cobra.Command { + rootCmd := &cobra.Command{ + Use: "azd concurx [options]", + Short: "Concurrent execution for azd deployment", + SilenceUsage: true, + SilenceErrors: true, + CompletionOptions: cobra.CompletionOptions{ + DisableDefaultCmd: true, + }, + } + + rootCmd.SetHelpCommand(&cobra.Command{Hidden: true}) + rootCmd.PersistentFlags().Bool("debug", false, "Enable debug mode") + + rootCmd.AddCommand(newUpCommand()) + rootCmd.AddCommand(newVersionCommand()) + + return rootCmd +} diff --git a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/up.go b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/up.go new file mode 100644 index 00000000000..1963e1aeee9 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/up.go @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +func newUpCommand() *cobra.Command { + return &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) + projectResp, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil { + return fmt.Errorf("failed to get project: %w", err) + } + + fmt.Printf("Project: %s\n", projectResp.GetProject().Name) + fmt.Printf("\nServices from azure.yaml (%d):\n", len(projectResp.GetProject().Services)) + for name := range projectResp.GetProject().Services { + fmt.Printf(" - %s\n", name) + } + + // Get the resolved services (includes services generated by importers like Aspire) + resolvedResp, err := azdClient.Project().GetResolvedServices(ctx, &azdext.EmptyRequest{}) + if err != nil { + return fmt.Errorf("failed to get resolved services: %w", err) + } + + fmt.Printf("\nResolved services after import processing (%d):\n", len(resolvedResp.GetServices())) + for name, svc := range resolvedResp.GetServices() { + fmt.Printf(" - %s (host: %s, language: %s)\n", name, svc.Host, svc.Language) + } + + return nil + }, + } +} diff --git a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/version.go b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/version.go new file mode 100644 index 00000000000..715323a6c5c --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/version.go @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var ( + // Populated at build time + Version = "dev" // Default value for development builds + Commit = "none" + BuildDate = "unknown" +) + +func newVersionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Prints the version of the application", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("Version: %s\nCommit: %s\nBuild Date: %s\n", Version, Commit, BuildDate) + }, + } +} diff --git a/cli/azd/extensions/microsoft.azd.concurx/main.go b/cli/azd/extensions/microsoft.azd.concurx/main.go new file mode 100644 index 00000000000..839f2c79a41 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.concurx/main.go @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package main + +import ( + "context" + "os" + + "concurx/internal/cmd" + + "github.com/fatih/color" +) + +func init() { + forceColorVal, has := os.LookupEnv("FORCE_COLOR") + if has && forceColorVal == "1" { + color.NoColor = false + } +} + +func main() { + // Execute the root command + ctx := context.Background() + rootCmd := cmd.NewRootCommand() + + if err := rootCmd.ExecuteContext(ctx); err != nil { + color.Red("Error: %v", err) + os.Exit(1) + } +} diff --git a/cli/azd/grpc/proto/project.proto b/cli/azd/grpc/proto/project.proto index 62df83af009..8cc3643f7bc 100644 --- a/cli/azd/grpc/proto/project.proto +++ b/cli/azd/grpc/proto/project.proto @@ -15,6 +15,10 @@ service ProjectService { // AddService adds a new service to the project. rpc AddService(AddServiceRequest) returns (EmptyResponse); + + // GetResolvedServices gets the resolved list of services after processing any importers (e.g., Aspire projects). + // This returns the actual services that will be deployed, including those generated by importers. + rpc GetResolvedServices(EmptyRequest) returns (GetResolvedServicesResponse); } // GetProjectResponse message definition @@ -26,3 +30,9 @@ message GetProjectResponse { message AddServiceRequest { ServiceConfig service = 1; } + +// GetResolvedServicesResponse message definition +message GetResolvedServicesResponse { + // Map of service name to service configuration + map services = 1; +} diff --git a/cli/azd/internal/grpcserver/project_service.go b/cli/azd/internal/grpcserver/project_service.go index 04ffadc9ad1..10e209a7060 100644 --- a/cli/azd/internal/grpcserver/project_service.go +++ b/cli/azd/internal/grpcserver/project_service.go @@ -20,15 +20,18 @@ type projectService struct { lazyAzdContext *lazy.Lazy[*azdcontext.AzdContext] lazyEnvManager *lazy.Lazy[environment.Manager] + importManager *project.ImportManager } func NewProjectService( lazyAzdContext *lazy.Lazy[*azdcontext.AzdContext], lazyEnvManager *lazy.Lazy[environment.Manager], + importManager *project.ImportManager, ) azdext.ProjectServiceServer { return &projectService{ lazyAzdContext: lazyAzdContext, lazyEnvManager: lazyEnvManager, + importManager: importManager, } } @@ -125,3 +128,64 @@ func (s *projectService) AddService(ctx context.Context, req *azdext.AddServiceR return &azdext.EmptyResponse{}, nil } + +// GetResolvedServices returns the resolved list of services after processing any importers (e.g., Aspire projects). +// This includes services generated by importers like Aspire AppHost projects. +func (s *projectService) GetResolvedServices( + ctx context.Context, + req *azdext.EmptyRequest, +) (*azdext.GetResolvedServicesResponse, error) { + azdContext, err := s.lazyAzdContext.GetValue() + if err != nil { + return nil, err + } + + projectConfig, err := project.Load(ctx, azdContext.ProjectPath()) + if err != nil { + return nil, err + } + + envKeyMapper := func(env string) string { + return "" + } + + defaultEnvironment, err := azdContext.GetDefaultEnvironmentName() + if err != nil { + return nil, err + } + + envManager, err := s.lazyEnvManager.GetValue() + if err != nil { + return nil, err + } + + if defaultEnvironment != "" { + env, err := envManager.Get(ctx, defaultEnvironment) + if err == nil && env != nil { + envKeyMapper = env.Getenv + } + } + + // Get resolved services using ImportManager + servicesStable, err := s.importManager.ServiceStable(ctx, projectConfig) + if err != nil { + return nil, fmt.Errorf("resolving services: %w", err) + } + + // Convert to proto format + protoServices := make(map[string]*azdext.ServiceConfig) + for _, service := range servicesStable { + var protoService *azdext.ServiceConfig + + // Use mapper with environment variable resolver + if err := mapper.WithResolver(envKeyMapper).Convert(service, &protoService); err != nil { + return nil, fmt.Errorf("converting service config to proto: %w", err) + } + + protoServices[service.Name] = protoService + } + + return &azdext.GetResolvedServicesResponse{ + Services: protoServices, + }, nil +} diff --git a/cli/azd/internal/grpcserver/project_service_test.go b/cli/azd/internal/grpcserver/project_service_test.go index ff55a66ef13..42e45ca4f2f 100644 --- a/cli/azd/internal/grpcserver/project_service_test.go +++ b/cli/azd/internal/grpcserver/project_service_test.go @@ -32,8 +32,9 @@ func Test_ProjectService_NoProject(t *testing.T) { return nil, azdcontext.ErrNoProject }) - // Create the service. - service := NewProjectService(lazyAzdContext, lazyEnvManager) + // Create the service with ImportManager. + importManager := project.NewImportManager(&project.DotNetImporter{}) + service := NewProjectService(lazyAzdContext, lazyEnvManager, importManager) _, err := service.Get(*mockContext.Context, &azdext.EmptyRequest{}) require.Error(t, err) } @@ -76,8 +77,9 @@ func Test_ProjectService_Flow(t *testing.T) { err = envManager.Save(*mockContext.Context, testEnv1) require.NoError(t, err) - // Create the service. - service := NewProjectService(lazyAzdContext, lazyEnvManager) + // Create the service with ImportManager. + importManager := project.NewImportManager(&project.DotNetImporter{}) + service := NewProjectService(lazyAzdContext, lazyEnvManager, importManager) // Test: Retrieve project details. getResponse, err := service.Get(*mockContext.Context, &azdext.EmptyRequest{}) @@ -112,8 +114,9 @@ func Test_ProjectService_AddService(t *testing.T) { lazyAzdContext := lazy.From(azdContext) lazyEnvManager := lazy.From(envManager) - // Create the project service. - service := NewProjectService(lazyAzdContext, lazyEnvManager) + // Create the project service with ImportManager. + importManager := project.NewImportManager(&project.DotNetImporter{}) + service := NewProjectService(lazyAzdContext, lazyEnvManager, importManager) // Prepare a new service addition request. serviceRequest := &azdext.AddServiceRequest{ diff --git a/cli/azd/pkg/azdext/project.pb.go b/cli/azd/pkg/azdext/project.pb.go index c53579c6577..a28b6e16f94 100644 --- a/cli/azd/pkg/azdext/project.pb.go +++ b/cli/azd/pkg/azdext/project.pb.go @@ -114,6 +114,52 @@ func (x *AddServiceRequest) GetService() *ServiceConfig { return nil } +// GetResolvedServicesResponse message definition +type GetResolvedServicesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Map of service name to service configuration + Services map[string]*ServiceConfig `protobuf:"bytes,1,rep,name=services,proto3" json:"services,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetResolvedServicesResponse) Reset() { + *x = GetResolvedServicesResponse{} + mi := &file_project_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetResolvedServicesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetResolvedServicesResponse) ProtoMessage() {} + +func (x *GetResolvedServicesResponse) ProtoReflect() protoreflect.Message { + mi := &file_project_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetResolvedServicesResponse.ProtoReflect.Descriptor instead. +func (*GetResolvedServicesResponse) Descriptor() ([]byte, []int) { + return file_project_proto_rawDescGZIP(), []int{2} +} + +func (x *GetResolvedServicesResponse) GetServices() map[string]*ServiceConfig { + if x != nil { + return x.Services + } + return nil +} + var File_project_proto protoreflect.FileDescriptor const file_project_proto_rawDesc = "" + @@ -122,11 +168,17 @@ const file_project_proto_rawDesc = "" + "\x12GetProjectResponse\x12/\n" + "\aproject\x18\x01 \x01(\v2\x15.azdext.ProjectConfigR\aproject\"D\n" + "\x11AddServiceRequest\x12/\n" + - "\aservice\x18\x01 \x01(\v2\x15.azdext.ServiceConfigR\aservice2\x89\x01\n" + + "\aservice\x18\x01 \x01(\v2\x15.azdext.ServiceConfigR\aservice\"\xc0\x01\n" + + "\x1bGetResolvedServicesResponse\x12M\n" + + "\bservices\x18\x01 \x03(\v21.azdext.GetResolvedServicesResponse.ServicesEntryR\bservices\x1aR\n" + + "\rServicesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12+\n" + + "\x05value\x18\x02 \x01(\v2\x15.azdext.ServiceConfigR\x05value:\x028\x012\xdb\x01\n" + "\x0eProjectService\x127\n" + "\x03Get\x12\x14.azdext.EmptyRequest\x1a\x1a.azdext.GetProjectResponse\x12>\n" + "\n" + - "AddService\x12\x19.azdext.AddServiceRequest\x1a\x15.azdext.EmptyResponseB/Z-github.com/azure/azure-dev/cli/azd/pkg/azdextb\x06proto3" + "AddService\x12\x19.azdext.AddServiceRequest\x1a\x15.azdext.EmptyResponse\x12P\n" + + "\x13GetResolvedServices\x12\x14.azdext.EmptyRequest\x1a#.azdext.GetResolvedServicesResponseB/Z-github.com/azure/azure-dev/cli/azd/pkg/azdextb\x06proto3" var ( file_project_proto_rawDescOnce sync.Once @@ -140,27 +192,33 @@ func file_project_proto_rawDescGZIP() []byte { return file_project_proto_rawDescData } -var file_project_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_project_proto_msgTypes = make([]protoimpl.MessageInfo, 4) var file_project_proto_goTypes = []any{ - (*GetProjectResponse)(nil), // 0: azdext.GetProjectResponse - (*AddServiceRequest)(nil), // 1: azdext.AddServiceRequest - (*ProjectConfig)(nil), // 2: azdext.ProjectConfig - (*ServiceConfig)(nil), // 3: azdext.ServiceConfig - (*EmptyRequest)(nil), // 4: azdext.EmptyRequest - (*EmptyResponse)(nil), // 5: azdext.EmptyResponse + (*GetProjectResponse)(nil), // 0: azdext.GetProjectResponse + (*AddServiceRequest)(nil), // 1: azdext.AddServiceRequest + (*GetResolvedServicesResponse)(nil), // 2: azdext.GetResolvedServicesResponse + nil, // 3: azdext.GetResolvedServicesResponse.ServicesEntry + (*ProjectConfig)(nil), // 4: azdext.ProjectConfig + (*ServiceConfig)(nil), // 5: azdext.ServiceConfig + (*EmptyRequest)(nil), // 6: azdext.EmptyRequest + (*EmptyResponse)(nil), // 7: azdext.EmptyResponse } var file_project_proto_depIdxs = []int32{ - 2, // 0: azdext.GetProjectResponse.project:type_name -> azdext.ProjectConfig - 3, // 1: azdext.AddServiceRequest.service:type_name -> azdext.ServiceConfig - 4, // 2: azdext.ProjectService.Get:input_type -> azdext.EmptyRequest - 1, // 3: azdext.ProjectService.AddService:input_type -> azdext.AddServiceRequest - 0, // 4: azdext.ProjectService.Get:output_type -> azdext.GetProjectResponse - 5, // 5: azdext.ProjectService.AddService:output_type -> azdext.EmptyResponse - 4, // [4:6] is the sub-list for method output_type - 2, // [2:4] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name + 4, // 0: azdext.GetProjectResponse.project:type_name -> azdext.ProjectConfig + 5, // 1: azdext.AddServiceRequest.service:type_name -> azdext.ServiceConfig + 3, // 2: azdext.GetResolvedServicesResponse.services:type_name -> azdext.GetResolvedServicesResponse.ServicesEntry + 5, // 3: azdext.GetResolvedServicesResponse.ServicesEntry.value:type_name -> azdext.ServiceConfig + 6, // 4: azdext.ProjectService.Get:input_type -> azdext.EmptyRequest + 1, // 5: azdext.ProjectService.AddService:input_type -> azdext.AddServiceRequest + 6, // 6: azdext.ProjectService.GetResolvedServices:input_type -> azdext.EmptyRequest + 0, // 7: azdext.ProjectService.Get:output_type -> azdext.GetProjectResponse + 7, // 8: azdext.ProjectService.AddService:output_type -> azdext.EmptyResponse + 2, // 9: azdext.ProjectService.GetResolvedServices:output_type -> azdext.GetResolvedServicesResponse + 7, // [7:10] is the sub-list for method output_type + 4, // [4:7] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name } func init() { file_project_proto_init() } @@ -175,7 +233,7 @@ func file_project_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_project_proto_rawDesc), len(file_project_proto_rawDesc)), NumEnums: 0, - NumMessages: 2, + NumMessages: 4, NumExtensions: 0, NumServices: 1, }, diff --git a/cli/azd/pkg/azdext/project_grpc.pb.go b/cli/azd/pkg/azdext/project_grpc.pb.go index 95824d4a316..4bee10a85fb 100644 --- a/cli/azd/pkg/azdext/project_grpc.pb.go +++ b/cli/azd/pkg/azdext/project_grpc.pb.go @@ -22,8 +22,9 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - ProjectService_Get_FullMethodName = "/azdext.ProjectService/Get" - ProjectService_AddService_FullMethodName = "/azdext.ProjectService/AddService" + ProjectService_Get_FullMethodName = "/azdext.ProjectService/Get" + ProjectService_AddService_FullMethodName = "/azdext.ProjectService/AddService" + ProjectService_GetResolvedServices_FullMethodName = "/azdext.ProjectService/GetResolvedServices" ) // ProjectServiceClient is the client API for ProjectService service. @@ -36,6 +37,9 @@ type ProjectServiceClient interface { Get(ctx context.Context, in *EmptyRequest, opts ...grpc.CallOption) (*GetProjectResponse, error) // AddService adds a new service to the project. AddService(ctx context.Context, in *AddServiceRequest, opts ...grpc.CallOption) (*EmptyResponse, error) + // GetResolvedServices gets the resolved list of services after processing any importers (e.g., Aspire projects). + // This returns the actual services that will be deployed, including those generated by importers. + GetResolvedServices(ctx context.Context, in *EmptyRequest, opts ...grpc.CallOption) (*GetResolvedServicesResponse, error) } type projectServiceClient struct { @@ -66,6 +70,16 @@ func (c *projectServiceClient) AddService(ctx context.Context, in *AddServiceReq return out, nil } +func (c *projectServiceClient) GetResolvedServices(ctx context.Context, in *EmptyRequest, opts ...grpc.CallOption) (*GetResolvedServicesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetResolvedServicesResponse) + err := c.cc.Invoke(ctx, ProjectService_GetResolvedServices_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // ProjectServiceServer is the server API for ProjectService service. // All implementations must embed UnimplementedProjectServiceServer // for forward compatibility. @@ -76,6 +90,9 @@ type ProjectServiceServer interface { Get(context.Context, *EmptyRequest) (*GetProjectResponse, error) // AddService adds a new service to the project. AddService(context.Context, *AddServiceRequest) (*EmptyResponse, error) + // GetResolvedServices gets the resolved list of services after processing any importers (e.g., Aspire projects). + // This returns the actual services that will be deployed, including those generated by importers. + GetResolvedServices(context.Context, *EmptyRequest) (*GetResolvedServicesResponse, error) mustEmbedUnimplementedProjectServiceServer() } @@ -92,6 +109,9 @@ func (UnimplementedProjectServiceServer) Get(context.Context, *EmptyRequest) (*G func (UnimplementedProjectServiceServer) AddService(context.Context, *AddServiceRequest) (*EmptyResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method AddService not implemented") } +func (UnimplementedProjectServiceServer) GetResolvedServices(context.Context, *EmptyRequest) (*GetResolvedServicesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetResolvedServices not implemented") +} func (UnimplementedProjectServiceServer) mustEmbedUnimplementedProjectServiceServer() {} func (UnimplementedProjectServiceServer) testEmbeddedByValue() {} @@ -149,6 +169,24 @@ func _ProjectService_AddService_Handler(srv interface{}, ctx context.Context, de return interceptor(ctx, in, info, handler) } +func _ProjectService_GetResolvedServices_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(EmptyRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ProjectServiceServer).GetResolvedServices(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ProjectService_GetResolvedServices_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ProjectServiceServer).GetResolvedServices(ctx, req.(*EmptyRequest)) + } + return interceptor(ctx, in, info, handler) +} + // ProjectService_ServiceDesc is the grpc.ServiceDesc for ProjectService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -164,6 +202,10 @@ var ProjectService_ServiceDesc = grpc.ServiceDesc{ MethodName: "AddService", Handler: _ProjectService_AddService_Handler, }, + { + MethodName: "GetResolvedServices", + Handler: _ProjectService_GetResolvedServices_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "project.proto", From f2fc08739c7ef22855a0f2f463950f36464159ee Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Thu, 20 Nov 2025 08:24:36 +0000 Subject: [PATCH 02/15] ignore binary --- .gitignore | 2 ++ cli/azd/extensions/microsoft.azd.concurx/README.md | 3 +++ 2 files changed, 5 insertions(+) create mode 100644 cli/azd/extensions/microsoft.azd.concurx/README.md diff --git a/.gitignore b/.gitignore index 660ecdae526..06a094b7e3e 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,5 @@ 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 diff --git a/cli/azd/extensions/microsoft.azd.concurx/README.md b/cli/azd/extensions/microsoft.azd.concurx/README.md new file mode 100644 index 00000000000..2fb8c85c518 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.concurx/README.md @@ -0,0 +1,3 @@ +# `azd` ConcurX Extension + +An AZD extension to make deployments concurrently. From 84e396c70748723d72653237f69da3eb910e2e5b Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Thu, 20 Nov 2025 21:25:00 +0000 Subject: [PATCH 03/15] wip --- .../microsoft.azd.concurx/internal/cmd/up.go | 117 ++++++++++++++++-- 1 file changed, 105 insertions(+), 12 deletions(-) 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 1963e1aeee9..1d062041299 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/up.go +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/up.go @@ -4,9 +4,12 @@ package cmd import ( + "context" "fmt" + "sync" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/fatih/color" "github.com/spf13/cobra" ) @@ -27,28 +30,118 @@ func newUpCommand() *cobra.Command { defer azdClient.Close() // Get the original project configuration (services as defined in azure.yaml) - projectResp, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + 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 project: %w", err) + return fmt.Errorf("failed to get resolved services: %w", err) } - fmt.Printf("Project: %s\n", projectResp.GetProject().Name) - fmt.Printf("\nServices from azure.yaml (%d):\n", len(projectResp.GetProject().Services)) - for name := range projectResp.GetProject().Services { - fmt.Printf(" - %s\n", name) + 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 resolved services (includes services generated by importers like Aspire) - resolvedResp, err := azdClient.Project().GetResolvedServices(ctx, &azdext.EmptyRequest{}) + // Get the workflow client + workflowClient := azdClient.Workflow() + + // Step 1: Run package + color.New(color.FgCyan, color.Bold).Println("\n==> Running package...") + _, err = workflowClient.Run(ctx, &azdext.RunWorkflowRequest{ + Workflow: &azdext.Workflow{ + Name: "package", + Steps: []*azdext.WorkflowStep{ + { + Command: &azdext.WorkflowCommand{ + Args: []string{"package"}, + }, + }, + }, + }, + }) if err != nil { - return fmt.Errorf("failed to get resolved services: %w", err) + return fmt.Errorf("failed to run package: %w", err) } + fmt.Println("Package completed") - fmt.Printf("\nResolved services after import processing (%d):\n", len(resolvedResp.GetServices())) - for name, svc := range resolvedResp.GetServices() { - fmt.Printf(" - %s (host: %s, language: %s)\n", name, svc.Host, svc.Language) + // Step 2: 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 3: Run deploy concurrently for each service + color.New(color.FgCyan, color.Bold).Printf("\n==> Deploying %d services concurrently...\n", len(services)) + + var wg sync.WaitGroup + errChan := make(chan error, len(services)) + + for serviceName := range services { + wg.Add(1) + go func(svcName string) { + defer wg.Done() + + // Create a new independent context for each deployment + // This prevents context cancellation from affecting other concurrent deployments + deployCtx := azdext.WithAccessToken(context.Background()) + + color.New(color.FgYellow).Printf(" [%s] Starting deployment...\n", svcName) + + _, err := workflowClient.Run(deployCtx, &azdext.RunWorkflowRequest{ + Workflow: &azdext.Workflow{ + Name: fmt.Sprintf("deploy-%s", svcName), + Steps: []*azdext.WorkflowStep{ + { + Command: &azdext.WorkflowCommand{ + Args: []string{"deploy", svcName}, + }, + }, + }, + }, + }) + + if err != nil { + color.New(color.FgRed).Printf(" [%s] Deployment failed: %v\n", svcName, err) + errChan <- fmt.Errorf("failed to deploy service %s: %w", svcName, err) + return + } + + color.New(color.FgGreen).Printf(" [%s] Deployment completed successfully!\n", svcName) + }(serviceName) + } // Wait for all deployments to complete + wg.Wait() + close(errChan) + + // Check for any errors + var deployErrors []error + for err := range errChan { + deployErrors = append(deployErrors, err) + } + + 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)) } + color.New(color.FgGreen, color.Bold).Println("\n==> All services deployed successfully!") return nil }, } From 051b6d0fabc010f09a0f4954676fef752659cd59 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Sun, 23 Nov 2025 21:19:50 +0000 Subject: [PATCH 04/15] wip --- .../microsoft.azd.concurx/internal/cmd/up.go | 231 ++++++++++++++---- 1 file changed, 185 insertions(+), 46 deletions(-) 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 1d062041299..4c03e3bbac6 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/up.go +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/up.go @@ -4,15 +4,68 @@ 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" "github.com/spf13/cobra" ) +// buildGateWriter wraps an io.Writer and monitors written content. +// For Aspire/dotnet services, it closes a channel when it detects "Deploying services (azd deploy)" +// in the output, which indicates that the AppHost and dotnet project have been successfully built +// and the manifest has been generated. At this point, it's safe for other dotnet services to start +// building in parallel. +type buildGateWriter struct { + writer io.Writer + gateChannel chan struct{} + gateReleased *atomic.Bool + serviceName string + releaseMu sync.Mutex +} + +func newBuildGateWriter(w io.Writer, gateChannel chan struct{}, gateReleased *atomic.Bool, serviceName string) *buildGateWriter { + return &buildGateWriter{ + writer: w, + gateChannel: gateChannel, + gateReleased: gateReleased, + serviceName: serviceName, + } +} + +func (bw *buildGateWriter) Write(p []byte) (n int, err error) { + // Always write to the underlying writer first + n, err = bw.writer.Write(p) + + // Check if we should release the gate + bw.releaseMu.Lock() + defer bw.releaseMu.Unlock() + + if !bw.gateReleased.Load() { + content := string(p) + // Look for "Deploying services (azd deploy)" in the output + // 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, + ) + } + } + + return n, err +} + func newUpCommand() *cobra.Command { return &cobra.Command{ Use: "up", @@ -47,26 +100,7 @@ func newUpCommand() *cobra.Command { // Get the workflow client workflowClient := azdClient.Workflow() - // Step 1: Run package - color.New(color.FgCyan, color.Bold).Println("\n==> Running package...") - _, err = workflowClient.Run(ctx, &azdext.RunWorkflowRequest{ - Workflow: &azdext.Workflow{ - Name: "package", - Steps: []*azdext.WorkflowStep{ - { - Command: &azdext.WorkflowCommand{ - Args: []string{"package"}, - }, - }, - }, - }, - }) - if err != nil { - return fmt.Errorf("failed to run package: %w", err) - } - fmt.Println("Package completed") - - // Step 2: Run provision + // Step 1: Run provision color.New(color.FgCyan, color.Bold).Println("\n==> Running provision...") _, err = workflowClient.Run(ctx, &azdext.RunWorkflowRequest{ Workflow: &azdext.Workflow{ @@ -85,45 +119,150 @@ func newUpCommand() *cobra.Command { } fmt.Println("Provision completed") - // Step 3: Run deploy concurrently for each service + // 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{}) - for serviceName := range services { + // 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) - go func(svcName string) { + activeDeployments.Add(1) + + go func(svcName string, svc *azdext.ServiceConfig) { defer wg.Done() + defer activeDeployments.Add(-1) - // Create a new independent context for each deployment - // This prevents context cancellation from affecting other concurrent deployments - deployCtx := azdext.WithAccessToken(context.Background()) - - color.New(color.FgYellow).Printf(" [%s] Starting deployment...\n", svcName) - - _, err := workflowClient.Run(deployCtx, &azdext.RunWorkflowRequest{ - Workflow: &azdext.Workflow{ - Name: fmt.Sprintf("deploy-%s", svcName), - Steps: []*azdext.WorkflowStep{ - { - Command: &azdext.WorkflowCommand{ - Args: []string{"deploy", svcName}, - }, - }, - }, - }, - }) + // 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{} + + // If Aspire service, check if this is the first one + if isAspireService { + firstAspireMu.Lock() + if firstAspireService { + isFirstAspire = true + firstAspireService = false + firstAspireMu.Unlock() + + color.New(color.FgYellow).Printf( + " [%s] First Aspire service, waiting for build completion... (logs: %s)\n", + svcName, + absLogPath, + ) + } else { + firstAspireMu.Unlock() + + 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 + + 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, + ) + } + + // 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 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 + } + + // 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 = cmd.Run() if err != nil { - color.New(color.FgRed).Printf(" [%s] Deployment failed: %v\n", svcName, err) + color.New(color.FgRed).Printf( + " [%s] Failed (logs: %s)\n", + svcName, + absLogPath, + ) errChan <- fmt.Errorf("failed to deploy service %s: %w", svcName, err) return } - color.New(color.FgGreen).Printf(" [%s] Deployment completed successfully!\n", svcName) - }(serviceName) - } // Wait for all deployments to complete + color.New(color.FgGreen).Printf( + " [%s] Completed (logs: %s)\n", + svcName, + absLogPath, + ) + }(serviceName, service) + } + + // Wait for all deployments to complete wg.Wait() close(errChan) From 33aecc5034d28eee6c14b809e9fae5c3d121f6b5 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Thu, 11 Dec 2025 21:10:58 +0000 Subject: [PATCH 05/15] cspell --- cli/azd/.vscode/cspell-azd-dictionary.txt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cli/azd/.vscode/cspell-azd-dictionary.txt b/cli/azd/.vscode/cspell-azd-dictionary.txt index c0a270a06b9..185cd456b19 100644 --- a/cli/azd/.vscode/cspell-azd-dictionary.txt +++ b/cli/azd/.vscode/cspell-azd-dictionary.txt @@ -81,6 +81,7 @@ cmdrecord cmdsubst Cobo cognitiveservices +concurx conditionalize consolesize containeragent @@ -151,6 +152,7 @@ jmes jquery keychain kubelogin +langchain langchaingo LASTEXITCODE ldflags @@ -285,5 +287,4 @@ wireinject yacspin yamlnode ymlt -zerr -langchain +zerr \ No newline at end of file From fa333be42ff585c54ee97c6bfd8dbef64a05dcec Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Thu, 11 Dec 2025 21:16:59 +0000 Subject: [PATCH 06/15] updates to mod --- cli/azd/extensions/microsoft.azd.concurx/go.mod | 8 ++++---- cli/azd/extensions/microsoft.azd.concurx/go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cli/azd/extensions/microsoft.azd.concurx/go.mod b/cli/azd/extensions/microsoft.azd.concurx/go.mod index d968cd6a711..ef5bf2a73cd 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/go.mod +++ b/cli/azd/extensions/microsoft.azd.concurx/go.mod @@ -63,10 +63,10 @@ require ( go.opentelemetry.io/otel/sdk v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/net v0.46.0 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/term v0.36.0 // indirect - golang.org/x/text v0.30.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.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 a1cfd953fd8..b8f257e3601 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/go.sum +++ b/cli/azd/extensions/microsoft.azd.concurx/go.sum @@ -179,19 +179,19 @@ go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0 golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +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.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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +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/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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 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= From bac1495d489666ebf06e0e3dad9c520c63a446cb Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Fri, 12 Dec 2025 19:04:12 +0000 Subject: [PATCH 07/15] Support interactive invocation opt-in for extensions --- cli/azd/extensions/extension.schema.json | 6 ++++++ .../microsoft.azd.extensions/internal/cmd/publish.go | 2 ++ .../internal/models/extension_schema.go | 4 ++++ cli/azd/pkg/extensions/extension.go | 1 + cli/azd/pkg/extensions/manager.go | 1 + cli/azd/pkg/extensions/registry.go | 3 +++ cli/azd/pkg/extensions/runner.go | 7 +++++++ 7 files changed, 24 insertions(+) diff --git a/cli/azd/extensions/extension.schema.json b/cli/azd/extensions/extension.schema.json index a834a5198ea..ed2647ed027 100644 --- a/cli/azd/extensions/extension.schema.json +++ b/cli/azd/extensions/extension.schema.json @@ -237,6 +237,12 @@ } }, "required": ["serve"] + }, + "interactive": { + "type": "boolean", + "title": "Interactive Mode", + "description": "When true, the extension runs in interactive mode with stdin/stdout/stderr directly connected to the terminal. This is required for TUI (Terminal User Interface) applications that use libraries like Bubble Tea. Default is false.", + "default": false } }, "required": [ diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/publish.go b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/publish.go index 475b9859b51..02e0c9ce237 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/publish.go +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/publish.go @@ -401,6 +401,7 @@ func addOrUpdateExtension( Examples: extensionMetadata.Examples, Dependencies: extensionMetadata.Dependencies, Providers: extensionMetadata.Providers, + Interactive: extensionMetadata.Interactive, Artifacts: artifacts, } @@ -417,6 +418,7 @@ func addOrUpdateExtension( Examples: extensionMetadata.Examples, Dependencies: extensionMetadata.Dependencies, Providers: extensionMetadata.Providers, + Interactive: extensionMetadata.Interactive, Artifacts: artifacts, }) } diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/models/extension_schema.go b/cli/azd/extensions/microsoft.azd.extensions/internal/models/extension_schema.go index db1b0e727bf..ac830ceb350 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/models/extension_schema.go +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/models/extension_schema.go @@ -30,6 +30,7 @@ type ExtensionSchema struct { Examples []extensions.ExtensionExample `yaml:"examples" json:"examples"` Tags []string `yaml:"tags" json:"tags,omitempty"` Dependencies []extensions.ExtensionDependency `yaml:"dependencies" json:"dependencies,omitempty"` + Interactive bool `yaml:"interactive" json:"interactive,omitempty"` Platforms map[string]map[string]any `yaml:"platforms" json:"platforms,omitempty"` Path string `yaml:"-" json:"-"` } @@ -72,6 +73,9 @@ func (e ExtensionSchema) MarshalYAML() (interface{}, error) { if len(e.Providers) > 0 { base["providers"] = e.Providers } + if e.Interactive { + base["interactive"] = e.Interactive + } if len(e.Platforms) > 0 { base["platforms"] = e.Platforms } diff --git a/cli/azd/pkg/extensions/extension.go b/cli/azd/pkg/extensions/extension.go index 4eb9d1f7704..48968ab019f 100644 --- a/cli/azd/pkg/extensions/extension.go +++ b/cli/azd/pkg/extensions/extension.go @@ -25,6 +25,7 @@ type Extension struct { Source string `json:"source"` Providers []Provider `json:"providers,omitempty"` McpConfig *McpConfig `json:"mcp,omitempty"` + Interactive bool `json:"interactive,omitempty"` stdin *bytes.Buffer stdout *output.DynamicMultiWriter diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index 065c7e5f7f4..8a0af1d641a 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -482,6 +482,7 @@ func (m *Manager) Install( Source: extension.Source, Providers: selectedVersion.Providers, McpConfig: selectedVersion.McpConfig, + Interactive: selectedVersion.Interactive, } if err := m.userConfig.Set(installedConfigKey, extensions); err != nil { diff --git a/cli/azd/pkg/extensions/registry.go b/cli/azd/pkg/extensions/registry.go index 8b39342abce..37fd39870a1 100644 --- a/cli/azd/pkg/extensions/registry.go +++ b/cli/azd/pkg/extensions/registry.go @@ -114,6 +114,9 @@ type ExtensionVersion struct { EntryPoint string `json:"entryPoint,omitempty"` // McpConfig is the MCP server configuration for this extension version McpConfig *McpConfig `json:"mcp,omitempty"` + // Interactive indicates whether the extension should run in interactive mode + // with stdin/stdout/stderr directly connected to the terminal (required for TUI apps) + Interactive bool `json:"interactive,omitempty"` } // ExtensionArtifact represents the artifact information of an extension diff --git a/cli/azd/pkg/extensions/runner.go b/cli/azd/pkg/extensions/runner.go index 16680fad16f..fb7841b5f0c 100644 --- a/cli/azd/pkg/extensions/runner.go +++ b/cli/azd/pkg/extensions/runner.go @@ -63,6 +63,13 @@ func (r *Runner) Invoke(ctx context.Context, extension *Extension, options *Invo runArgs = runArgs.WithStdErr(options.StdErr) } + // Enable interactive mode for extensions that opt-in via the "interactive" property + // in their extension.yaml. This ensures stdin/stdout/stderr are directly connected + // to the terminal, which is required for TUI applications like Bubble Tea. + if extension.Interactive { + runArgs = runArgs.WithInteractive(true) + } + runResult, err := r.commandRunner.Run(ctx, runArgs) if err != nil { return &runResult, &ExtensionRunError{Err: err, ExtensionId: extension.Id} From ca7e8a155ddd49dc15e0345a1d193f95e92fd43b Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Fri, 12 Dec 2025 19:11:49 +0000 Subject: [PATCH 08/15] Improve visuals for concurX as TUI --- .gitignore | 4 +- cli/azd/.vscode/cspell.yaml | 3 + .../microsoft.azd.concurx/extension.yaml | 1 + .../extensions/microsoft.azd.concurx/go.mod | 12 +- .../extensions/microsoft.azd.concurx/go.sum | 25 +- .../internal/cmd/about.go | 28 + .../internal/cmd/about_model.go | 221 ++++++++ .../internal/cmd/concurrent_deployer.go | 529 ++++++++++++++++++ .../internal/cmd/deployment_model.go | 341 +++++++++++ .../internal/cmd/root.go | 1 + .../microsoft.azd.concurx/internal/cmd/up.go | 279 +++------ 11 files changed, 1218 insertions(+), 226 deletions(-) create mode 100644 cli/azd/extensions/microsoft.azd.concurx/internal/cmd/about.go create mode 100644 cli/azd/extensions/microsoft.azd.concurx/internal/cmd/about_model.go create mode 100644 cli/azd/extensions/microsoft.azd.concurx/internal/cmd/concurrent_deployer.go create mode 100644 cli/azd/extensions/microsoft.azd.concurx/internal/cmd/deployment_model.go 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 4721aa0864e..3faedc42d2b 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/extension.yaml b/cli/azd/extensions/microsoft.azd.concurx/extension.yaml index 60db89236af..2de15660cf8 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/extension.yaml +++ b/cli/azd/extensions/microsoft.azd.concurx/extension.yaml @@ -7,3 +7,4 @@ language: go namespace: concurx usage: azd concurx [options] version: 0.0.1 +interactive: true 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/about.go b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/about.go new file mode 100644 index 00000000000..8ffcd8a7415 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/about.go @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" +) + +func newAboutCommand() *cobra.Command { + return &cobra.Command{ + Use: "about", + Short: "Display information about concurX", + RunE: func(cmd *cobra.Command, args []string) error { + model := newAboutModel() + p := tea.NewProgram(model, tea.WithAltScreen()) + + if _, err := p.Run(); err != nil { + return fmt.Errorf("failed to run about screen: %w", err) + } + + return nil + }, + } +} diff --git a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/about_model.go b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/about_model.go new file mode 100644 index 00000000000..55327720041 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/about_model.go @@ -0,0 +1,221 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// aboutModel holds the state for the about screen animation +type aboutModel struct { + width int + height int + position int + asciiArt []string + quitting bool + colorIndex int +} + +type tickAboutMsg time.Time + +// ASCII art for "concurX" +var concurXArt = []string{ + " ", + " ██████╗ ██████╗ ███╗ ██╗ ██████╗██╗ ██╗██████╗ ██╗ ██╗", + " ██╔════╝██╔═══██╗████╗ ██║██╔════╝██║ ██║██╔══██╗╚██╗██╔╝", + " ██║ ██║ ██║██╔██╗ ██║██║ ██║ ██║██████╔╝ ╚███╔╝ ", + " ██║ ██║ ██║██║╚██╗██║██║ ██║ ██║██╔══██╗ ██╔██╗ ", + " ╚██████╗╚██████╔╝██║ ╚████║╚██████╗╚██████╔╝██║ ██║██╔╝ ██╗", + " ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝", + " ", +} + +var artWidth = 68 // Width of the ASCII art + +// Rainbow colors for cycling animation +var rainbowColors = []string{ + "39", // Blue + "45", // Light Blue + "51", // Cyan + "87", // Light Purple + "201", // Magenta + "199", // Pink + "213", // Light Pink + "207", // Light Purple + "141", // Purple + "99", // Light Purple +} + +func newAboutModel() aboutModel { + return aboutModel{ + width: 80, + height: 24, + position: 0, + asciiArt: concurXArt, + colorIndex: 0, + } +} + +func (m aboutModel) Init() tea.Cmd { + return tickAbout() +} + +func tickAbout() tea.Cmd { + return tea.Tick(time.Millisecond*80, func(t time.Time) tea.Msg { + return tickAboutMsg(t) + }) +} + +func (m aboutModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c", "esc": + m.quitting = true + return m, tea.Quit + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tickAboutMsg: + if !m.quitting { + // Move position to the right + m.position++ + // Wrap position when art has completely scrolled across + // This creates a seamless loop + if m.position >= m.width { + m.position = m.position - m.width + } + // Cycle through colors for a rainbow effect + m.colorIndex = (m.colorIndex + 1) % len(rainbowColors) + return m, tickAbout() + } + } + + return m, nil +} + +func (m aboutModel) View() string { + if m.quitting { + return "" + } + + var b strings.Builder + + // Calculate vertical centering + artHeight := len(m.asciiArt) + topPadding := (m.height - artHeight) / 2 + if topPadding < 0 { + topPadding = 0 + } + + // Add top padding + for i := 0; i < topPadding; i++ { + b.WriteString("\n") + } + + // Calculate horizontal position (can be negative when entering from right) + xPos := m.position - artWidth + + // Render each line of ASCII art with Italian flag colors + for _, line := range m.asciiArt { + renderedLine := m.renderLineWithColors(line, xPos) + b.WriteString(renderedLine) + b.WriteString("\n") + } + + // Add bottom padding and help text + for i := 0; i < m.height-topPadding-artHeight-2; i++ { + b.WriteString("\n") + } + + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + helpText := "Press q, ESC, or Ctrl+C to exit" + padding := (m.width - len(helpText)) / 2 + if padding < 0 { + padding = 0 + } + b.WriteString(strings.Repeat(" ", padding)) + b.WriteString(helpStyle.Render(helpText)) + + return b.String() +} + +// renderLineWithColors renders a line with Italian flag colors based on screen position +// Green for left third, white for middle third, red for right third +func (m aboutModel) renderLineWithColors(line string, xPos int) string { + var b strings.Builder + + // Calculate the boundaries for the three color zones + leftBoundary := m.width / 3 + rightBoundary := (m.width * 2) / 3 + + // Define Italian flag colors + greenStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("40")) // Green + whiteStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("255")) // White + redStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196")) // Red + + lineRunes := []rune(line) + + // Track which characters to render and at what position + type charPos struct { + ch rune + screenX int + } + chars := make([]charPos, 0, len(lineRunes)) + + for i, ch := range lineRunes { + screenX := xPos + i + // Handle wrapping using modulo for seamless looping + if screenX < 0 { + screenX = screenX + m.width + } + if screenX >= m.width { + screenX = screenX % m.width + } + if screenX >= 0 && screenX < m.width { + chars = append(chars, charPos{ch: ch, screenX: screenX}) + } + } + + // Build the line character by character with appropriate colors + currentPos := 0 + for currentPos < m.width { + // Find if there's a character at this position + found := false + var ch rune + for _, cp := range chars { + if cp.screenX == currentPos { + ch = cp.ch + found = true + break + } + } + + if !found { + b.WriteRune(' ') + } else { + // Determine color based on position + var style lipgloss.Style + if currentPos < leftBoundary { + style = greenStyle + } else if currentPos < rightBoundary { + style = whiteStyle + } else { + style = redStyle + } + b.WriteString(style.Render(string(ch))) + } + currentPos++ + } + + return b.String() +} 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..e8ff7244818 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/concurrent_deployer.go @@ -0,0 +1,529 @@ +// 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 +} + +// NewConcurrentDeployer creates a new concurrent deployer +func NewConcurrentDeployer( + ctx context.Context, + _ azdext.WorkflowServiceClient, + services map[string]*azdext.ServiceConfig, + ui *tea.Program, +) (*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(), + }, 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 + cmd := exec.Command("azd", "provision") + 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, + ) + + 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 { + cd.finalSummaryMu.Lock() + cd.finalSummary = renderPersistedSummary(&m) + cd.finalSummaryMu.Unlock() + } + + // 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 +} + +func newServiceDeployer( + ctx context.Context, + serviceName string, + service *azdext.ServiceConfig, + logsDir string, + ui *tea.Program, + buildGate *buildGate, + provision *provisionState, + errChan chan error, +) *serviceDeployer { + return &serviceDeployer{ + ctx: ctx, + serviceName: serviceName, + service: service, + logsDir: logsDir, + ui: ui, + buildGate: buildGate, + provision: provision, + errChan: errChan, + } +} + +// 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 + cmd := exec.Command("azd", "deploy", sd.serviceName) + 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..169e56b6812 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/deployment_model.go @@ -0,0 +1,341 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/spinner" + 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 +} + +// deploymentModel is the Bubble Tea model for deployment visualization +type deploymentModel struct { + services map[string]*ServiceState + serviceOrder []string + spinner spinner.Model + quitting bool + err error + provisionStatus string // "running", "completed", "failed" + provisionMsg string + provisionLogPath string + provisionErr error +} + +// 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 + +// 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) 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, + } + } + + return deploymentModel{ + services: services, + serviceOrder: serviceNames, + spinner: s, + } +} + +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 (m deploymentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + m.quitting = true + return m, tea.Quit + } + + 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 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() + } + + 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 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) +} diff --git a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/root.go b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/root.go index 1097db51e80..29d3b7110a1 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/root.go +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/root.go @@ -23,6 +23,7 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newUpCommand()) rootCmd.AddCommand(newVersionCommand()) + rootCmd.AddCommand(newAboutCommand()) return rootCmd } 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..7978e2c7150 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() } } @@ -70,218 +63,74 @@ func newUpCommand() *cobra.Command { return &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{} - - // If Aspire service, check if this is the first one - if isAspireService { - firstAspireMu.Lock() - if firstAspireService { - isFirstAspire = true - firstAspireService = false - firstAspireMu.Unlock() - - color.New(color.FgYellow).Printf( - " [%s] First Aspire service, waiting for build completion... (logs: %s)\n", - svcName, - absLogPath, - ) - } else { - firstAspireMu.Unlock() + RunE: runUpCommand, + } +} - 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 +func runUpCommand(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) - 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, - ) - } + // Initialize AZD client and get services + services, workflowClient, cleanup, err := initializeAzdClient(ctx) + if err != nil { + return err + } + defer cleanup() - // 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 + ui := createUI(services) - // 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) + 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) *tea.Program { + serviceNames := make([]string, 0, len(services)) + for name := range services { + serviceNames = append(serviceNames, name) } + + model := newDeploymentModel(serviceNames) + return tea.NewProgram( + model, + tea.WithAltScreen(), // Use alternate screen buffer + tea.WithMouseCellMotion(), // Enable mouse support + ) } From feb7c4a8ba8f57e7593c5eb6186d7cab9500e980 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Wed, 17 Dec 2025 03:14:22 +0000 Subject: [PATCH 09/15] remove the about art --- .../internal/cmd/about.go | 28 --- .../internal/cmd/about_model.go | 221 ------------------ .../internal/cmd/root.go | 1 - 3 files changed, 250 deletions(-) delete mode 100644 cli/azd/extensions/microsoft.azd.concurx/internal/cmd/about.go delete mode 100644 cli/azd/extensions/microsoft.azd.concurx/internal/cmd/about_model.go diff --git a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/about.go b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/about.go deleted file mode 100644 index 8ffcd8a7415..00000000000 --- a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/about.go +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "fmt" - - tea "github.com/charmbracelet/bubbletea" - "github.com/spf13/cobra" -) - -func newAboutCommand() *cobra.Command { - return &cobra.Command{ - Use: "about", - Short: "Display information about concurX", - RunE: func(cmd *cobra.Command, args []string) error { - model := newAboutModel() - p := tea.NewProgram(model, tea.WithAltScreen()) - - if _, err := p.Run(); err != nil { - return fmt.Errorf("failed to run about screen: %w", err) - } - - return nil - }, - } -} diff --git a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/about_model.go b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/about_model.go deleted file mode 100644 index 55327720041..00000000000 --- a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/about_model.go +++ /dev/null @@ -1,221 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "strings" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -// aboutModel holds the state for the about screen animation -type aboutModel struct { - width int - height int - position int - asciiArt []string - quitting bool - colorIndex int -} - -type tickAboutMsg time.Time - -// ASCII art for "concurX" -var concurXArt = []string{ - " ", - " ██████╗ ██████╗ ███╗ ██╗ ██████╗██╗ ██╗██████╗ ██╗ ██╗", - " ██╔════╝██╔═══██╗████╗ ██║██╔════╝██║ ██║██╔══██╗╚██╗██╔╝", - " ██║ ██║ ██║██╔██╗ ██║██║ ██║ ██║██████╔╝ ╚███╔╝ ", - " ██║ ██║ ██║██║╚██╗██║██║ ██║ ██║██╔══██╗ ██╔██╗ ", - " ╚██████╗╚██████╔╝██║ ╚████║╚██████╗╚██████╔╝██║ ██║██╔╝ ██╗", - " ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝", - " ", -} - -var artWidth = 68 // Width of the ASCII art - -// Rainbow colors for cycling animation -var rainbowColors = []string{ - "39", // Blue - "45", // Light Blue - "51", // Cyan - "87", // Light Purple - "201", // Magenta - "199", // Pink - "213", // Light Pink - "207", // Light Purple - "141", // Purple - "99", // Light Purple -} - -func newAboutModel() aboutModel { - return aboutModel{ - width: 80, - height: 24, - position: 0, - asciiArt: concurXArt, - colorIndex: 0, - } -} - -func (m aboutModel) Init() tea.Cmd { - return tickAbout() -} - -func tickAbout() tea.Cmd { - return tea.Tick(time.Millisecond*80, func(t time.Time) tea.Msg { - return tickAboutMsg(t) - }) -} - -func (m aboutModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c", "esc": - m.quitting = true - return m, tea.Quit - } - - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - return m, nil - - case tickAboutMsg: - if !m.quitting { - // Move position to the right - m.position++ - // Wrap position when art has completely scrolled across - // This creates a seamless loop - if m.position >= m.width { - m.position = m.position - m.width - } - // Cycle through colors for a rainbow effect - m.colorIndex = (m.colorIndex + 1) % len(rainbowColors) - return m, tickAbout() - } - } - - return m, nil -} - -func (m aboutModel) View() string { - if m.quitting { - return "" - } - - var b strings.Builder - - // Calculate vertical centering - artHeight := len(m.asciiArt) - topPadding := (m.height - artHeight) / 2 - if topPadding < 0 { - topPadding = 0 - } - - // Add top padding - for i := 0; i < topPadding; i++ { - b.WriteString("\n") - } - - // Calculate horizontal position (can be negative when entering from right) - xPos := m.position - artWidth - - // Render each line of ASCII art with Italian flag colors - for _, line := range m.asciiArt { - renderedLine := m.renderLineWithColors(line, xPos) - b.WriteString(renderedLine) - b.WriteString("\n") - } - - // Add bottom padding and help text - for i := 0; i < m.height-topPadding-artHeight-2; i++ { - b.WriteString("\n") - } - - helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - helpText := "Press q, ESC, or Ctrl+C to exit" - padding := (m.width - len(helpText)) / 2 - if padding < 0 { - padding = 0 - } - b.WriteString(strings.Repeat(" ", padding)) - b.WriteString(helpStyle.Render(helpText)) - - return b.String() -} - -// renderLineWithColors renders a line with Italian flag colors based on screen position -// Green for left third, white for middle third, red for right third -func (m aboutModel) renderLineWithColors(line string, xPos int) string { - var b strings.Builder - - // Calculate the boundaries for the three color zones - leftBoundary := m.width / 3 - rightBoundary := (m.width * 2) / 3 - - // Define Italian flag colors - greenStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("40")) // Green - whiteStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("255")) // White - redStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196")) // Red - - lineRunes := []rune(line) - - // Track which characters to render and at what position - type charPos struct { - ch rune - screenX int - } - chars := make([]charPos, 0, len(lineRunes)) - - for i, ch := range lineRunes { - screenX := xPos + i - // Handle wrapping using modulo for seamless looping - if screenX < 0 { - screenX = screenX + m.width - } - if screenX >= m.width { - screenX = screenX % m.width - } - if screenX >= 0 && screenX < m.width { - chars = append(chars, charPos{ch: ch, screenX: screenX}) - } - } - - // Build the line character by character with appropriate colors - currentPos := 0 - for currentPos < m.width { - // Find if there's a character at this position - found := false - var ch rune - for _, cp := range chars { - if cp.screenX == currentPos { - ch = cp.ch - found = true - break - } - } - - if !found { - b.WriteRune(' ') - } else { - // Determine color based on position - var style lipgloss.Style - if currentPos < leftBoundary { - style = greenStyle - } else if currentPos < rightBoundary { - style = whiteStyle - } else { - style = redStyle - } - b.WriteString(style.Render(string(ch))) - } - currentPos++ - } - - return b.String() -} diff --git a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/root.go b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/root.go index 29d3b7110a1..1097db51e80 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/root.go +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/root.go @@ -23,7 +23,6 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newUpCommand()) rootCmd.AddCommand(newVersionCommand()) - rootCmd.AddCommand(newAboutCommand()) return rootCmd } From b7a3c9321a294af9c1025efe5be38afe8b1c20e5 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Wed, 17 Dec 2025 03:17:17 +0000 Subject: [PATCH 10/15] updates on top of main --- cli/azd/extensions/extension.schema.json | 6 ------ cli/azd/extensions/microsoft.azd.concurx/extension.yaml | 1 - .../internal/models/extension_schema.go | 3 --- cli/azd/pkg/extensions/extension.go | 1 - cli/azd/pkg/extensions/manager.go | 1 - cli/azd/pkg/extensions/registry.go | 3 --- cli/azd/pkg/extensions/runner.go | 7 ------- 7 files changed, 22 deletions(-) diff --git a/cli/azd/extensions/extension.schema.json b/cli/azd/extensions/extension.schema.json index ed2647ed027..a834a5198ea 100644 --- a/cli/azd/extensions/extension.schema.json +++ b/cli/azd/extensions/extension.schema.json @@ -237,12 +237,6 @@ } }, "required": ["serve"] - }, - "interactive": { - "type": "boolean", - "title": "Interactive Mode", - "description": "When true, the extension runs in interactive mode with stdin/stdout/stderr directly connected to the terminal. This is required for TUI (Terminal User Interface) applications that use libraries like Bubble Tea. Default is false.", - "default": false } }, "required": [ diff --git a/cli/azd/extensions/microsoft.azd.concurx/extension.yaml b/cli/azd/extensions/microsoft.azd.concurx/extension.yaml index 2de15660cf8..60db89236af 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/extension.yaml +++ b/cli/azd/extensions/microsoft.azd.concurx/extension.yaml @@ -7,4 +7,3 @@ language: go namespace: concurx usage: azd concurx [options] version: 0.0.1 -interactive: true diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/models/extension_schema.go b/cli/azd/extensions/microsoft.azd.extensions/internal/models/extension_schema.go index ac830ceb350..9a9a72dac0d 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/models/extension_schema.go +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/models/extension_schema.go @@ -73,9 +73,6 @@ func (e ExtensionSchema) MarshalYAML() (interface{}, error) { if len(e.Providers) > 0 { base["providers"] = e.Providers } - if e.Interactive { - base["interactive"] = e.Interactive - } if len(e.Platforms) > 0 { base["platforms"] = e.Platforms } diff --git a/cli/azd/pkg/extensions/extension.go b/cli/azd/pkg/extensions/extension.go index 48968ab019f..4eb9d1f7704 100644 --- a/cli/azd/pkg/extensions/extension.go +++ b/cli/azd/pkg/extensions/extension.go @@ -25,7 +25,6 @@ type Extension struct { Source string `json:"source"` Providers []Provider `json:"providers,omitempty"` McpConfig *McpConfig `json:"mcp,omitempty"` - Interactive bool `json:"interactive,omitempty"` stdin *bytes.Buffer stdout *output.DynamicMultiWriter diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index 8a0af1d641a..065c7e5f7f4 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -482,7 +482,6 @@ func (m *Manager) Install( Source: extension.Source, Providers: selectedVersion.Providers, McpConfig: selectedVersion.McpConfig, - Interactive: selectedVersion.Interactive, } if err := m.userConfig.Set(installedConfigKey, extensions); err != nil { diff --git a/cli/azd/pkg/extensions/registry.go b/cli/azd/pkg/extensions/registry.go index 37fd39870a1..8b39342abce 100644 --- a/cli/azd/pkg/extensions/registry.go +++ b/cli/azd/pkg/extensions/registry.go @@ -114,9 +114,6 @@ type ExtensionVersion struct { EntryPoint string `json:"entryPoint,omitempty"` // McpConfig is the MCP server configuration for this extension version McpConfig *McpConfig `json:"mcp,omitempty"` - // Interactive indicates whether the extension should run in interactive mode - // with stdin/stdout/stderr directly connected to the terminal (required for TUI apps) - Interactive bool `json:"interactive,omitempty"` } // ExtensionArtifact represents the artifact information of an extension diff --git a/cli/azd/pkg/extensions/runner.go b/cli/azd/pkg/extensions/runner.go index 33aba50dd92..b3ec3ed2d2d 100644 --- a/cli/azd/pkg/extensions/runner.go +++ b/cli/azd/pkg/extensions/runner.go @@ -68,13 +68,6 @@ func (r *Runner) Invoke(ctx context.Context, extension *Extension, options *Invo } } - // Enable interactive mode for extensions that opt-in via the "interactive" property - // in their extension.yaml. This ensures stdin/stdout/stderr are directly connected - // to the terminal, which is required for TUI applications like Bubble Tea. - if extension.Interactive { - runArgs = runArgs.WithInteractive(true) - } - runResult, err := r.commandRunner.Run(ctx, runArgs) if err != nil { return &runResult, &ExtensionRunError{Err: err, ExtensionId: extension.Id} From 1260f8e08cd1211c2d4b8d1aaf672301cb49bc71 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Wed, 17 Dec 2025 03:18:30 +0000 Subject: [PATCH 11/15] missing merges --- .../extensions/microsoft.azd.extensions/internal/cmd/publish.go | 2 -- .../internal/models/extension_schema.go | 1 - 2 files changed, 3 deletions(-) diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/publish.go b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/publish.go index 02e0c9ce237..475b9859b51 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/publish.go +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/publish.go @@ -401,7 +401,6 @@ func addOrUpdateExtension( Examples: extensionMetadata.Examples, Dependencies: extensionMetadata.Dependencies, Providers: extensionMetadata.Providers, - Interactive: extensionMetadata.Interactive, Artifacts: artifacts, } @@ -418,7 +417,6 @@ func addOrUpdateExtension( Examples: extensionMetadata.Examples, Dependencies: extensionMetadata.Dependencies, Providers: extensionMetadata.Providers, - Interactive: extensionMetadata.Interactive, Artifacts: artifacts, }) } diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/models/extension_schema.go b/cli/azd/extensions/microsoft.azd.extensions/internal/models/extension_schema.go index 9a9a72dac0d..db1b0e727bf 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/models/extension_schema.go +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/models/extension_schema.go @@ -30,7 +30,6 @@ type ExtensionSchema struct { Examples []extensions.ExtensionExample `yaml:"examples" json:"examples"` Tags []string `yaml:"tags" json:"tags,omitempty"` Dependencies []extensions.ExtensionDependency `yaml:"dependencies" json:"dependencies,omitempty"` - Interactive bool `yaml:"interactive" json:"interactive,omitempty"` Platforms map[string]map[string]any `yaml:"platforms" json:"platforms,omitempty"` Path string `yaml:"-" json:"-"` } From 1f578613c57565871fb41aa7dc9592f3ddfda8de Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Wed, 17 Dec 2025 03:36:02 +0000 Subject: [PATCH 12/15] handle cancellation --- .../internal/cmd/concurrent_deployer.go | 18 +++++++++++++----- .../internal/cmd/deployment_model.go | 11 ++++++++++- .../microsoft.azd.concurx/internal/cmd/up.go | 12 ++++++++---- 3 files changed, 31 insertions(+), 10 deletions(-) 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 index e8ff7244818..2411df688ee 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/concurrent_deployer.go +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/concurrent_deployer.go @@ -110,7 +110,7 @@ func (cd *ConcurrentDeployer) runProvision() { defer logFile.Close() // Run azd provision as a subprocess to capture output - cmd := exec.Command("azd", "provision") + cmd := exec.CommandContext(cd.ctx, "azd", "provision") cmd.Stdout = logFile cmd.Stderr = logFile cmd.Dir, _ = os.Getwd() @@ -176,9 +176,17 @@ func (cd *ConcurrentDeployer) collectResults() error { } if m, ok := finalModel.(deploymentModel); ok { - cd.finalSummaryMu.Lock() - cd.finalSummary = renderPersistedSummary(&m) - cd.finalSummaryMu.Unlock() + // 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 @@ -396,7 +404,7 @@ func (sd *serviceDeployer) runDeployment(isFirstAspire bool) error { } // #nosec G204 - serviceName is from validated azd context, not user input - cmd := exec.Command("azd", "deploy", sd.serviceName) + cmd := exec.CommandContext(sd.ctx, "azd", "deploy", sd.serviceName) cmd.Stdout = outputWriter cmd.Stderr = outputWriter cmd.Dir, _ = os.Getwd() 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 index 169e56b6812..5e6fac86083 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/deployment_model.go +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/deployment_model.go @@ -4,6 +4,7 @@ package cmd import ( + "context" "fmt" "strings" "time" @@ -40,11 +41,13 @@ type deploymentModel struct { 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 } // Messages that can be sent to the Bubble Tea program @@ -93,7 +96,7 @@ var ( Italic(true) ) -func newDeploymentModel(serviceNames []string) deploymentModel { +func newDeploymentModel(serviceNames []string, cancel context.CancelFunc) deploymentModel { s := spinner.New() s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) @@ -110,6 +113,7 @@ func newDeploymentModel(serviceNames []string) deploymentModel { services: services, serviceOrder: serviceNames, spinner: s, + cancel: cancel, } } @@ -132,6 +136,11 @@ func (m deploymentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 } 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 7978e2c7150..253b8ef12b7 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/up.go +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/up.go @@ -70,6 +70,10 @@ func newUpCommand() *cobra.Command { func runUpCommand(cmd *cobra.Command, args []string) error { ctx := azdext.WithAccessToken(cmd.Context()) + // Create cancellable context for handling Ctrl+C in UI + ctx, cancel := context.WithCancel(ctx) + defer cancel() + // Initialize AZD client and get services services, workflowClient, cleanup, err := initializeAzdClient(ctx) if err != nil { @@ -77,8 +81,8 @@ func runUpCommand(cmd *cobra.Command, args []string) error { } defer cleanup() - // Create Bubble Tea UI - ui := createUI(services) + // Create Bubble Tea UI with cancel function + ui := createUI(services, cancel) // Create and run concurrent deployer deployer, err := NewConcurrentDeployer(ctx, workflowClient, services, ui) @@ -121,13 +125,13 @@ func initializeAzdClient(ctx context.Context) ( } // createUI initializes the Bubble Tea program -func createUI(services map[string]*azdext.ServiceConfig) *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) + model := newDeploymentModel(serviceNames, cancel) return tea.NewProgram( model, tea.WithAltScreen(), // Use alternate screen buffer From e135ce91e560fa1785d54821c2e65d54aceee439 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Wed, 17 Dec 2025 03:59:32 +0000 Subject: [PATCH 13/15] logs with tabs --- .../internal/cmd/deployment_model.go | 214 +++++++++++++++++- .../microsoft.azd.concurx/internal/cmd/up.go | 3 +- 2 files changed, 214 insertions(+), 3 deletions(-) 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 index 5e6fac86083..f4d9f19cb88 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/deployment_model.go +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/deployment_model.go @@ -6,10 +6,13 @@ package cmd import ( "context" "fmt" + "io" + "os" "strings" "time" "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -35,6 +38,14 @@ type ServiceState struct { 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 @@ -48,6 +59,15 @@ type deploymentModel struct { 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 } // Messages that can be sent to the Bubble Tea program @@ -109,11 +129,24 @@ func newDeploymentModel(serviceNames []string, cancel context.CancelFunc) deploy } } + // 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, } } @@ -132,6 +165,21 @@ func tickCmd() tea.Cmd { 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": @@ -142,6 +190,49 @@ func (m deploymentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 + } + + // 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: @@ -204,6 +295,10 @@ func (m deploymentModel) View() string { return m.renderFinalView() } + if m.viewMode == viewLogs { + return m.renderLogsView() + } + var b strings.Builder // Title @@ -252,7 +347,7 @@ func (m deploymentModel) View() string { b.WriteString("\n") b.WriteString(lipgloss.NewStyle(). Foreground(lipgloss.Color("240")). - Render("Press q or Ctrl+C to quit")) + Render("Press L to see logs • Press q or Ctrl+C to quit")) return b.String() } @@ -348,3 +443,120 @@ func formatDuration(d time.Duration) string { 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 + // Update viewport if in logs view + if m.viewMode == viewLogs { + m.updateViewportContent() + } +} + +// 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 + helpText := "← → navigate tabs • ↑↓ scroll • PgUp/PgDn page scroll • B back • q quit" + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(helpText)) + 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..." + } + + m.viewport.SetContent(content) + m.viewport.GotoTop() +} + +// 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...) +} 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 253b8ef12b7..e5b6dbb111b 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/up.go +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/up.go @@ -134,7 +134,6 @@ func createUI(services map[string]*azdext.ServiceConfig, cancel context.CancelFu model := newDeploymentModel(serviceNames, cancel) return tea.NewProgram( model, - tea.WithAltScreen(), // Use alternate screen buffer - tea.WithMouseCellMotion(), // Enable mouse support + tea.WithAltScreen(), // Use alternate screen buffer ) } From f138944d19ca3507c4007d25d7da3cb6e7f6a174 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Wed, 17 Dec 2025 18:05:40 +0000 Subject: [PATCH 14/15] logs improvements --- .../internal/cmd/concurrent_deployer.go | 20 ++- .../internal/cmd/deployment_model.go | 142 +++++++++++++++++- .../microsoft.azd.concurx/internal/cmd/up.go | 9 +- 3 files changed, 160 insertions(+), 11 deletions(-) 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 index 2411df688ee..a781654eb51 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/concurrent_deployer.go +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/concurrent_deployer.go @@ -33,6 +33,7 @@ type ConcurrentDeployer struct { provision *provisionState finalSummaryMu sync.Mutex finalSummary string + debug bool } // NewConcurrentDeployer creates a new concurrent deployer @@ -41,6 +42,7 @@ func NewConcurrentDeployer( _ 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") @@ -62,6 +64,7 @@ func NewConcurrentDeployer( errChan: make(chan error, len(services)), buildGate: newBuildGate(), provision: newProvisionState(), + debug: debug, }, nil } @@ -110,7 +113,11 @@ func (cd *ConcurrentDeployer) runProvision() { defer logFile.Close() // Run azd provision as a subprocess to capture output - cmd := exec.CommandContext(cd.ctx, "azd", "provision") + 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() @@ -151,6 +158,7 @@ func (cd *ConcurrentDeployer) startServiceDeployments() { cd.buildGate, cd.provision, cd.errChan, + cd.debug, ) go func() { @@ -289,6 +297,7 @@ type serviceDeployer struct { errChan chan error logFile *os.File logPath string + debug bool } func newServiceDeployer( @@ -300,6 +309,7 @@ func newServiceDeployer( buildGate *buildGate, provision *provisionState, errChan chan error, + debug bool, ) *serviceDeployer { return &serviceDeployer{ ctx: ctx, @@ -310,6 +320,7 @@ func newServiceDeployer( buildGate: buildGate, provision: provision, errChan: errChan, + debug: debug, } } @@ -404,7 +415,12 @@ func (sd *serviceDeployer) runDeployment(isFirstAspire bool) error { } // #nosec G204 - serviceName is from validated azd context, not user input - cmd := exec.CommandContext(sd.ctx, "azd", "deploy", sd.serviceName) + 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() 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 index f4d9f19cb88..565382fab91 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/deployment_model.go +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/deployment_model.go @@ -8,6 +8,8 @@ import ( "fmt" "io" "os" + "os/exec" + "runtime" "strings" "time" @@ -68,6 +70,7 @@ type deploymentModel struct { width int height int ready bool + autoRefresh bool // Auto-refresh logs when enabled } // Messages that can be sent to the Bubble Tea program @@ -89,6 +92,8 @@ type deploymentCompleteMsg struct{} type tickMsg time.Time +type logRefreshMsg time.Time + // Styles var ( titleStyle = lipgloss.NewStyle(). @@ -163,6 +168,12 @@ func tickCmd() tea.Cmd { }) } +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: @@ -226,6 +237,28 @@ func (m deploymentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 @@ -272,6 +305,28 @@ func (m deploymentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // 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) @@ -472,10 +527,6 @@ func (m *deploymentModel) refreshLogContents() { } m.logContents[tabName] = content - // Update viewport if in logs view - if m.viewMode == viewLogs { - m.updateViewportContent() - } } // readLogFile reads the entire content of a log file @@ -502,9 +553,17 @@ func (m deploymentModel) renderLogsView() string { b.WriteString(titleStyle.Render("📋 Deployment Logs")) b.WriteString("\n\n") - // Instructions - helpText := "← → navigate tabs • ↑↓ scroll • PgUp/PgDn page scroll • B back • q quit" + // 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 @@ -529,8 +588,14 @@ func (m *deploymentModel) updateViewportContent() { content = "Loading logs..." } + // Remember if we were at bottom before updating + atBottom := m.viewport.AtBottom() m.viewport.SetContent(content) - m.viewport.GotoTop() + + // 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 @@ -560,3 +625,66 @@ func (m deploymentModel) renderTabs() string { 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 e5b6dbb111b..2b2c422c632 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/up.go +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/up.go @@ -60,11 +60,13 @@ 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: runUpCommand, } + cmd.Flags().Bool("debug", false, "Enable debug logging for azd commands") + return cmd } func runUpCommand(cmd *cobra.Command, args []string) error { @@ -81,11 +83,14 @@ func runUpCommand(cmd *cobra.Command, args []string) error { } defer cleanup() + // Get debug flag + debug, _ := cmd.Flags().GetBool("debug") + // Create Bubble Tea UI with cancel function ui := createUI(services, cancel) // Create and run concurrent deployer - deployer, err := NewConcurrentDeployer(ctx, workflowClient, services, ui) + deployer, err := NewConcurrentDeployer(ctx, workflowClient, services, ui, debug) if err != nil { return err } From a77b3eb210b394e44aa9288ce21a2a5c5e1c4b2f Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Wed, 7 Jan 2026 01:50:32 +0000 Subject: [PATCH 15/15] pipeline def --- .../release-ext-microsoft-azd-concurx.yml | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 eng/pipelines/release-ext-microsoft-azd-concurx.yml 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