From ace419bd6ba4965fbf6f05a025834234023d8750 Mon Sep 17 00:00:00 2001 From: Abdul-Microsoft Date: Wed, 6 May 2026 11:46:08 +0530 Subject: [PATCH 01/20] feat: Add local dev setup and deploy-to-Azure automation scripts - setup_local_dev.sh/.ps1: Automates full local development setup - Prerequisite checks with detailed install guidance - Azure config fetch from Resource Group (container app or individual resources) - RBAC role assignment with pre-check (Cosmos DB, AI Foundry, Search, Storage) - Virtual environment setup for backend, MCP server, and frontend - VS Code settings and launch.json generation - Auto-fix for .venv lock issues (VS Code Python extension) - deploy_to_azure.sh/.ps1: Deploys local code changes to Azure - Builds Docker images for selected services (backend, mcp, frontend) - Pushes to ACR with unique timestamp+git-sha tags - Updates Container Apps and App Service with new images - ACR discovery, creation, and AcrPull role assignment - Dry-run mode, build-only/deploy-only modes - Rollback commands printed after deployment - .gitignore: Added local dev artifacts (.macae_*.pid, start_all_services.sh) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 4 + deploy_to_azure.ps1 | 617 +++++++++++++++++++++++ deploy_to_azure.sh | 756 ++++++++++++++++++++++++++++ setup_local_dev.ps1 | 825 ++++++++++++++++++++++++++++++ setup_local_dev.sh | 1166 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 3368 insertions(+) create mode 100644 deploy_to_azure.ps1 create mode 100644 deploy_to_azure.sh create mode 100644 setup_local_dev.ps1 create mode 100644 setup_local_dev.sh diff --git a/.gitignore b/.gitignore index e62f35001..c16eeb073 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ __pycache__/ .env .env_* appsettings.json + +# Local dev setup artifacts +.macae_*.pid +start_all_services.sh # Distribution / packaging .Python build/ diff --git a/deploy_to_azure.ps1 b/deploy_to_azure.ps1 new file mode 100644 index 000000000..de4f5f0d9 --- /dev/null +++ b/deploy_to_azure.ps1 @@ -0,0 +1,617 @@ +# ============================================================================== +# MACAE - Deploy Local Code to Azure +# ============================================================================== +# +# Usage: +# .\deploy_to_azure.ps1 -ResourceGroup [options] +# +# Examples: +# .\deploy_to_azure.ps1 -ResourceGroup rg-macae-dev +# .\deploy_to_azure.ps1 -ResourceGroup rg-macae-dev -Services "backend,mcp" +# .\deploy_to_azure.ps1 -ResourceGroup rg-macae-dev -Acr myacr +# .\deploy_to_azure.ps1 -ResourceGroup rg-macae-dev -DryRun +# ============================================================================== + +param( + [Parameter(Mandatory=$true)] + [string]$ResourceGroup, + [string]$Acr = "", + [string]$Services = "", + [string]$Tag = "", + [switch]$DryRun, + [switch]$BuildOnly, + [switch]$DeployOnly +) + +$ErrorActionPreference = "Stop" + +# ============================================================================== +# Configuration +# ============================================================================== + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$BackendDir = Join-Path $ScriptDir "src\backend" +$McpDir = Join-Path $ScriptDir "src\mcp_server" +$FrontendDir = Join-Path $ScriptDir "src\App" + +$BackendImageName = "macaebackend" +$McpImageName = "macaemcp" +$FrontendImageName = "macaefrontend" + +# ============================================================================== +# Logging +# ============================================================================== + +function Write-LogInfo { param([string]$msg) Write-Host "[i] $msg" -ForegroundColor Blue } +function Write-LogSuccess { param([string]$msg) Write-Host "[✓] $msg" -ForegroundColor Green } +function Write-LogWarn { param([string]$msg) Write-Host "[!] $msg" -ForegroundColor Yellow } +function Write-LogError { param([string]$msg) Write-Host "[✗] $msg" -ForegroundColor Red } +function Write-LogStep { param([string]$msg) Write-Host "`n━━━ $msg ━━━`n" -ForegroundColor Cyan } + +# ============================================================================== +# Step 1: Prerequisites +# ============================================================================== + +function Check-Prerequisites { + Write-LogStep "Step 1: Checking Prerequisites" + + $missing = @() + + if (Get-Command docker -ErrorAction SilentlyContinue) { + Write-LogSuccess "Docker found: $(docker --version)" + } else { + $missing += "docker" + } + + if (Get-Command az -ErrorAction SilentlyContinue) { + Write-LogSuccess "Azure CLI found" + } else { + $missing += "azure-cli" + } + + if (Get-Command git -ErrorAction SilentlyContinue) { + Write-LogSuccess "Git found" + } else { + $missing += "git" + } + + if ($missing.Count -gt 0) { + Write-LogError "Missing prerequisites: $($missing -join ', ')" + Write-Host "" + foreach ($tool in $missing) { + switch ($tool) { + "docker" { + Write-Host " ┌─ Docker ──────────────────────────────────────────────────────" + Write-Host " │ Download: https://www.docker.com/products/docker-desktop" + Write-Host " │ Or: winget install Docker.DockerDesktop" + Write-Host " │ Verify: docker --version" + Write-Host " └──────────────────────────────────────────────────────────────" + } + "azure-cli" { + Write-Host " ┌─ Azure CLI ───────────────────────────────────────────────────" + Write-Host " │ Download: https://aka.ms/installazurecliwindows" + Write-Host " │ Or: winget install Microsoft.AzureCLI" + Write-Host " │ Verify: az --version" + Write-Host " └──────────────────────────────────────────────────────────────" + } + "git" { + Write-Host " ┌─ Git ─────────────────────────────────────────────────────────" + Write-Host " │ Download: https://git-scm.com/download/win" + Write-Host " │ Or: winget install Git.Git" + Write-Host " │ Verify: git --version" + Write-Host " └──────────────────────────────────────────────────────────────" + } + } + } + exit 1 + } + + # Check Docker daemon + $dockerInfo = docker info 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-LogError "Docker daemon is not running. Please start Docker Desktop and retry." + exit 1 + } + Write-LogSuccess "Docker daemon is running" + + # Check Azure login + $azAccount = az account show 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-LogWarn "Not logged into Azure CLI. Running 'az login'..." + az login + } + Write-LogSuccess "Logged into Azure CLI" +} + +# ============================================================================== +# Step 2: Discover Azure Resources +# ============================================================================== + +function Discover-Resources { + Write-LogStep "Step 2: Discovering Azure Resources" + + $rgCheck = az group show --name $ResourceGroup 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-LogError "Resource group '$ResourceGroup' not found." + exit 1 + } + Write-LogSuccess "Resource group: $ResourceGroup" + + # Discover container apps + $caList = az containerapp list --resource-group $ResourceGroup --query "[].name" -o tsv 2>$null + + $script:BackendCA = "" + $script:McpCA = "" + + if ($caList) { + foreach ($app in ($caList -split "`n")) { + $app = $app.Trim() + if ($app -like "ca-mcp-*") { + $script:McpCA = $app + } elseif ($app -like "ca-*") { + $script:BackendCA = $app + } + } + } + + if ($script:BackendCA) { Write-LogSuccess "Backend Container App: $script:BackendCA" } + else { Write-LogWarn "Backend Container App: not found in RG" } + + if ($script:McpCA) { Write-LogSuccess "MCP Container App: $script:McpCA" } + else { Write-LogWarn "MCP Container App: not found in RG" } + + # Discover frontend web app + $script:FrontendApp = az webapp list --resource-group $ResourceGroup --query "[0].name" -o tsv 2>$null + if ($script:FrontendApp) { Write-LogSuccess "Frontend Web App: $script:FrontendApp" } + else { Write-LogWarn "Frontend Web App: not found in RG" } + + # Capture current images for rollback + $script:OldBackendImage = "" + $script:OldMcpImage = "" + $script:OldFrontendImage = "" + + if ($script:BackendCA) { + $script:OldBackendImage = az containerapp show --name $script:BackendCA --resource-group $ResourceGroup ` + --query "properties.template.containers[0].image" -o tsv 2>$null + Write-LogInfo "Current backend image: $script:OldBackendImage" + } + if ($script:McpCA) { + $script:OldMcpImage = az containerapp show --name $script:McpCA --resource-group $ResourceGroup ` + --query "properties.template.containers[0].image" -o tsv 2>$null + Write-LogInfo "Current MCP image: $script:OldMcpImage" + } + if ($script:FrontendApp) { + $script:OldFrontendImage = az webapp config show --name $script:FrontendApp --resource-group $ResourceGroup ` + --query "linuxFxVersion" -o tsv 2>$null + Write-LogInfo "Current frontend image: $script:OldFrontendImage" + } +} + +# ============================================================================== +# Step 3: Resolve ACR +# ============================================================================== + +function Resolve-Acr { + Write-LogStep "Step 3: Resolving Container Registry" + + if ($Acr) { + $input = $Acr -replace '\.azurecr\.io$', '' + $script:AcrName = az acr show --name $input --query "name" -o tsv 2>$null + if (-not $script:AcrName) { + Write-LogError "ACR '$Acr' not found or not accessible." + exit 1 + } + $script:AcrLoginServer = az acr show --name $script:AcrName --query "loginServer" -o tsv + $script:AcrId = az acr show --name $script:AcrName --query "id" -o tsv + Write-LogSuccess "Using specified ACR: $script:AcrName ($script:AcrLoginServer)" + return + } + + # Discover in RG + Write-LogInfo "Looking for ACR in resource group..." + $script:AcrName = az acr list --resource-group $ResourceGroup --query "[0].name" -o tsv 2>$null + + if ($script:AcrName) { + $script:AcrLoginServer = az acr show --name $script:AcrName --query "loginServer" -o tsv + $script:AcrId = az acr show --name $script:AcrName --query "id" -o tsv + Write-LogSuccess "Found ACR in RG: $script:AcrName ($script:AcrLoginServer)" + return + } + + # Ask user + Write-LogWarn "No ACR found in resource group '$ResourceGroup'." + Write-Host "" + $userAcr = Read-Host "Do you have an existing ACR? Enter its name (or press Enter to create one)" + + if ($userAcr) { + $input = $userAcr -replace '\.azurecr\.io$', '' + $script:AcrName = az acr show --name $input --query "name" -o tsv 2>$null + if (-not $script:AcrName) { + Write-LogError "ACR '$userAcr' not found." + exit 1 + } + $script:AcrLoginServer = az acr show --name $script:AcrName --query "loginServer" -o tsv + $script:AcrId = az acr show --name $script:AcrName --query "id" -o tsv + Write-LogSuccess "Using ACR: $script:AcrName ($script:AcrLoginServer)" + } else { + # Create new ACR + $suffix = ($ResourceGroup -replace '[^a-zA-Z0-9]', '').Substring(0, [Math]::Min(15, ($ResourceGroup -replace '[^a-zA-Z0-9]', '').Length)) + $ts = (Get-Date).ToString("HHmmss") + $newAcrName = ("acr$suffix$ts").ToLower().Substring(0, [Math]::Min(50, ("acr$suffix$ts").Length)) + + Write-LogInfo "Creating ACR: $newAcrName in $ResourceGroup..." + az acr create ` + --resource-group $ResourceGroup ` + --name $newAcrName ` + --sku Basic ` + --admin-enabled false ` + --output none + + $script:AcrName = $newAcrName + $script:AcrLoginServer = az acr show --name $script:AcrName --query "loginServer" -o tsv + $script:AcrId = az acr show --name $script:AcrName --query "id" -o tsv + Write-LogSuccess "Created ACR: $script:AcrName ($script:AcrLoginServer)" + + Assign-AcrPullRoles + } +} + +# ============================================================================== +# ACR Pull Role Assignment +# ============================================================================== + +function Assign-AcrPullRoles { + Write-LogInfo "Assigning AcrPull role to service identities..." + + $acrPullRole = "7f951dda-4ed3-4680-a7ca-43fe172d538d" + + # Backend + if ($script:BackendCA) { + $identity = az containerapp show --name $script:BackendCA --resource-group $ResourceGroup ` + --query "identity.principalId" -o tsv 2>$null + if ($identity -and $identity -ne "null") { + $existing = az role assignment list --assignee $identity --role $acrPullRole --scope $script:AcrId --query "[0].id" -o tsv 2>$null + if (-not $existing) { + az role assignment create --assignee $identity --role $acrPullRole --scope $script:AcrId --output none 2>$null + Write-LogSuccess " AcrPull assigned to backend identity" + } else { + Write-LogInfo " AcrPull already assigned to backend identity ✓" + } + } + } + + # MCP + if ($script:McpCA) { + $identity = az containerapp show --name $script:McpCA --resource-group $ResourceGroup ` + --query "identity.principalId" -o tsv 2>$null + if ($identity -and $identity -ne "null") { + $existing = az role assignment list --assignee $identity --role $acrPullRole --scope $script:AcrId --query "[0].id" -o tsv 2>$null + if (-not $existing) { + az role assignment create --assignee $identity --role $acrPullRole --scope $script:AcrId --output none 2>$null + Write-LogSuccess " AcrPull assigned to MCP identity" + } else { + Write-LogInfo " AcrPull already assigned to MCP identity ✓" + } + } + } + + # Frontend + if ($script:FrontendApp) { + $identity = az webapp show --name $script:FrontendApp --resource-group $ResourceGroup ` + --query "identity.principalId" -o tsv 2>$null + if ($identity -and $identity -ne "null") { + $existing = az role assignment list --assignee $identity --role $acrPullRole --scope $script:AcrId --query "[0].id" -o tsv 2>$null + if (-not $existing) { + az role assignment create --assignee $identity --role $acrPullRole --scope $script:AcrId --output none 2>$null + Write-LogSuccess " AcrPull assigned to frontend identity" + } else { + Write-LogInfo " AcrPull already assigned to frontend identity ✓" + } + } + } +} + +# ============================================================================== +# Step 4: Determine Services +# ============================================================================== + +function Determine-Services { + Write-LogStep "Step 4: Determining Services to Deploy" + + $script:DeployBackend = $false + $script:DeployMcp = $false + $script:DeployFrontend = $false + + if ($Services) { + foreach ($svc in ($Services -split ',')) { + $svc = $svc.Trim().ToLower() + switch ($svc) { + "backend" { $script:DeployBackend = $true } + "mcp" { $script:DeployMcp = $true } + "frontend" { $script:DeployFrontend = $true } + default { Write-LogWarn "Unknown service: $svc (valid: backend, mcp, frontend)" } + } + } + } else { + $script:DeployBackend = $true + $script:DeployMcp = $true + $script:DeployFrontend = $true + } + + Write-Host " Services to deploy:" + if ($script:DeployBackend) { Write-Host " ✓ Backend" } else { Write-Host " ○ Backend (skipped)" } + if ($script:DeployMcp) { Write-Host " ✓ MCP Server" } else { Write-Host " ○ MCP Server (skipped)" } + if ($script:DeployFrontend) { Write-Host " ✓ Frontend" } else { Write-Host " ○ Frontend (skipped)" } +} + +# ============================================================================== +# Step 5: Generate Tag +# ============================================================================== + +function Generate-Tag { + Write-LogStep "Step 5: Generating Image Tag" + + if ($Tag) { + $script:ImageTag = $Tag + } else { + $timestamp = (Get-Date).ToString("yyyyMMdd-HHmmss") + $gitSha = git rev-parse --short=7 HEAD 2>$null + if (-not $gitSha) { $gitSha = "unknown" } + $script:ImageTag = "$timestamp-$gitSha" + } + + Write-LogSuccess "Image tag: $script:ImageTag" +} + +# ============================================================================== +# Step 6: Build & Push +# ============================================================================== + +function Build-AndPush { + Write-LogStep "Step 6: Building & Pushing Docker Images" + + if ($DeployOnly) { + Write-LogInfo "Skipping build (--DeployOnly mode)" + return + } + + # Login to ACR + Write-LogInfo "Logging into ACR: $script:AcrName..." + az acr login --name $script:AcrName + Write-LogSuccess "ACR login successful" + + $env:DOCKER_BUILDKIT = "1" + + if ($script:DeployBackend) { + $fullImage = "$($script:AcrLoginServer)/$BackendImageName`:$($script:ImageTag)" + Write-LogInfo "Building backend image: $fullImage" + if ($DryRun) { + Write-LogInfo "[DRY RUN] Would build: docker build -t $fullImage $BackendDir" + } else { + docker build -t $fullImage $BackendDir + Write-LogSuccess "Backend image built" + docker push $fullImage + Write-LogSuccess "Backend image pushed: $fullImage" + } + } + + if ($script:DeployMcp) { + $fullImage = "$($script:AcrLoginServer)/$McpImageName`:$($script:ImageTag)" + Write-LogInfo "Building MCP image: $fullImage" + if ($DryRun) { + Write-LogInfo "[DRY RUN] Would build: docker build -t $fullImage $McpDir" + } else { + docker build -t $fullImage $McpDir + Write-LogSuccess "MCP image built" + docker push $fullImage + Write-LogSuccess "MCP image pushed: $fullImage" + } + } + + if ($script:DeployFrontend) { + $fullImage = "$($script:AcrLoginServer)/$FrontendImageName`:$($script:ImageTag)" + Write-LogInfo "Building frontend image: $fullImage" + if ($DryRun) { + Write-LogInfo "[DRY RUN] Would build: docker build -t $fullImage $FrontendDir" + } else { + docker build -t $fullImage $FrontendDir + Write-LogSuccess "Frontend image built" + docker push $fullImage + Write-LogSuccess "Frontend image pushed: $fullImage" + } + } + + if ($BuildOnly) { + Write-LogSuccess "Build & push complete (-BuildOnly mode, skipping Azure update)" + } +} + +# ============================================================================== +# Step 7: Configure ACR on Resources (if changed) +# ============================================================================== + +function Configure-AcrOnResources { + if ($script:DeployBackend -and $script:BackendCA) { + $currentRegistry = az containerapp show --name $script:BackendCA --resource-group $ResourceGroup ` + --query "properties.configuration.registries[0].server" -o tsv 2>$null + if ($currentRegistry -and $currentRegistry -ne $script:AcrLoginServer) { + Write-LogInfo "Updating backend Container App registry to $($script:AcrLoginServer)..." + if (-not $DryRun) { + $identityId = az containerapp show --name $script:BackendCA --resource-group $ResourceGroup ` + --query "identity.userAssignedIdentities | keys(@) | [0]" -o tsv 2>$null + if ($identityId -and $identityId -ne "null") { + az containerapp registry set --name $script:BackendCA --resource-group $ResourceGroup ` + --server $script:AcrLoginServer --identity $identityId --output none 2>$null + } else { + az containerapp registry set --name $script:BackendCA --resource-group $ResourceGroup ` + --server $script:AcrLoginServer --identity system --output none 2>$null + } + Write-LogSuccess "Backend registry updated" + } + } + } + + if ($script:DeployMcp -and $script:McpCA) { + $currentRegistry = az containerapp show --name $script:McpCA --resource-group $ResourceGroup ` + --query "properties.configuration.registries[0].server" -o tsv 2>$null + if ($currentRegistry -and $currentRegistry -ne $script:AcrLoginServer) { + Write-LogInfo "Updating MCP Container App registry to $($script:AcrLoginServer)..." + if (-not $DryRun) { + $identityId = az containerapp show --name $script:McpCA --resource-group $ResourceGroup ` + --query "identity.userAssignedIdentities | keys(@) | [0]" -o tsv 2>$null + if ($identityId -and $identityId -ne "null") { + az containerapp registry set --name $script:McpCA --resource-group $ResourceGroup ` + --server $script:AcrLoginServer --identity $identityId --output none 2>$null + } else { + az containerapp registry set --name $script:McpCA --resource-group $ResourceGroup ` + --server $script:AcrLoginServer --identity system --output none 2>$null + } + Write-LogSuccess "MCP registry updated" + } + } + } + + if ($script:DeployFrontend -and $script:FrontendApp) { + Write-LogInfo "Updating frontend App Service registry config..." + if (-not $DryRun) { + az webapp config appsettings set --name $script:FrontendApp --resource-group $ResourceGroup ` + --settings DOCKER_REGISTRY_SERVER_URL="https://$($script:AcrLoginServer)" --output none 2>$null + az webapp config set --name $script:FrontendApp --resource-group $ResourceGroup ` + --generic-configurations '{\"acrUseManagedIdentityCreds\": true}' --output none 2>$null + Write-LogSuccess "Frontend registry config updated" + } + } +} + +# ============================================================================== +# Step 8: Update Azure Resources +# ============================================================================== + +function Update-AzureResources { + Write-LogStep "Step 7: Updating Azure Resources" + + if ($BuildOnly) { return } + + Configure-AcrOnResources + + # Backend + if ($script:DeployBackend) { + if (-not $script:BackendCA) { + Write-LogWarn "No backend Container App found — skipping backend deployment" + } else { + $fullImage = "$($script:AcrLoginServer)/$BackendImageName`:$($script:ImageTag)" + Write-LogInfo "Updating backend: $($script:BackendCA) → $fullImage" + if ($DryRun) { + Write-LogInfo "[DRY RUN] Would run: az containerapp update --name $($script:BackendCA) --image $fullImage" + } else { + az containerapp update --name $script:BackendCA --resource-group $ResourceGroup --image $fullImage --output none + Write-LogSuccess "Backend updated successfully" + } + } + } + + # MCP + if ($script:DeployMcp) { + if (-not $script:McpCA) { + Write-LogWarn "No MCP Container App found — skipping MCP deployment" + } else { + $fullImage = "$($script:AcrLoginServer)/$McpImageName`:$($script:ImageTag)" + Write-LogInfo "Updating MCP: $($script:McpCA) → $fullImage" + if ($DryRun) { + Write-LogInfo "[DRY RUN] Would run: az containerapp update --name $($script:McpCA) --image $fullImage" + } else { + az containerapp update --name $script:McpCA --resource-group $ResourceGroup --image $fullImage --output none + Write-LogSuccess "MCP updated successfully" + } + } + } + + # Frontend + if ($script:DeployFrontend) { + if (-not $script:FrontendApp) { + Write-LogWarn "No Frontend Web App found — skipping frontend deployment" + } else { + $fullImage = "$($script:AcrLoginServer)/$FrontendImageName`:$($script:ImageTag)" + Write-LogInfo "Updating frontend: $($script:FrontendApp) → $fullImage" + if ($DryRun) { + Write-LogInfo "[DRY RUN] Would run: az webapp config container set + restart" + } else { + az webapp config container set ` + --name $script:FrontendApp ` + --resource-group $ResourceGroup ` + --container-image-name $fullImage ` + --container-registry-url "https://$($script:AcrLoginServer)" ` + --output none + + Write-LogInfo "Restarting frontend App Service..." + az webapp restart --name $script:FrontendApp --resource-group $ResourceGroup --output none + Write-LogSuccess "Frontend updated and restarted" + } + } + } +} + +# ============================================================================== +# Summary +# ============================================================================== + +function Print-Summary { + Write-LogStep "Deployment Summary" + + Write-Host " Resource Group: $ResourceGroup" + Write-Host " ACR: $($script:AcrLoginServer)" + Write-Host " Image Tag: $($script:ImageTag)" + Write-Host "" + + if ($script:DeployBackend -and $script:BackendCA) { + Write-Host " Backend: $($script:AcrLoginServer)/$BackendImageName`:$($script:ImageTag)" + } + if ($script:DeployMcp -and $script:McpCA) { + Write-Host " MCP: $($script:AcrLoginServer)/$McpImageName`:$($script:ImageTag)" + } + if ($script:DeployFrontend -and $script:FrontendApp) { + Write-Host " Frontend: $($script:AcrLoginServer)/$FrontendImageName`:$($script:ImageTag)" + } + + Write-Host "" + Write-Host " ┌─ Rollback Commands (if needed) ───────────────────────────────" + + if ($script:DeployBackend -and $script:BackendCA -and $script:OldBackendImage) { + Write-Host " │ Backend: az containerapp update --name $($script:BackendCA) --resource-group $ResourceGroup --image $($script:OldBackendImage)" + } + if ($script:DeployMcp -and $script:McpCA -and $script:OldMcpImage) { + Write-Host " │ MCP: az containerapp update --name $($script:McpCA) --resource-group $ResourceGroup --image $($script:OldMcpImage)" + } + if ($script:DeployFrontend -and $script:FrontendApp -and $script:OldFrontendImage) { + $oldImg = $script:OldFrontendImage -replace '^DOCKER\|', '' + Write-Host " │ Frontend: az webapp config container set --name $($script:FrontendApp) --resource-group $ResourceGroup --container-image-name $oldImg" + } + Write-Host " └──────────────────────────────────────────────────────────────" + + if ($DryRun) { + Write-Host "" + Write-LogWarn "This was a DRY RUN — no changes were made." + } else { + Write-Host "" + Write-LogSuccess "Deployment complete!" + } +} + +# ============================================================================== +# Main +# ============================================================================== + +Write-Host "" +Write-Host "╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan +Write-Host "║ MACAE - Deploy Local Code to Azure ║" -ForegroundColor Cyan +Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan +Write-Host "" + +Check-Prerequisites +Discover-Resources +Resolve-Acr +Determine-Services +Generate-Tag +Build-AndPush +Update-AzureResources +Print-Summary diff --git a/deploy_to_azure.sh b/deploy_to_azure.sh new file mode 100644 index 000000000..49f0c93ee --- /dev/null +++ b/deploy_to_azure.sh @@ -0,0 +1,756 @@ +#!/usr/bin/env bash +# ============================================================================== +# MACAE - Deploy Local Code to Azure +# ============================================================================== +# +# Builds Docker images for changed services, pushes to ACR, and updates +# the deployed Azure resources (Container Apps / App Service). +# +# Usage: +# ./deploy_to_azure.sh -g [options] +# +# Examples: +# ./deploy_to_azure.sh -g rg-macae-dev # Deploy all services +# ./deploy_to_azure.sh -g rg-macae-dev --services backend,mcp # Deploy specific services +# ./deploy_to_azure.sh -g rg-macae-dev --acr myacr # Use specific ACR +# ./deploy_to_azure.sh -g rg-macae-dev --dry-run # Preview what would happen +# ============================================================================== + +set -euo pipefail + +# ============================================================================== +# Configuration +# ============================================================================== + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RESOURCE_GROUP="" +ACR_INPUT="" +SERVICES="" +CUSTOM_TAG="" +DRY_RUN=false +BUILD_ONLY=false +DEPLOY_ONLY=false + +# Image names (matching infra conventions) +BACKEND_IMAGE_NAME="macaebackend" +MCP_IMAGE_NAME="macaemcp" +FRONTEND_IMAGE_NAME="macaefrontend" + +# Service paths +BACKEND_DIR="$SCRIPT_DIR/src/backend" +MCP_DIR="$SCRIPT_DIR/src/mcp_server" +FRONTEND_DIR="$SCRIPT_DIR/src/App" + +# ============================================================================== +# Logging +# ============================================================================== + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +BLUE='\033[0;34m'; CYAN='\033[0;36m'; NC='\033[0m' + +log_info() { echo -e "${BLUE}[i]${NC} $*"; } +log_success() { echo -e "${GREEN}[✓]${NC} $*"; } +log_warn() { echo -e "${YELLOW}[!]${NC} $*"; } +log_error() { echo -e "${RED}[✗]${NC} $*"; } +log_step() { echo -e "\n${CYAN}━━━ $* ━━━${NC}\n"; } + +# ============================================================================== +# Argument Parsing +# ============================================================================== + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + -g|--resource-group) + RESOURCE_GROUP="$2"; shift 2 ;; + --acr) + ACR_INPUT="$2"; shift 2 ;; + --services) + SERVICES="$2"; shift 2 ;; + --tag) + CUSTOM_TAG="$2"; shift 2 ;; + --dry-run) + DRY_RUN=true; shift ;; + --build-only) + BUILD_ONLY=true; shift ;; + --deploy-only) + DEPLOY_ONLY=true; shift ;; + -h|--help) + show_help; exit 0 ;; + *) + log_error "Unknown option: $1"; show_help; exit 1 ;; + esac + done +} + +show_help() { + cat < [options] + +Required: + -g, --resource-group Azure Resource Group name + +Options: + --acr ACR name or login server (auto-discovers if not set) + --services Comma-separated: backend,mcp,frontend (default: all) + --tag Custom image tag (default: auto-generated) + --dry-run Preview what would happen without making changes + --build-only Build and push images only, don't update Azure resources + --deploy-only Update Azure resources only (images must already exist) + -h, --help Show this help message + +Examples: + ./deploy_to_azure.sh -g rg-macae-dev + ./deploy_to_azure.sh -g rg-macae-dev --services backend + ./deploy_to_azure.sh -g rg-macae-dev --acr myregistry --tag v1.0 + ./deploy_to_azure.sh -g rg-macae-dev --dry-run +EOF +} + +# ============================================================================== +# Prerequisites +# ============================================================================== + +check_prerequisites() { + log_step "Step 1: Checking Prerequisites" + + local missing=() + + if command -v docker &>/dev/null; then + log_success "Docker found: $(docker --version)" + else + missing+=("docker") + fi + + if command -v az &>/dev/null; then + log_success "Azure CLI found" + else + missing+=("azure-cli") + fi + + if command -v git &>/dev/null; then + log_success "Git found" + else + missing+=("git") + fi + + if [[ ${#missing[@]} -gt 0 ]]; then + log_error "Missing prerequisites: ${missing[*]}" + echo "" + for tool in "${missing[@]}"; do + case "$tool" in + docker) + echo " ┌─ Docker ──────────────────────────────────────────────────────" + echo " │ Download: https://docs.docker.com/get-docker/" + echo " │ Windows: Docker Desktop from https://www.docker.com/products/docker-desktop" + echo " │ Verify: docker --version" + echo " └──────────────────────────────────────────────────────────────" + ;; + azure-cli) + echo " ┌─ Azure CLI ───────────────────────────────────────────────────" + echo " │ Install: https://learn.microsoft.com/cli/azure/install-azure-cli" + echo " │ Verify: az --version" + echo " └──────────────────────────────────────────────────────────────" + ;; + git) + echo " ┌─ Git ─────────────────────────────────────────────────────────" + echo " │ Install: https://git-scm.com/downloads" + echo " │ Verify: git --version" + echo " └──────────────────────────────────────────────────────────────" + ;; + esac + done + exit 1 + fi + + # Check Docker daemon is running + if ! docker info &>/dev/null; then + log_error "Docker daemon is not running. Please start Docker Desktop and retry." + exit 1 + fi + log_success "Docker daemon is running" + + # Check Azure login + if ! az account show &>/dev/null; then + log_warn "Not logged into Azure CLI. Running 'az login'..." + az login + fi + log_success "Logged into Azure CLI" +} + +# ============================================================================== +# Step 2: Validate Resource Group & Discover Resources +# ============================================================================== + +validate_and_discover() { + log_step "Step 2: Discovering Azure Resources" + + if [[ -z "$RESOURCE_GROUP" ]]; then + log_error "Resource group is required. Use: -g " + exit 1 + fi + + if ! az group show --name "$RESOURCE_GROUP" &>/dev/null; then + log_error "Resource group '$RESOURCE_GROUP' not found." + exit 1 + fi + log_success "Resource group: $RESOURCE_GROUP" + + # Discover backend container app + local ca_list + ca_list=$(az containerapp list --resource-group "$RESOURCE_GROUP" --query "[].name" -o tsv 2>/dev/null || true) + + BACKEND_CA="" + MCP_CA="" + + if [[ -n "$ca_list" ]]; then + while IFS= read -r app; do + app=$(echo "$app" | tr -d '\r') + if [[ "$app" == ca-mcp-* ]]; then + MCP_CA="$app" + elif [[ "$app" == ca-* ]]; then + BACKEND_CA="$app" + fi + done <<< "$ca_list" + fi + + if [[ -n "$BACKEND_CA" ]]; then + log_success "Backend Container App: $BACKEND_CA" + else + log_warn "Backend Container App: not found in RG" + fi + + if [[ -n "$MCP_CA" ]]; then + log_success "MCP Container App: $MCP_CA" + else + log_warn "MCP Container App: not found in RG" + fi + + # Discover frontend web app + FRONTEND_APP="" + FRONTEND_APP=$(az webapp list --resource-group "$RESOURCE_GROUP" --query "[0].name" -o tsv 2>/dev/null || true) + + if [[ -n "$FRONTEND_APP" ]]; then + log_success "Frontend Web App: $FRONTEND_APP" + else + log_warn "Frontend Web App: not found in RG" + fi + + # Capture current images for rollback + if [[ -n "$BACKEND_CA" ]]; then + OLD_BACKEND_IMAGE=$(az containerapp show --name "$BACKEND_CA" --resource-group "$RESOURCE_GROUP" \ + --query "properties.template.containers[0].image" -o tsv 2>/dev/null || echo "unknown") + log_info "Current backend image: $OLD_BACKEND_IMAGE" + fi + + if [[ -n "$MCP_CA" ]]; then + OLD_MCP_IMAGE=$(az containerapp show --name "$MCP_CA" --resource-group "$RESOURCE_GROUP" \ + --query "properties.template.containers[0].image" -o tsv 2>/dev/null || echo "unknown") + log_info "Current MCP image: $OLD_MCP_IMAGE" + fi + + if [[ -n "$FRONTEND_APP" ]]; then + OLD_FRONTEND_IMAGE=$(az webapp config show --name "$FRONTEND_APP" --resource-group "$RESOURCE_GROUP" \ + --query "linuxFxVersion" -o tsv 2>/dev/null || echo "unknown") + log_info "Current frontend image: $OLD_FRONTEND_IMAGE" + fi +} + +# ============================================================================== +# Step 3: Resolve ACR +# ============================================================================== + +resolve_acr() { + log_step "Step 3: Resolving Container Registry" + + if [[ -n "$ACR_INPUT" ]]; then + # User provided ACR — normalize to name and login server + local input="${ACR_INPUT%.azurecr.io}" # strip suffix if provided + ACR_NAME=$(az acr show --name "$input" --query "name" -o tsv 2>/dev/null || true) + if [[ -z "$ACR_NAME" ]]; then + log_error "ACR '$ACR_INPUT' not found or not accessible." + exit 1 + fi + ACR_LOGIN_SERVER=$(az acr show --name "$ACR_NAME" --query "loginServer" -o tsv) + ACR_ID=$(az acr show --name "$ACR_NAME" --query "id" -o tsv) + log_success "Using specified ACR: $ACR_NAME ($ACR_LOGIN_SERVER)" + return + fi + + # Try to discover ACR in the RG + log_info "Looking for ACR in resource group..." + ACR_NAME=$(az acr list --resource-group "$RESOURCE_GROUP" --query "[0].name" -o tsv 2>/dev/null || true) + + if [[ -n "$ACR_NAME" ]]; then + ACR_LOGIN_SERVER=$(az acr show --name "$ACR_NAME" --query "loginServer" -o tsv) + ACR_ID=$(az acr show --name "$ACR_NAME" --query "id" -o tsv) + log_success "Found ACR in RG: $ACR_NAME ($ACR_LOGIN_SERVER)" + return + fi + + # No ACR found — ask user + log_warn "No ACR found in resource group '$RESOURCE_GROUP'." + echo "" + read -rp "Do you have an existing ACR? Enter its name (or press Enter to create one): " user_acr + + if [[ -n "$user_acr" ]]; then + local input="${user_acr%.azurecr.io}" + ACR_NAME=$(az acr show --name "$input" --query "name" -o tsv 2>/dev/null || true) + if [[ -z "$ACR_NAME" ]]; then + log_error "ACR '$user_acr' not found." + exit 1 + fi + ACR_LOGIN_SERVER=$(az acr show --name "$ACR_NAME" --query "loginServer" -o tsv) + ACR_ID=$(az acr show --name "$ACR_NAME" --query "id" -o tsv) + log_success "Using ACR: $ACR_NAME ($ACR_LOGIN_SERVER)" + else + # Create new ACR + local suffix + suffix=$(echo "$RESOURCE_GROUP" | sed 's/[^a-zA-Z0-9]//g' | tail -c 15) + local new_acr_name="acr${suffix}$(date +%s | tail -c 6)" + new_acr_name=$(echo "$new_acr_name" | tr '[:upper:]' '[:lower:]' | head -c 50) + + log_info "Creating ACR: $new_acr_name in $RESOURCE_GROUP..." + az acr create \ + --resource-group "$RESOURCE_GROUP" \ + --name "$new_acr_name" \ + --sku Basic \ + --admin-enabled false \ + --output none + + ACR_NAME="$new_acr_name" + ACR_LOGIN_SERVER=$(az acr show --name "$ACR_NAME" --query "loginServer" -o tsv) + ACR_ID=$(az acr show --name "$ACR_NAME" --query "id" -o tsv) + log_success "Created ACR: $ACR_NAME ($ACR_LOGIN_SERVER)" + + # Assign AcrPull to resource identities + assign_acr_pull_roles + fi +} + +# ============================================================================== +# ACR Pull Role Assignment +# ============================================================================== + +assign_acr_pull_roles() { + log_info "Assigning AcrPull role to service identities..." + + local acr_pull_role="7f951dda-4ed3-4680-a7ca-43fe172d538d" # AcrPull built-in role ID + + # Backend Container App identity + if [[ -n "$BACKEND_CA" ]]; then + local backend_identity + backend_identity=$(az containerapp show --name "$BACKEND_CA" --resource-group "$RESOURCE_GROUP" \ + --query "identity.principalId" -o tsv 2>/dev/null || true) + if [[ -n "$backend_identity" && "$backend_identity" != "null" ]]; then + local existing + existing=$(az role assignment list --assignee "$backend_identity" --role "$acr_pull_role" --scope "$ACR_ID" --query "[0].id" -o tsv 2>/dev/null || true) + if [[ -z "$existing" ]]; then + az role assignment create --assignee "$backend_identity" --role "$acr_pull_role" --scope "$ACR_ID" --output none 2>/dev/null || true + log_success " AcrPull assigned to backend identity" + else + log_info " AcrPull already assigned to backend identity ✓" + fi + fi + fi + + # MCP Container App identity + if [[ -n "$MCP_CA" ]]; then + local mcp_identity + mcp_identity=$(az containerapp show --name "$MCP_CA" --resource-group "$RESOURCE_GROUP" \ + --query "identity.principalId" -o tsv 2>/dev/null || true) + if [[ -n "$mcp_identity" && "$mcp_identity" != "null" ]]; then + local existing + existing=$(az role assignment list --assignee "$mcp_identity" --role "$acr_pull_role" --scope "$ACR_ID" --query "[0].id" -o tsv 2>/dev/null || true) + if [[ -z "$existing" ]]; then + az role assignment create --assignee "$mcp_identity" --role "$acr_pull_role" --scope "$ACR_ID" --output none 2>/dev/null || true + log_success " AcrPull assigned to MCP identity" + else + log_info " AcrPull already assigned to MCP identity ✓" + fi + fi + fi + + # Frontend Web App identity + if [[ -n "$FRONTEND_APP" ]]; then + local frontend_identity + frontend_identity=$(az webapp show --name "$FRONTEND_APP" --resource-group "$RESOURCE_GROUP" \ + --query "identity.principalId" -o tsv 2>/dev/null || true) + if [[ -n "$frontend_identity" && "$frontend_identity" != "null" ]]; then + local existing + existing=$(az role assignment list --assignee "$frontend_identity" --role "$acr_pull_role" --scope "$ACR_ID" --query "[0].id" -o tsv 2>/dev/null || true) + if [[ -z "$existing" ]]; then + az role assignment create --assignee "$frontend_identity" --role "$acr_pull_role" --scope "$ACR_ID" --output none 2>/dev/null || true + log_success " AcrPull assigned to frontend identity" + else + log_info " AcrPull already assigned to frontend identity ✓" + fi + fi + fi +} + +# ============================================================================== +# Step 4: Determine Services to Deploy +# ============================================================================== + +determine_services() { + log_step "Step 4: Determining Services to Deploy" + + DEPLOY_BACKEND=false + DEPLOY_MCP=false + DEPLOY_FRONTEND=false + + if [[ -n "$SERVICES" ]]; then + # Explicit service selection + IFS=',' read -ra svc_list <<< "$SERVICES" + for svc in "${svc_list[@]}"; do + svc=$(echo "$svc" | tr -d ' ' | tr '[:upper:]' '[:lower:]') + case "$svc" in + backend) DEPLOY_BACKEND=true ;; + mcp) DEPLOY_MCP=true ;; + frontend) DEPLOY_FRONTEND=true ;; + *) log_warn "Unknown service: $svc (valid: backend, mcp, frontend)" ;; + esac + done + else + # Default: deploy all services + DEPLOY_BACKEND=true + DEPLOY_MCP=true + DEPLOY_FRONTEND=true + fi + + echo " Services to deploy:" + [[ "$DEPLOY_BACKEND" == true ]] && echo " ✓ Backend" || echo " ○ Backend (skipped)" + [[ "$DEPLOY_MCP" == true ]] && echo " ✓ MCP Server" || echo " ○ MCP Server (skipped)" + [[ "$DEPLOY_FRONTEND" == true ]] && echo " ✓ Frontend" || echo " ○ Frontend (skipped)" +} + +# ============================================================================== +# Step 5: Generate Image Tag +# ============================================================================== + +generate_tag() { + log_step "Step 5: Generating Image Tag" + + if [[ -n "$CUSTOM_TAG" ]]; then + IMAGE_TAG="$CUSTOM_TAG" + else + local timestamp + timestamp=$(date +%Y%m%d-%H%M%S) + local git_sha + git_sha=$(git rev-parse --short=7 HEAD 2>/dev/null || echo "unknown") + IMAGE_TAG="${timestamp}-${git_sha}" + fi + + log_success "Image tag: $IMAGE_TAG" +} + +# ============================================================================== +# Step 6: Build & Push Images +# ============================================================================== + +build_and_push() { + log_step "Step 6: Building & Pushing Docker Images" + + if [[ "$DEPLOY_ONLY" == true ]]; then + log_info "Skipping build (--deploy-only mode)" + return + fi + + # Login to ACR + log_info "Logging into ACR: $ACR_NAME..." + az acr login --name "$ACR_NAME" + log_success "ACR login successful" + + export DOCKER_BUILDKIT=1 + + if [[ "$DEPLOY_BACKEND" == true ]]; then + local full_image="$ACR_LOGIN_SERVER/$BACKEND_IMAGE_NAME:$IMAGE_TAG" + log_info "Building backend image: $full_image" + if [[ "$DRY_RUN" == true ]]; then + log_info "[DRY RUN] Would build: docker build -t $full_image $BACKEND_DIR" + else + docker build -t "$full_image" "$BACKEND_DIR" + log_success "Backend image built" + docker push "$full_image" + log_success "Backend image pushed: $full_image" + fi + fi + + if [[ "$DEPLOY_MCP" == true ]]; then + local full_image="$ACR_LOGIN_SERVER/$MCP_IMAGE_NAME:$IMAGE_TAG" + log_info "Building MCP image: $full_image" + if [[ "$DRY_RUN" == true ]]; then + log_info "[DRY RUN] Would build: docker build -t $full_image $MCP_DIR" + else + docker build -t "$full_image" "$MCP_DIR" + log_success "MCP image built" + docker push "$full_image" + log_success "MCP image pushed: $full_image" + fi + fi + + if [[ "$DEPLOY_FRONTEND" == true ]]; then + local full_image="$ACR_LOGIN_SERVER/$FRONTEND_IMAGE_NAME:$IMAGE_TAG" + log_info "Building frontend image: $full_image" + if [[ "$DRY_RUN" == true ]]; then + log_info "[DRY RUN] Would build: docker build -t $full_image $FRONTEND_DIR" + else + docker build -t "$full_image" "$FRONTEND_DIR" + log_success "Frontend image built" + docker push "$full_image" + log_success "Frontend image pushed: $full_image" + fi + fi + + if [[ "$BUILD_ONLY" == true ]]; then + log_success "Build & push complete (--build-only mode, skipping Azure update)" + fi +} + +# ============================================================================== +# Step 7: Configure ACR on Azure Resources (if ACR changed) +# ============================================================================== + +configure_acr_on_resources() { + # Check if we need to update ACR configuration on the resources + # This is needed when the ACR is different from what's currently configured + + if [[ "$DEPLOY_BACKEND" == true && -n "$BACKEND_CA" ]]; then + local current_registry + current_registry=$(az containerapp show --name "$BACKEND_CA" --resource-group "$RESOURCE_GROUP" \ + --query "properties.configuration.registries[0].server" -o tsv 2>/dev/null || true) + + if [[ -n "$current_registry" && "$current_registry" != "$ACR_LOGIN_SERVER" ]]; then + log_info "Updating backend Container App registry to $ACR_LOGIN_SERVER..." + if [[ "$DRY_RUN" != true ]]; then + # Get managed identity for registry pull + local identity_id + identity_id=$(az containerapp show --name "$BACKEND_CA" --resource-group "$RESOURCE_GROUP" \ + --query "identity.userAssignedIdentities | keys(@) | [0]" -o tsv 2>/dev/null || true) + + if [[ -n "$identity_id" && "$identity_id" != "null" ]]; then + az containerapp registry set \ + --name "$BACKEND_CA" \ + --resource-group "$RESOURCE_GROUP" \ + --server "$ACR_LOGIN_SERVER" \ + --identity "$identity_id" \ + --output none 2>/dev/null || true + else + az containerapp registry set \ + --name "$BACKEND_CA" \ + --resource-group "$RESOURCE_GROUP" \ + --server "$ACR_LOGIN_SERVER" \ + --identity system \ + --output none 2>/dev/null || true + fi + log_success "Backend registry updated" + fi + fi + fi + + if [[ "$DEPLOY_MCP" == true && -n "$MCP_CA" ]]; then + local current_registry + current_registry=$(az containerapp show --name "$MCP_CA" --resource-group "$RESOURCE_GROUP" \ + --query "properties.configuration.registries[0].server" -o tsv 2>/dev/null || true) + + if [[ -n "$current_registry" && "$current_registry" != "$ACR_LOGIN_SERVER" ]]; then + log_info "Updating MCP Container App registry to $ACR_LOGIN_SERVER..." + if [[ "$DRY_RUN" != true ]]; then + local identity_id + identity_id=$(az containerapp show --name "$MCP_CA" --resource-group "$RESOURCE_GROUP" \ + --query "identity.userAssignedIdentities | keys(@) | [0]" -o tsv 2>/dev/null || true) + + if [[ -n "$identity_id" && "$identity_id" != "null" ]]; then + az containerapp registry set \ + --name "$MCP_CA" \ + --resource-group "$RESOURCE_GROUP" \ + --server "$ACR_LOGIN_SERVER" \ + --identity "$identity_id" \ + --output none 2>/dev/null || true + else + az containerapp registry set \ + --name "$MCP_CA" \ + --resource-group "$RESOURCE_GROUP" \ + --server "$ACR_LOGIN_SERVER" \ + --identity system \ + --output none 2>/dev/null || true + fi + log_success "MCP registry updated" + fi + fi + fi + + if [[ "$DEPLOY_FRONTEND" == true && -n "$FRONTEND_APP" ]]; then + log_info "Updating frontend App Service registry config..." + if [[ "$DRY_RUN" != true ]]; then + az webapp config appsettings set \ + --name "$FRONTEND_APP" \ + --resource-group "$RESOURCE_GROUP" \ + --settings DOCKER_REGISTRY_SERVER_URL="https://$ACR_LOGIN_SERVER" \ + --output none 2>/dev/null || true + + # Enable managed identity for ACR pull + az webapp config set \ + --name "$FRONTEND_APP" \ + --resource-group "$RESOURCE_GROUP" \ + --generic-configurations '{"acrUseManagedIdentityCreds": true}' \ + --output none 2>/dev/null || true + log_success "Frontend registry config updated" + fi + fi +} + +# ============================================================================== +# Step 8: Update Azure Resources +# ============================================================================== + +update_azure_resources() { + log_step "Step 7: Updating Azure Resources" + + if [[ "$BUILD_ONLY" == true ]]; then + return + fi + + # Configure ACR on resources if needed + configure_acr_on_resources + + # Update Backend Container App + if [[ "$DEPLOY_BACKEND" == true ]]; then + if [[ -z "$BACKEND_CA" ]]; then + log_warn "No backend Container App found — skipping backend deployment" + else + local full_image="$ACR_LOGIN_SERVER/$BACKEND_IMAGE_NAME:$IMAGE_TAG" + log_info "Updating backend: $BACKEND_CA → $full_image" + if [[ "$DRY_RUN" == true ]]; then + log_info "[DRY RUN] Would run: az containerapp update --name $BACKEND_CA --resource-group $RESOURCE_GROUP --image $full_image" + else + az containerapp update \ + --name "$BACKEND_CA" \ + --resource-group "$RESOURCE_GROUP" \ + --image "$full_image" \ + --output none + log_success "Backend updated successfully" + fi + fi + fi + + # Update MCP Container App + if [[ "$DEPLOY_MCP" == true ]]; then + if [[ -z "$MCP_CA" ]]; then + log_warn "No MCP Container App found — skipping MCP deployment" + else + local full_image="$ACR_LOGIN_SERVER/$MCP_IMAGE_NAME:$IMAGE_TAG" + log_info "Updating MCP: $MCP_CA → $full_image" + if [[ "$DRY_RUN" == true ]]; then + log_info "[DRY RUN] Would run: az containerapp update --name $MCP_CA --resource-group $RESOURCE_GROUP --image $full_image" + else + az containerapp update \ + --name "$MCP_CA" \ + --resource-group "$RESOURCE_GROUP" \ + --image "$full_image" \ + --output none + log_success "MCP updated successfully" + fi + fi + fi + + # Update Frontend App Service + if [[ "$DEPLOY_FRONTEND" == true ]]; then + if [[ -z "$FRONTEND_APP" ]]; then + log_warn "No Frontend Web App found — skipping frontend deployment" + else + local full_image="$ACR_LOGIN_SERVER/$FRONTEND_IMAGE_NAME:$IMAGE_TAG" + log_info "Updating frontend: $FRONTEND_APP → $full_image" + if [[ "$DRY_RUN" == true ]]; then + log_info "[DRY RUN] Would run: az webapp config container set + restart" + else + az webapp config container set \ + --name "$FRONTEND_APP" \ + --resource-group "$RESOURCE_GROUP" \ + --container-image-name "$full_image" \ + --container-registry-url "https://$ACR_LOGIN_SERVER" \ + --output none + + log_info "Restarting frontend App Service..." + az webapp restart \ + --name "$FRONTEND_APP" \ + --resource-group "$RESOURCE_GROUP" \ + --output none + log_success "Frontend updated and restarted" + fi + fi + fi +} + +# ============================================================================== +# Summary & Rollback Info +# ============================================================================== + +print_summary() { + log_step "Deployment Summary" + + echo " Resource Group: $RESOURCE_GROUP" + echo " ACR: $ACR_LOGIN_SERVER" + echo " Image Tag: $IMAGE_TAG" + echo "" + + if [[ "$DEPLOY_BACKEND" == true && -n "$BACKEND_CA" ]]; then + echo " Backend: $ACR_LOGIN_SERVER/$BACKEND_IMAGE_NAME:$IMAGE_TAG" + fi + if [[ "$DEPLOY_MCP" == true && -n "$MCP_CA" ]]; then + echo " MCP: $ACR_LOGIN_SERVER/$MCP_IMAGE_NAME:$IMAGE_TAG" + fi + if [[ "$DEPLOY_FRONTEND" == true && -n "$FRONTEND_APP" ]]; then + echo " Frontend: $ACR_LOGIN_SERVER/$FRONTEND_IMAGE_NAME:$IMAGE_TAG" + fi + + echo "" + echo " ┌─ Rollback Commands (if needed) ───────────────────────────────" + + if [[ "$DEPLOY_BACKEND" == true && -n "$BACKEND_CA" && -n "${OLD_BACKEND_IMAGE:-}" ]]; then + echo " │ Backend: az containerapp update --name $BACKEND_CA --resource-group $RESOURCE_GROUP --image $OLD_BACKEND_IMAGE" + fi + if [[ "$DEPLOY_MCP" == true && -n "$MCP_CA" && -n "${OLD_MCP_IMAGE:-}" ]]; then + echo " │ MCP: az containerapp update --name $MCP_CA --resource-group $RESOURCE_GROUP --image $OLD_MCP_IMAGE" + fi + if [[ "$DEPLOY_FRONTEND" == true && -n "$FRONTEND_APP" && -n "${OLD_FRONTEND_IMAGE:-}" ]]; then + local old_img="${OLD_FRONTEND_IMAGE#DOCKER|}" + echo " │ Frontend: az webapp config container set --name $FRONTEND_APP --resource-group $RESOURCE_GROUP --container-image-name $old_img" + fi + echo " └──────────────────────────────────────────────────────────────" + + if [[ "$DRY_RUN" == true ]]; then + echo "" + log_warn "This was a DRY RUN — no changes were made." + else + echo "" + log_success "Deployment complete!" + fi +} + +# ============================================================================== +# Main +# ============================================================================== + +main() { + echo "" + echo -e "${CYAN}╔══════════════════════════════════════════════════════════════╗${NC}" + echo -e "${CYAN}║ MACAE - Deploy Local Code to Azure ║${NC}" + echo -e "${CYAN}╚══════════════════════════════════════════════════════════════╝${NC}" + echo "" + + parse_args "$@" + check_prerequisites + validate_and_discover + resolve_acr + determine_services + generate_tag + build_and_push + update_azure_resources + print_summary +} + +main "$@" diff --git a/setup_local_dev.ps1 b/setup_local_dev.ps1 new file mode 100644 index 000000000..e6c41b7e9 --- /dev/null +++ b/setup_local_dev.ps1 @@ -0,0 +1,825 @@ +# ============================================================================== +# MACAE - Local Development Setup Script (Windows PowerShell) +# ============================================================================== +# Automates the entire local development setup for the Multi-Agent Custom +# Automation Engine Solution Accelerator on Windows. +# +# Usage: +# .\setup_local_dev.ps1 [-ResourceGroup ] [-Subscription ] [-AzdEnvName ] [-AssignRbac] [-SkipVscode] +# +# Examples: +# .\setup_local_dev.ps1 -ResourceGroup "my-resource-group" +# .\setup_local_dev.ps1 -AzdEnvName "my-azd-env" +# .\setup_local_dev.ps1 -ResourceGroup "rg-macae-dev" -AssignRbac +# ============================================================================== + +param( + [string]$ResourceGroup = "", + [string]$Subscription = "", + [string]$AzdEnvName = "", + [switch]$AssignRbac, + [switch]$SkipVscode +) + +$ErrorActionPreference = "Stop" + +# Script directory (repo root) +$ScriptDir = $PSScriptRoot +if (-not $ScriptDir) { $ScriptDir = Get-Location } +$BackendDir = Join-Path $ScriptDir "src\backend" +$McpDir = Join-Path $ScriptDir "src\mcp_server" +$FrontendDir = Join-Path $ScriptDir "src\App" + +# ============================================================================== +# Helper Functions +# ============================================================================== + +function Write-LogInfo { param([string]$Message) Write-Host "[INFO] $Message" -ForegroundColor Blue } +function Write-LogSuccess { param([string]$Message) Write-Host "[✓] $Message" -ForegroundColor Green } +function Write-LogWarn { param([string]$Message) Write-Host "[WARN] $Message" -ForegroundColor Yellow } +function Write-LogError { param([string]$Message) Write-Host "[ERROR] $Message" -ForegroundColor Red } +function Write-LogStep { + param([string]$Message) + Write-Host "" + Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan + Write-Host " $Message" -ForegroundColor Cyan + Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan + Write-Host "" +} + +function Test-CommandExists { + param([string]$Command) + return [bool](Get-Command $Command -ErrorAction SilentlyContinue) +} + +# ============================================================================== +# Step 1: Prerequisites +# ============================================================================== + +function Check-Prerequisites { + Write-LogStep "Step 1: Checking Prerequisites" + + $missing = @() + + # Python 3.12+ + if (Test-CommandExists "py") { + $pyVersion = & py -3.12 --version 2>$null + if ($pyVersion) { + Write-LogSuccess "Python 3.12 found: $pyVersion" + } else { + $missing += "Python.Python.3.12" + } + } elseif (Test-CommandExists "python") { + $pyVersion = & python --version 2>$null + if ($pyVersion -match "3\.1[2-9]") { + Write-LogSuccess "Python found: $pyVersion" + } else { + $missing += "Python.Python.3.12" + } + } else { + $missing += "Python.Python.3.12" + } + + # Node.js + if (Test-CommandExists "node") { + Write-LogSuccess "Node.js found: $(node --version)" + } else { + $missing += "OpenJS.NodeJS.LTS" + } + + # npm + if (Test-CommandExists "npm") { + Write-LogSuccess "npm found: $(npm --version)" + } else { + $missing += "npm (install Node.js)" + } + + # uv + if (Test-CommandExists "uv") { + Write-LogSuccess "uv found: $(uv --version)" + } else { + $missing += "uv" + } + + # Azure CLI + if (Test-CommandExists "az") { + Write-LogSuccess "Azure CLI found" + } else { + $missing += "Microsoft.AzureCLI" + } + + # Git + if (Test-CommandExists "git") { + Write-LogSuccess "Git found: $(git --version)" + } else { + $missing += "Git.Git" + } + + if ($missing.Count -eq 0) { + Write-LogSuccess "All prerequisites installed!" + return + } + + Write-LogError "Missing prerequisites: $($missing -join ', ')" + Write-Host "" + Write-LogWarn "Please install the following before proceeding:" + Write-Host "" + foreach ($tool in $missing) { + switch -Regex ($tool) { + "Python" { + Write-Host " ┌─ Python 3.12 ─────────────────────────────────────────────────" + Write-Host " │ Download: https://www.python.org/downloads/" + Write-Host " │ Quick install (Windows):" + Write-Host " │ winget install Python.Python.3.12" + Write-Host " │ Verify: python --version (should show 3.12.x)" + Write-Host " │ Note: During install, CHECK 'Add Python to PATH'" + Write-Host " └──────────────────────────────────────────────────────────────" + } + "npm|Node" { + Write-Host " ┌─ Node.js & npm ───────────────────────────────────────────────" + Write-Host " │ Download: https://nodejs.org/ (LTS version)" + Write-Host " │ Quick install (Windows):" + Write-Host " │ winget install OpenJS.NodeJS.LTS" + Write-Host " │ Verify: node --version && npm --version" + Write-Host " └──────────────────────────────────────────────────────────────" + } + "uv" { + Write-Host " ┌─ uv (Python package manager) ─────────────────────────────────" + Write-Host " │ Quick install options:" + Write-Host " │ Option 1: py -3.12 -m pip install uv" + Write-Host " │ Option 2: winget install astral-sh.uv" + Write-Host " │ Option 3: irm https://astral.sh/uv/install.ps1 | iex" + Write-Host " │ Docs: https://docs.astral.sh/uv/getting-started/installation/" + Write-Host " │ Verify: uv --version" + Write-Host " └──────────────────────────────────────────────────────────────" + } + "AzureCLI" { + Write-Host " ┌─ Azure CLI ───────────────────────────────────────────────────" + Write-Host " │ Download: https://aka.ms/installazurecliwindows" + Write-Host " │ Quick install (Windows):" + Write-Host " │ winget install Microsoft.AzureCLI" + Write-Host " │ Docs: https://learn.microsoft.com/cli/azure/install-azure-cli" + Write-Host " │ Verify: az --version" + Write-Host " │ After install: az login" + Write-Host " └──────────────────────────────────────────────────────────────" + } + "Git" { + Write-Host " ┌─ Git ─────────────────────────────────────────────────────────" + Write-Host " │ Download: https://git-scm.com/download/win" + Write-Host " │ Quick install (Windows):" + Write-Host " │ winget install Git.Git" + Write-Host " │ Verify: git --version" + Write-Host " └──────────────────────────────────────────────────────────────" + } + } + } + Write-Host "" + Write-Host " For detailed step-by-step instructions, see:" + Write-Host " https://github.com/microsoft/Multi-Agent-Custom-Automation-Engine-Solution-Accelerator/blob/main/docs/LocalDevelopmentSetup.md#step-1-prerequisites---install-required-tools" + Write-Host "" + Write-Host " Also see: docs/NON_DEVCONTAINER_SETUP.md for VS Code extension recommendations." + Write-Host "" + Write-LogInfo "After installing, restart your terminal and re-run this script." + exit 1 +} + +# ============================================================================== +# Step 2: Azure Authentication +# ============================================================================== + +function Check-AzureAuth { + Write-LogStep "Step 2: Azure Authentication" + + $accountInfo = $null + try { + $accountInfo = az account show --output json 2>$null | ConvertFrom-Json + } catch {} + + if (-not $accountInfo) { + Write-LogWarn "Not logged into Azure CLI" + Write-LogInfo "Running 'az login'..." + az login + $accountInfo = az account show --output json | ConvertFrom-Json + } + + if ($Subscription) { + Write-LogInfo "Setting subscription to: $Subscription" + az account set --subscription $Subscription + $accountInfo = az account show --output json | ConvertFrom-Json + } + + $script:Subscription = $accountInfo.id + Write-LogSuccess "Logged in to Azure" + Write-LogInfo " Subscription: $($accountInfo.name) ($($accountInfo.id))" + + $response = Read-Host "Is this the correct subscription? [Y/n]" + if ($response -match "^[Nn]") { + az account list --output table --query "[].{Name:name, Id:id, State:state}" + $script:Subscription = Read-Host "Enter subscription ID" + az account set --subscription $script:Subscription + } +} + +# ============================================================================== +# Step 3: Fetch Configuration +# ============================================================================== + +function Fetch-Configuration { + Write-LogStep "Step 3: Fetching Azure Configuration" + + $configSource = "" + + # Priority 1: Resource group provided via parameter + if ($ResourceGroup) { + $configSource = "rg" + } + # Priority 2: azd env provided + elseif ($AzdEnvName) { + $configSource = "azd" + } + # Priority 3: Existing .env with valid values - use silently + elseif (Test-Path (Join-Path $BackendDir ".env")) { + $envContent = Get-Content (Join-Path $BackendDir ".env") -Raw -ErrorAction SilentlyContinue + if ($envContent -match "COSMOSDB_ENDPOINT=https://") { + Write-LogInfo "Existing .env file found with valid configuration. Using it." + $configSource = "existing" + } + } + + # If still not determined, ask for RG name + if (-not $configSource) { + Write-Host "" + Write-LogInfo "No resource group provided and no existing .env found." + Write-LogInfo "Please provide your Azure Resource Group name (from your deployment)." + $script:ResourceGroup = Read-Host "Resource Group name" + if (-not $script:ResourceGroup) { + Write-LogError "Resource group name is required." + Write-LogInfo "Usage: .\setup_local_dev.ps1 -ResourceGroup " + exit 1 + } + $configSource = "rg" + } + + switch ($configSource) { + "azd" { Fetch-FromAzd } + "rg" { Fetch-FromResourceGroup } + "existing" { + if (Test-Path (Join-Path $BackendDir ".env")) { + Write-LogSuccess "Using existing .env file" + } else { + Copy-Item (Join-Path $BackendDir ".env.sample") (Join-Path $BackendDir ".env") + Write-LogWarn "Created .env from template. Please fill in values and re-run." + exit 0 + } + } + } +} + +function Fetch-FromAzd { + Write-LogInfo "Fetching from azd environment: $AzdEnvName" + + if (-not (Test-CommandExists "azd")) { + Write-LogError "azd CLI not found. Install from https://aka.ms/azd" + exit 1 + } + + $azdValues = azd env get-values --environment $AzdEnvName 2>$null + if (-not $azdValues) { + Write-LogError "Failed to get values from azd environment '$AzdEnvName'" + exit 1 + } + + Generate-EnvFile ($azdValues -join "`n") +} + +function Fetch-FromResourceGroup { + Write-LogInfo "Fetching from Resource Group: $ResourceGroup" + + # Validate RG + $rgExists = az group show --name $ResourceGroup 2>$null + if (-not $rgExists) { + Write-LogError "Resource group '$ResourceGroup' not found" + exit 1 + } + + # Find backend container app + $containerApps = az containerapp list --resource-group $ResourceGroup --query "[].name" -o tsv 2>$null + $backendApp = "" + + if ($containerApps) { + foreach ($app in ($containerApps -split "`n")) { + $app = $app.Trim() + if ($app -like "ca-mcp-*") { continue } + $hasCosmos = az containerapp show --name $app --resource-group $ResourceGroup ` + --query "properties.template.containers[0].env[?name=='COSMOSDB_ENDPOINT'].value" -o tsv 2>$null + if ($hasCosmos) { + $backendApp = $app + break + } + } + } + + if ($backendApp) { + Write-LogSuccess "Found backend container app: $backendApp" + $envJson = az containerapp show --name $backendApp --resource-group $ResourceGroup ` + --query "properties.template.containers[0].env" -o json 2>$null + + $envVars = $envJson | ConvertFrom-Json + $lines = @() + foreach ($e in $envVars) { + if ($e.name -and $e.value) { + $lines += "$($e.name)=$($e.value)" + } + } + Generate-EnvFile ($lines -join "`n") + } else { + Write-LogWarn "No backend container app found. Discovering resources..." + Fetch-FromResources + } +} + +function Fetch-FromResources { + $subId = az account show --query id -o tsv + $tenantId = az account show --query tenantId -o tsv + + $cosmosName = az cosmosdb list --resource-group $ResourceGroup --query "[0].name" -o tsv 2>$null + $aiServicesName = az cognitiveservices account list --resource-group $ResourceGroup ` + --query "[?kind=='AIServices' || kind=='CognitiveServices'].name | [0]" -o tsv 2>$null + $aiProjectName = az cognitiveservices account list --resource-group $ResourceGroup ` + --query "[?kind=='AIProject'].name | [0]" -o tsv 2>$null + $searchName = az search service list --resource-group $ResourceGroup --query "[0].name" -o tsv 2>$null + $appInsightsKey = az monitor app-insights component list --resource-group $ResourceGroup ` + --query "[0].instrumentationKey" -o tsv 2>$null + $appInsightsConn = az monitor app-insights component list --resource-group $ResourceGroup ` + --query "[0].connectionString" -o tsv 2>$null + $storageName = az storage account list --resource-group $ResourceGroup --query "[0].name" -o tsv 2>$null + + $cosmosEndpoint = if ($cosmosName) { "https://$cosmosName.documents.azure.com:443/" } else { "" } + $aiEndpoint = if ($aiServicesName) { "https://$aiServicesName.openai.azure.com/" } else { "" } + $searchEndpoint = if ($searchName) { "https://$searchName.search.windows.net" } else { "" } + $storageUrl = if ($storageName) { "https://$storageName.blob.core.windows.net/" } else { "" } + $projectEndpoint = if ($aiServicesName -and $aiProjectName) { "https://$aiServicesName.services.ai.azure.com/api/projects/$aiProjectName" } else { "" } + + $envLines = @" +COSMOSDB_ENDPOINT=$cosmosEndpoint +COSMOSDB_DATABASE=macae +COSMOSDB_CONTAINER=memory +AZURE_OPENAI_ENDPOINT=$aiEndpoint +AZURE_OPENAI_MODEL_NAME=gpt-4.1-mini +AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4.1-mini +AZURE_OPENAI_RAI_DEPLOYMENT_NAME=gpt-4.1 +AZURE_OPENAI_API_VERSION=2024-12-01-preview +APPLICATIONINSIGHTS_INSTRUMENTATION_KEY=$appInsightsKey +APPLICATIONINSIGHTS_CONNECTION_STRING=$appInsightsConn +AZURE_AI_SUBSCRIPTION_ID=$subId +AZURE_AI_RESOURCE_GROUP=$ResourceGroup +AZURE_AI_PROJECT_NAME=$aiProjectName +AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME=gpt-4.1-mini +AZURE_AI_SEARCH_CONNECTION_NAME=macae-search-connection +AZURE_AI_SEARCH_ENDPOINT=$searchEndpoint +AZURE_TENANT_ID=$tenantId +AZURE_STORAGE_BLOB_URL=$storageUrl +AZURE_AI_PROJECT_ENDPOINT=$projectEndpoint +AZURE_AI_AGENT_ENDPOINT=$projectEndpoint +REASONING_MODEL_NAME=o4-mini +"@ + + Generate-EnvFile $envLines +} + +function Generate-EnvFile { + param([string]$RawValues) + + $envFile = Join-Path $BackendDir ".env" + Write-LogInfo "Generating .env file at: $envFile" + + # Parse into hashtable + $envVars = @{} + foreach ($line in ($RawValues -split "`n")) { + $line = $line.Trim() + if (-not $line -or $line.StartsWith("#")) { continue } + $eqIdx = $line.IndexOf("=") + if ($eqIdx -gt 0) { + $key = $line.Substring(0, $eqIdx) + $value = $line.Substring($eqIdx + 1).Trim('"').Trim("'") + $envVars[$key] = $value + } + } + + # Local overrides + $envVars["APP_ENV"] = "dev" + $envVars["BACKEND_API_URL"] = "http://localhost:8000" + $envVars["FRONTEND_SITE_NAME"] = "*" + $envVars["MCP_SERVER_ENDPOINT"] = "http://localhost:9000/mcp" + $envVars["MCP_SERVER_NAME"] = "MacaeMcpServer" + $envVars["MCP_SERVER_DESCRIPTION"] = "MCP server with greeting, HR, and planning tools" + + # Write file + $content = @" +# =================================================================== +# MACAE Local Development Configuration +# Generated by setup_local_dev.ps1 on $(Get-Date -Format "yyyy-MM-dd HH:mm:ss") +# =================================================================== + +# --- Local Development Settings (DO NOT CHANGE) --- +APP_ENV=dev +BACKEND_API_URL=http://localhost:8000 +FRONTEND_SITE_NAME=* +MCP_SERVER_ENDPOINT=http://localhost:9000/mcp +MCP_SERVER_NAME=MacaeMcpServer +MCP_SERVER_DESCRIPTION="MCP server with greeting, HR, and planning tools" + +# --- Azure Authentication --- +AZURE_TENANT_ID=$($envVars["AZURE_TENANT_ID"]) +AZURE_CLIENT_ID=$($envVars["AZURE_CLIENT_ID"]) + +# --- CosmosDB --- +COSMOSDB_ENDPOINT=$($envVars["COSMOSDB_ENDPOINT"]) +COSMOSDB_DATABASE=$($envVars["COSMOSDB_DATABASE"] ?? "macae") +COSMOSDB_CONTAINER=$($envVars["COSMOSDB_CONTAINER"] ?? "memory") + +# --- Azure OpenAI --- +AZURE_OPENAI_ENDPOINT=$($envVars["AZURE_OPENAI_ENDPOINT"]) +AZURE_OPENAI_MODEL_NAME=$($envVars["AZURE_OPENAI_MODEL_NAME"] ?? "gpt-4.1-mini") +AZURE_OPENAI_DEPLOYMENT_NAME=$($envVars["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? "gpt-4.1-mini") +AZURE_OPENAI_RAI_DEPLOYMENT_NAME=$($envVars["AZURE_OPENAI_RAI_DEPLOYMENT_NAME"] ?? "gpt-4.1") +AZURE_OPENAI_API_VERSION=$($envVars["AZURE_OPENAI_API_VERSION"] ?? "2024-12-01-preview") +REASONING_MODEL_NAME=$($envVars["REASONING_MODEL_NAME"] ?? "o4-mini") +SUPPORTED_MODELS=$($envVars["SUPPORTED_MODELS"] ?? '["o3","o4-mini","gpt-4.1","gpt-4.1-mini"]') + +# --- Azure AI Foundry --- +AZURE_AI_SUBSCRIPTION_ID=$($envVars["AZURE_AI_SUBSCRIPTION_ID"]) +AZURE_AI_RESOURCE_GROUP=$($envVars["AZURE_AI_RESOURCE_GROUP"]) +AZURE_AI_PROJECT_NAME=$($envVars["AZURE_AI_PROJECT_NAME"]) +AZURE_AI_PROJECT_ENDPOINT=$($envVars["AZURE_AI_PROJECT_ENDPOINT"]) +AZURE_AI_AGENT_ENDPOINT=$($envVars["AZURE_AI_AGENT_ENDPOINT"]) +AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME=$($envVars["AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME"] ?? "gpt-4.1-mini") +AZURE_AI_AGENT_API_VERSION=$($envVars["AZURE_AI_AGENT_API_VERSION"] ?? "2025-05-01-preview") +AZURE_AI_AGENT_PROJECT_CONNECTION_STRING=$($envVars["AZURE_AI_AGENT_PROJECT_CONNECTION_STRING"]) +AZURE_COGNITIVE_SERVICES=$($envVars["AZURE_COGNITIVE_SERVICES"] ?? "https://cognitiveservices.azure.com/.default") + +# --- Azure AI Search --- +AZURE_AI_SEARCH_CONNECTION_NAME=$($envVars["AZURE_AI_SEARCH_CONNECTION_NAME"]) +AZURE_AI_SEARCH_ENDPOINT=$($envVars["AZURE_AI_SEARCH_ENDPOINT"]) + +# --- Application Insights --- +APPLICATIONINSIGHTS_INSTRUMENTATION_KEY=$($envVars["APPLICATIONINSIGHTS_INSTRUMENTATION_KEY"]) +APPLICATIONINSIGHTS_CONNECTION_STRING=$($envVars["APPLICATIONINSIGHTS_CONNECTION_STRING"]) + +# --- Storage --- +AZURE_STORAGE_BLOB_URL=$($envVars["AZURE_STORAGE_BLOB_URL"]) + +# --- Bing --- +AZURE_BING_CONNECTION_NAME=$($envVars["AZURE_BING_CONNECTION_NAME"] ?? "binggrnd") +BING_CONNECTION_NAME=$($envVars["BING_CONNECTION_NAME"] ?? "binggrnd") + +# --- Logging --- +AZURE_BASIC_LOGGING_LEVEL=$($envVars["AZURE_BASIC_LOGGING_LEVEL"] ?? "INFO") +AZURE_PACKAGE_LOGGING_LEVEL=$($envVars["AZURE_PACKAGE_LOGGING_LEVEL"] ?? "WARNING") +AZURE_LOGGING_PACKAGES=$($envVars["AZURE_LOGGING_PACKAGES"]) +"@ + + $content | Out-File -FilePath $envFile -Encoding utf8NoBOM + Write-LogSuccess ".env file generated successfully" + + # Validate required keys + $requiredKeys = @("COSMOSDB_ENDPOINT", "AZURE_OPENAI_ENDPOINT", "AZURE_AI_SUBSCRIPTION_ID", "AZURE_AI_RESOURCE_GROUP", "AZURE_AI_PROJECT_NAME", "AZURE_AI_AGENT_ENDPOINT") + $missingKeys = @() + foreach ($key in $requiredKeys) { + if (-not $envVars[$key]) { $missingKeys += $key } + } + if ($missingKeys.Count -gt 0) { + Write-LogWarn "The following required values are empty (edit .env manually):" + foreach ($k in $missingKeys) { Write-LogWarn " - $k" } + } +} + +# ============================================================================== +# Step 4: RBAC (Optional) +# ============================================================================== + +function Assign-RbacRoles { + # Always assign RBAC when resource group is known (needed for local dev access) + if (-not $ResourceGroup) { + Write-LogInfo "No resource group specified, skipping RBAC assignment." + return + } + + Write-LogStep "Step 4: Assigning RBAC Roles" + + $userObjectId = az ad signed-in-user show --query id -o tsv 2>$null + $userUpn = az ad signed-in-user show --query userPrincipalName -o tsv 2>$null + + if (-not $userObjectId) { + Write-LogError "Could not get user info. Skipping RBAC." + return + } + + Write-LogInfo "Assigning roles for: $userUpn ($userObjectId)" + $subId = az account show --query id -o tsv + + # Cosmos DB (uses its own role system, not ARM RBAC) + $cosmosName = az cosmosdb list --resource-group $ResourceGroup --query "[0].name" -o tsv 2>$null + if ($cosmosName) { + # Check if Cosmos role already assigned + $existingCosmos = az cosmosdb sql role assignment list ` + --resource-group $ResourceGroup --account-name $cosmosName ` + --query "[?principalId=='$userObjectId']" -o tsv 2>$null + if ($existingCosmos) { + Write-LogSuccess " Cosmos DB Data Contributor: already assigned ✓" + } else { + Write-LogInfo " Assigning Cosmos DB Data Contributor..." + az cosmosdb sql role assignment create ` + --resource-group $ResourceGroup --account-name $cosmosName ` + --role-definition-name "Cosmos DB Built-in Data Contributor" ` + --principal-id $userObjectId ` + --scope "/subscriptions/$subId/resourceGroups/$ResourceGroup/providers/Microsoft.DocumentDB/databaseAccounts/$cosmosName" 2>$null + if ($LASTEXITCODE -eq 0) { Write-LogSuccess " Cosmos DB role assigned" } + else { Write-LogWarn " Cosmos DB role assignment failed (may need elevated permissions)" } + } + } + + # AI Foundry roles + $aiServicesName = az cognitiveservices account list --resource-group $ResourceGroup ` + --query "[?kind=='AIServices'].name | [0]" -o tsv 2>$null + $aiProjectName = az cognitiveservices account list --resource-group $ResourceGroup ` + --query "[?kind=='AIProject'].name | [0]" -o tsv 2>$null + + if ($aiServicesName -and $aiProjectName) { + $scope = "/subscriptions/$subId/resourceGroups/$ResourceGroup/providers/Microsoft.CognitiveServices/accounts/$aiServicesName/projects/$aiProjectName" + foreach ($role in @("Azure AI User", "Azure AI Developer", "Cognitive Services OpenAI User")) { + $existing = az role assignment list --assignee $userObjectId --role $role --scope $scope --query "[0].id" -o tsv 2>$null + if ($existing) { + Write-LogSuccess " ${role}: already assigned ✓" + } else { + Write-LogInfo " Assigning '$role'..." + az role assignment create --assignee $userUpn --role $role --scope $scope 2>$null + if ($LASTEXITCODE -eq 0) { Write-LogSuccess " $role assigned" } + else { Write-LogWarn " $role assignment failed" } + } + } + } + + # Search + $searchName = az search service list --resource-group $ResourceGroup --query "[0].name" -o tsv 2>$null + if ($searchName) { + $scope = "/subscriptions/$subId/resourceGroups/$ResourceGroup/providers/Microsoft.Search/searchServices/$searchName" + $existing = az role assignment list --assignee $userObjectId --role "Search Index Data Contributor" --scope $scope --query "[0].id" -o tsv 2>$null + if ($existing) { + Write-LogSuccess " Search Index Data Contributor: already assigned ✓" + } else { + Write-LogInfo " Assigning Search Index Data Contributor..." + az role assignment create --assignee $userUpn --role "Search Index Data Contributor" --scope $scope 2>$null + if ($LASTEXITCODE -eq 0) { Write-LogSuccess " Search role assigned" } + else { Write-LogWarn " Search role assignment failed" } + } + } + + # Storage + $storageName = az storage account list --resource-group $ResourceGroup --query "[0].name" -o tsv 2>$null + if ($storageName) { + $scope = "/subscriptions/$subId/resourceGroups/$ResourceGroup/providers/Microsoft.Storage/storageAccounts/$storageName" + $existing = az role assignment list --assignee $userObjectId --role "Storage Blob Data Contributor" --scope $scope --query "[0].id" -o tsv 2>$null + if ($existing) { + Write-LogSuccess " Storage Blob Data Contributor: already assigned ✓" + } else { + Write-LogInfo " Assigning Storage Blob Data Contributor..." + az role assignment create --assignee $userUpn --role "Storage Blob Data Contributor" --scope $scope 2>$null + if ($LASTEXITCODE -eq 0) { Write-LogSuccess " Storage role assigned" } + else { Write-LogWarn " Storage role assignment failed" } + } + } + + Write-LogWarn "RBAC changes may take 5-10 minutes to propagate" +} + +# ============================================================================== +# Step 5-7: Service Setup +# ============================================================================== + +function Setup-Backend { + Write-LogStep "Step 5: Setting up Backend (src\backend)" + + Push-Location $BackendDir + + # Handle existing .venv that may be locked by VS Code or other processes + if (Test-Path ".venv") { + Write-LogInfo "Existing .venv found. Checking accessibility..." + $venvLocked = $false + try { + $testFile = ".venv\.uv-lock-test" + New-Item -Path $testFile -ItemType File -Force -ErrorAction Stop | Out-Null + Remove-Item $testFile -Force -ErrorAction SilentlyContinue + } catch { + $venvLocked = $true + } + + if ($venvLocked) { + Write-LogWarn ".venv is locked by another process (likely VS Code Python extension)." + Write-LogInfo "Attempting to auto-fix by killing locking Python processes..." + + # Find python processes running from this .venv + $venvFullPath = (Resolve-Path ".venv").Path + $lockingProcs = Get-Process -Name "python*" -ErrorAction SilentlyContinue | Where-Object { + try { + $_.Path -and $_.Path.StartsWith($venvFullPath, [System.StringComparison]::OrdinalIgnoreCase) + } catch { $false } + } + + if ($lockingProcs) { + foreach ($proc in $lockingProcs) { + Write-LogInfo " Killing PID $($proc.Id) ($($proc.Path))" + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + } + Start-Sleep -Seconds 2 + } + + # Retry deletion after killing processes + try { + Remove-Item -Recurse -Force ".venv" -ErrorAction Stop + Write-LogInfo "Removed locked .venv successfully." + } catch { + Write-LogWarn "Still cannot remove .venv after killing processes." + Write-LogWarn "Please close VS Code completely and re-run the script." + Pop-Location + throw "Cannot modify .venv - files still locked. Close VS Code and retry." + } + } + } + + if (-not (Test-Path ".venv")) { + Write-LogInfo "Creating virtual environment..." + uv venv .venv + } + + Write-LogInfo "Installing dependencies..." + # Use --no-cache to avoid stale lock issues on Windows + uv sync --python 3.12 --extra dev + + Write-LogSuccess "Backend setup complete" + Pop-Location +} + +function Setup-McpServer { + Write-LogStep "Step 6: Setting up MCP Server (src\mcp_server)" + + Push-Location $McpDir + + if (-not (Test-Path ".venv")) { + Write-LogInfo "Creating virtual environment..." + uv venv .venv + } + + Write-LogInfo "Installing dependencies..." + uv sync --python 3.12 + + Write-LogSuccess "MCP Server setup complete" + Pop-Location +} + +function Setup-Frontend { + Write-LogStep "Step 7: Setting up Frontend (src\App)" + + Push-Location $FrontendDir + + if (-not (Test-Path ".venv")) { + Write-LogInfo "Creating Python virtual environment..." + python -m venv .venv + } + + Write-LogInfo "Installing Python dependencies..." + & ".\.venv\Scripts\pip.exe" install -q -r requirements.txt + + Write-LogInfo "Installing npm dependencies..." + npm install + + Write-LogInfo "Building frontend..." + npm run build + + Write-LogSuccess "Frontend setup complete" + Pop-Location +} + +# ============================================================================== +# Step 8: VS Code +# ============================================================================== + +function Setup-VSCode { + if ($SkipVscode) { return } + + Write-LogStep "Step 8: Configuring VS Code" + + $vscodeDir = Join-Path $ScriptDir ".vscode" + if (-not (Test-Path $vscodeDir)) { New-Item -ItemType Directory -Path $vscodeDir | Out-Null } + + $extensionsFile = Join-Path $vscodeDir "extensions.json" + if (-not (Test-Path $extensionsFile)) { + @' +{ + "recommendations": [ + "ms-python.python", + "ms-python.pylint", + "ms-python.black-formatter", + "ms-python.isort", + "ms-vscode-remote.remote-wsl", + "ms-vscode-remote.remote-containers", + "redhat.vscode-yaml", + "ms-vscode.azure-account", + "ms-python.mypy-type-checker" + ] +} +'@ | Out-File -FilePath $extensionsFile -Encoding utf8NoBOM + Write-LogSuccess "Created .vscode\extensions.json" + } + + $settingsFile = Join-Path $vscodeDir "settings.json" + if (-not (Test-Path $settingsFile)) { + @' +{ + "python.defaultInterpreterPath": "${workspaceFolder}/src/backend/.venv/Scripts/python.exe", + "python.terminal.activateEnvironment": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "python.debugging.logLevel": "Debug", + "debug.inlineValues": "on", + "files.exclude": { + "**/__pycache__": true, + "**/*.pyc": true + }, + "python.analysis.extraPaths": [ + "${workspaceFolder}/src/backend", + "${workspaceFolder}/src/mcp_server" + ] +} +'@ | Out-File -FilePath $settingsFile -Encoding utf8NoBOM + Write-LogSuccess "Created .vscode\settings.json" + } +} + +# ============================================================================== +# Summary +# ============================================================================== + +function Print-Summary { + Write-LogStep "Setup Complete! 🎉" + + Write-Host "All services have been set up successfully." -ForegroundColor Green + Write-Host "" + Write-Host "To start the application, open 3 separate PowerShell windows:" -ForegroundColor Cyan + Write-Host "" + Write-Host " Terminal 1 - Backend (port 8000):" -ForegroundColor Yellow + Write-Host " cd src\backend" + Write-Host " .\.venv\Scripts\Activate.ps1" + Write-Host " python app.py" + Write-Host "" + Write-Host " Terminal 2 - MCP Server (port 9000):" -ForegroundColor Yellow + Write-Host " cd src\mcp_server" + Write-Host " .\.venv\Scripts\Activate.ps1" + Write-Host " python mcp_server.py --transport streamable-http --host 0.0.0.0 --port 9000" + Write-Host "" + Write-Host " Terminal 3 - Frontend (port 3000):" -ForegroundColor Yellow + Write-Host " cd src\App" + Write-Host " .\.venv\Scripts\Activate.ps1" + Write-Host " python frontend_server.py" + Write-Host "" + Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan + Write-Host " Application URL: http://localhost:3000" -ForegroundColor Green + Write-Host " Backend API: http://localhost:8000" -ForegroundColor Green + Write-Host " API Docs: http://localhost:8000/docs" -ForegroundColor Green + Write-Host " MCP Server: http://localhost:9000" -ForegroundColor Green + Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan +} + +# ============================================================================== +# Main +# ============================================================================== + +Write-Host "" +Write-Host "╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan +Write-Host "║ MACAE - Local Development Setup (Windows) ║" -ForegroundColor Cyan +Write-Host "║ Multi-Agent Custom Automation Engine ║" -ForegroundColor Cyan +Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan +Write-Host "" + +# Verify repo root +if (-not (Test-Path (Join-Path $ScriptDir "src\backend\app.py"))) { + Write-LogError "This script must be run from the repository root directory" + exit 1 +} + +# Ensure execution policy allows running scripts +$policy = Get-ExecutionPolicy -Scope CurrentUser +if ($policy -eq "Restricted") { + Write-LogWarn "Execution policy is Restricted. Setting to RemoteSigned..." + Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser -Force +} + +Check-Prerequisites +Check-AzureAuth +Fetch-Configuration +Assign-RbacRoles +Setup-Backend +Setup-McpServer +Setup-Frontend +Setup-VSCode +Print-Summary diff --git a/setup_local_dev.sh b/setup_local_dev.sh new file mode 100644 index 000000000..3b683e742 --- /dev/null +++ b/setup_local_dev.sh @@ -0,0 +1,1166 @@ +#!/usr/bin/env bash +# ============================================================================== +# MACAE - Local Development Setup Script +# ============================================================================== +# Automates the entire local development setup for the Multi-Agent Custom +# Automation Engine Solution Accelerator. +# +# Supports fetching Azure config from: +# 1. Existing .env file (already configured) +# 2. Azure Resource Group (discovers resources via az CLI) +# 3. azd deployment outputs (if deployed via azd up) +# +# Usage: +# ./setup_local_dev.sh [OPTIONS] +# +# Options: +# -g, --resource-group Azure Resource Group name +# -s, --subscription Azure Subscription ID +# -e, --env-name azd environment name +# --assign-rbac Assign RBAC roles to current user (requires permissions) +# --skip-prereqs Skip prerequisite installation +# --skip-vscode Skip VS Code configuration +# -h, --help Show this help message +# +# Examples: +# ./setup_local_dev.sh -g my-resource-group +# ./setup_local_dev.sh -e my-azd-env +# ./setup_local_dev.sh --resource-group rg-macae-dev --assign-rbac +# ============================================================================== + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Script directory (repo root) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BACKEND_DIR="$SCRIPT_DIR/src/backend" +MCP_DIR="$SCRIPT_DIR/src/mcp_server" +FRONTEND_DIR="$SCRIPT_DIR/src/App" + +# Default options +RESOURCE_GROUP="" +SUBSCRIPTION="" +AZD_ENV_NAME="" +ASSIGN_RBAC=false +SKIP_PREREQS=false +SKIP_VSCODE=false + +# ============================================================================== +# Helper Functions +# ============================================================================== + +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_success() { echo -e "${GREEN}[✓]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_step() { echo -e "\n${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"; echo -e "${CYAN} $1${NC}"; echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"; } + +confirm() { + local prompt="$1" + local default="${2:-y}" + if [[ "$default" == "y" ]]; then + read -rp "$prompt [Y/n]: " response + response="${response:-y}" + else + read -rp "$prompt [y/N]: " response + response="${response:-n}" + fi + [[ "$response" =~ ^[Yy]$ ]] +} + +command_exists() { command -v "$1" &>/dev/null; } + +detect_os() { + if [[ "$OSTYPE" == "linux-gnu"* ]]; then + if grep -qi "ubuntu\|debian" /etc/os-release 2>/dev/null; then + echo "debian" + elif grep -qi "fedora\|rhel\|centos" /etc/os-release 2>/dev/null; then + echo "rhel" + else + echo "linux" + fi + elif [[ "$OSTYPE" == "darwin"* ]]; then + echo "macos" + elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + echo "windows" + else + echo "unknown" + fi +} + +# ============================================================================== +# Parse Arguments +# ============================================================================== + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + -g|--resource-group) + RESOURCE_GROUP="$2"; shift 2 ;; + -s|--subscription) + SUBSCRIPTION="$2"; shift 2 ;; + -e|--env-name) + AZD_ENV_NAME="$2"; shift 2 ;; + --assign-rbac) + ASSIGN_RBAC=true; shift ;; + --skip-vscode) + SKIP_VSCODE=true; shift ;; + -h|--help) + show_help; exit 0 ;; + *) + log_error "Unknown option: $1"; show_help; exit 1 ;; + esac + done +} + +show_help() { + head -35 "${BASH_SOURCE[0]}" | tail -30 +} + +# ============================================================================== +# Step 1: Prerequisites Check & Installation +# ============================================================================== + +check_prerequisites() { + log_step "Step 1: Checking Prerequisites" + + local missing=() + local OS + OS=$(detect_os) + + # Check Python 3.12+ + if command_exists python3.12; then + log_success "Python 3.12 found: $(python3.12 --version)" + elif command_exists python3 && python3 -c "import sys; exit(0 if sys.version_info >= (3,12) else 1)" 2>/dev/null; then + log_success "Python 3 found (3.12+): $(python3 --version)" + else + missing+=("python3.12") + fi + + # Check Node.js + if command_exists node; then + log_success "Node.js found: $(node --version)" + else + missing+=("nodejs") + fi + + # Check npm + if command_exists npm; then + log_success "npm found: $(npm --version)" + else + missing+=("npm") + fi + + # Check uv + if command_exists uv; then + log_success "uv found: $(uv --version)" + else + missing+=("uv") + fi + + # Check Azure CLI + if command_exists az; then + log_success "Azure CLI found: $(az --version 2>/dev/null | head -1)" + else + missing+=("azure-cli") + fi + + # Check git + if command_exists git; then + log_success "Git found: $(git --version)" + else + missing+=("git") + fi + + if [[ ${#missing[@]} -eq 0 ]]; then + log_success "All prerequisites are installed!" + return 0 + fi + + log_error "Missing prerequisites: ${missing[*]}" + echo "" + log_warn "Please install the following before proceeding:" + echo "" + for tool in "${missing[@]}"; do + case "$tool" in + python3.12) + echo " ┌─ Python 3.12 ─────────────────────────────────────────────────" + echo " │ Download: https://www.python.org/downloads/" + echo " │ Quick install:" + echo " │ macOS: brew install python@3.12" + echo " │ Ubuntu: sudo apt update && sudo apt install python3.12 python3.12-venv -y" + echo " │ RHEL: sudo dnf install python3.12 python3.12-devel -y" + echo " │ Windows: winget install Python.Python.3.12" + echo " │ Verify: python3.12 --version (should show 3.12.x)" + echo " └──────────────────────────────────────────────────────────────" + ;; + nodejs|npm) + echo " ┌─ Node.js & npm ───────────────────────────────────────────────" + echo " │ Download: https://nodejs.org/ (LTS version)" + echo " │ Quick install:" + echo " │ macOS: brew install node" + echo " │ Ubuntu: sudo apt install nodejs npm -y" + echo " │ RHEL: sudo dnf install nodejs npm -y" + echo " │ Windows: winget install OpenJS.NodeJS.LTS" + echo " │ Verify: node --version && npm --version" + echo " └──────────────────────────────────────────────────────────────" + ;; + uv) + echo " ┌─ uv (Python package manager) ─────────────────────────────────" + echo " │ Quick install:" + echo " │ All OS: curl -LsSf https://astral.sh/uv/install.sh | sh" + echo " │ Or: pip install uv" + echo " │ Windows: irm https://astral.sh/uv/install.ps1 | iex" + echo " │ Docs: https://docs.astral.sh/uv/getting-started/installation/" + echo " │ Verify: uv --version" + echo " │ Note: After install, run: source ~/.bashrc (or restart terminal)" + echo " └──────────────────────────────────────────────────────────────" + ;; + azure-cli) + echo " ┌─ Azure CLI ───────────────────────────────────────────────────" + echo " │ Quick install:" + echo " │ macOS: brew install azure-cli" + echo " │ Ubuntu: curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash" + echo " │ RHEL: sudo rpm --import https://packages.microsoft.com/keys/microsoft.asc" + echo " │ sudo dnf install azure-cli -y" + echo " │ Windows: winget install Microsoft.AzureCLI" + echo " │ Docs: https://learn.microsoft.com/cli/azure/install-azure-cli" + echo " │ Verify: az --version" + echo " │ After install: az login" + echo " └──────────────────────────────────────────────────────────────" + ;; + git) + echo " ┌─ Git ─────────────────────────────────────────────────────────" + echo " │ Download: https://git-scm.com/downloads" + echo " │ Quick install:" + echo " │ macOS: brew install git" + echo " │ Ubuntu: sudo apt install git -y" + echo " │ RHEL: sudo dnf install git -y" + echo " │ Windows: winget install Git.Git" + echo " │ Verify: git --version" + echo " └──────────────────────────────────────────────────────────────" + ;; + esac + done + echo "" + echo " For detailed step-by-step instructions, see:" + echo " https://github.com/microsoft/Multi-Agent-Custom-Automation-Engine-Solution-Accelerator/blob/main/docs/LocalDevelopmentSetup.md#step-1-prerequisites---install-required-tools" + echo "" + echo " Also see: docs/NON_DEVCONTAINER_SETUP.md for VS Code extension recommendations." + echo "" + log_info "After installing, restart your terminal and re-run this script." + exit 1 +} + +# ============================================================================== +# Step 2: Azure Authentication +# ============================================================================== + +check_azure_auth() { + log_step "Step 2: Azure Authentication" + + if ! az account show &>/dev/null; then + log_warn "Not logged into Azure CLI" + log_info "Running 'az login'..." + az login + fi + + # Set subscription if provided + if [[ -n "$SUBSCRIPTION" ]]; then + log_info "Setting subscription to: $SUBSCRIPTION" + az account set --subscription "$SUBSCRIPTION" + fi + + local account_info + account_info=$(az account show --output json) + local sub_name + sub_name=$(echo "$account_info" | python3 -c "import sys,json; print(json.load(sys.stdin)['name'])" 2>/dev/null || echo "unknown") + local sub_id + sub_id=$(echo "$account_info" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null || echo "unknown") + + log_success "Logged in to Azure" + log_info " Subscription: $sub_name ($sub_id)" + + if [[ -z "$SUBSCRIPTION" ]]; then + SUBSCRIPTION="$sub_id" + fi + + if ! confirm "Is this the correct subscription?" "y"; then + log_info "Available subscriptions:" + az account list --output table --query "[].{Name:name, Id:id, State:state}" + read -rp "Enter subscription ID: " SUBSCRIPTION + az account set --subscription "$SUBSCRIPTION" + log_success "Switched to subscription: $SUBSCRIPTION" + fi +} + +# ============================================================================== +# Step 3: Fetch Configuration +# ============================================================================== + +fetch_configuration() { + log_step "Step 3: Fetching Azure Configuration" + + local config_source="" + + # Priority 1: Resource group provided via CLI arg + if [[ -n "$RESOURCE_GROUP" ]]; then + config_source="rg" + # Priority 2: azd env provided via CLI arg + elif [[ -n "$AZD_ENV_NAME" ]]; then + config_source="azd" + # Priority 3: Existing .env with valid values - use silently + elif [[ -f "$BACKEND_DIR/.env" ]] && grep -q "COSMOSDB_ENDPOINT=https://" "$BACKEND_DIR/.env" 2>/dev/null; then + log_info "Existing .env file found with valid configuration. Using it." + config_source="existing" + fi + + # If still not determined, ask for RG name + if [[ -z "$config_source" ]]; then + echo "" + log_info "No resource group provided and no existing .env found." + log_info "Please provide your Azure Resource Group name (from your deployment)." + read -rp "Resource Group name: " RESOURCE_GROUP + if [[ -z "$RESOURCE_GROUP" ]]; then + log_error "Resource group name is required." + log_info "Usage: ./setup_local_dev.sh -g " + exit 1 + fi + config_source="rg" + fi + + case "$config_source" in + azd) fetch_from_azd ;; + rg) fetch_from_resource_group ;; + existing) + if [[ -f "$BACKEND_DIR/.env" ]]; then + log_success "Using existing .env file at $BACKEND_DIR/.env" + else + log_warn "No .env file found. Creating from template..." + cp "$BACKEND_DIR/.env.sample" "$BACKEND_DIR/.env" + log_warn "Please manually fill in values in: $BACKEND_DIR/.env" + log_warn "Then re-run this script." + exit 0 + fi ;; + esac +} + +fetch_from_azd() { + log_info "Fetching configuration from azd environment: $AZD_ENV_NAME" + + if ! command_exists azd; then + log_error "azd CLI not found. Install from https://aka.ms/azd" + exit 1 + fi + + # Get all azd env values + local azd_values + azd_values=$(azd env get-values --environment "$AZD_ENV_NAME" 2>/dev/null) || { + log_error "Failed to get values from azd environment '$AZD_ENV_NAME'" + log_info "Make sure you've run 'azd up' or 'azd provision' first" + exit 1 + } + + generate_env_from_values "$azd_values" + log_success "Configuration fetched from azd environment" +} + +fetch_from_resource_group() { + log_info "Fetching configuration from Resource Group: $RESOURCE_GROUP" + + # Validate RG exists + if ! az group show --name "$RESOURCE_GROUP" &>/dev/null; then + log_error "Resource group '$RESOURCE_GROUP' not found" + exit 1 + fi + + # Strategy: Find the backend container app and extract its env vars + log_info "Looking for backend container app..." + + local container_apps + container_apps=$(az containerapp list --resource-group "$RESOURCE_GROUP" --query "[].name" -o tsv 2>/dev/null || true) + + local backend_app="" + if [[ -n "$container_apps" ]]; then + # Find the backend app (has COSMOSDB_ENDPOINT env var, not the MCP one) + while IFS= read -r app_name; do + if [[ "$app_name" == ca-mcp-* ]]; then + continue + fi + # Check if this app has the backend env vars + local has_cosmos + has_cosmos=$(az containerapp show --name "$app_name" --resource-group "$RESOURCE_GROUP" \ + --query "properties.template.containers[0].env[?name=='COSMOSDB_ENDPOINT'].value" -o tsv 2>/dev/null || true) + if [[ -n "$has_cosmos" ]]; then + backend_app="$app_name" + break + fi + done <<< "$container_apps" + fi + + if [[ -n "$backend_app" ]]; then + log_success "Found backend container app: $backend_app" + log_info "Extracting environment variables..." + fetch_env_from_container_app "$backend_app" + else + log_warn "No backend container app found. Discovering resources individually..." + fetch_env_from_resources + fi + + # Check for private networking + check_private_networking +} + +fetch_env_from_container_app() { + local app_name="$1" + + # Get all env vars from the container app + local env_json + env_json=$(az containerapp show --name "$app_name" --resource-group "$RESOURCE_GROUP" \ + --query "properties.template.containers[0].env" -o json 2>/dev/null) + + if [[ -z "$env_json" || "$env_json" == "null" ]]; then + log_error "Could not read environment variables from container app" + exit 1 + fi + + # Parse env vars into key=value format + local env_values + env_values=$(echo "$env_json" | python3 -c " +import sys, json +envs = json.load(sys.stdin) +for e in envs: + name = e.get('name', '') + value = e.get('value', '') + if name and value: + print(f'{name}={value}') +" 2>/dev/null) + + generate_env_from_values "$env_values" + log_success "Environment variables extracted from container app" +} + +fetch_env_from_resources() { + # Fallback: discover resources individually + log_info "Discovering Azure resources in resource group..." + + local sub_id + sub_id=$(az account show --query id -o tsv) + local tenant_id + tenant_id=$(az account show --query tenantId -o tsv) + + # CosmosDB + local cosmos_name cosmos_endpoint + cosmos_name=$(az cosmosdb list --resource-group "$RESOURCE_GROUP" --query "[0].name" -o tsv 2>/dev/null || true) + if [[ -n "$cosmos_name" ]]; then + cosmos_endpoint="https://${cosmos_name}.documents.azure.com:443/" + log_success " CosmosDB: $cosmos_name" + else + log_warn " CosmosDB: not found" + cosmos_endpoint="" + fi + + # AI Services (Cognitive Services) + local ai_services_name ai_endpoint + ai_services_name=$(az cognitiveservices account list --resource-group "$RESOURCE_GROUP" \ + --query "[?kind=='AIServices' || kind=='CognitiveServices'].name | [0]" -o tsv 2>/dev/null || true) + if [[ -n "$ai_services_name" ]]; then + ai_endpoint="https://${ai_services_name}.openai.azure.com/" + log_success " AI Services: $ai_services_name" + else + log_warn " AI Services: not found" + ai_endpoint="" + fi + + # AI Foundry Project + local ai_project_name ai_project_endpoint + ai_project_name=$(az cognitiveservices account list --resource-group "$RESOURCE_GROUP" \ + --query "[?kind=='AIProject'].name | [0]" -o tsv 2>/dev/null || true) + if [[ -n "$ai_project_name" && -n "$ai_services_name" ]]; then + ai_project_endpoint="https://${ai_services_name}.services.ai.azure.com/api/projects/${ai_project_name}" + log_success " AI Project: $ai_project_name" + else + # Try alternate endpoint format + ai_project_endpoint="" + log_warn " AI Project: not found (will need manual configuration)" + fi + + # Search Service + local search_name search_endpoint + search_name=$(az search service list --resource-group "$RESOURCE_GROUP" --query "[0].name" -o tsv 2>/dev/null || true) + if [[ -n "$search_name" ]]; then + search_endpoint="https://${search_name}.search.windows.net" + log_success " Search Service: $search_name" + else + log_warn " Search Service: not found" + search_endpoint="" + fi + + # Application Insights + local appinsights_key appinsights_connstr + appinsights_key=$(az monitor app-insights component list --resource-group "$RESOURCE_GROUP" \ + --query "[0].instrumentationKey" -o tsv 2>/dev/null || true) + appinsights_connstr=$(az monitor app-insights component list --resource-group "$RESOURCE_GROUP" \ + --query "[0].connectionString" -o tsv 2>/dev/null || true) + if [[ -n "$appinsights_key" ]]; then + log_success " Application Insights: found" + else + log_warn " Application Insights: not found (will use empty value)" + fi + + # Storage Account + local storage_name storage_blob_url + storage_name=$(az storage account list --resource-group "$RESOURCE_GROUP" --query "[0].name" -o tsv 2>/dev/null || true) + if [[ -n "$storage_name" ]]; then + storage_blob_url="https://${storage_name}.blob.core.windows.net/" + log_success " Storage Account: $storage_name" + else + log_warn " Storage Account: not found" + storage_blob_url="" + fi + + # Build env values + local env_values + env_values="COSMOSDB_ENDPOINT=${cosmos_endpoint} +COSMOSDB_DATABASE=macae +COSMOSDB_CONTAINER=memory +AZURE_OPENAI_ENDPOINT=${ai_endpoint} +AZURE_OPENAI_MODEL_NAME=gpt-4.1-mini +AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4.1-mini +AZURE_OPENAI_RAI_DEPLOYMENT_NAME=gpt-4.1 +AZURE_OPENAI_API_VERSION=2024-12-01-preview +APPLICATIONINSIGHTS_INSTRUMENTATION_KEY=${appinsights_key:-} +APPLICATIONINSIGHTS_CONNECTION_STRING=${appinsights_connstr:-} +AZURE_AI_SUBSCRIPTION_ID=${sub_id} +AZURE_AI_RESOURCE_GROUP=${RESOURCE_GROUP} +AZURE_AI_PROJECT_NAME=${ai_project_name:-} +AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME=gpt-4.1-mini +AZURE_AI_SEARCH_CONNECTION_NAME=macae-search-connection +AZURE_AI_SEARCH_ENDPOINT=${search_endpoint:-} +AZURE_COGNITIVE_SERVICES=https://cognitiveservices.azure.com/.default +AZURE_BING_CONNECTION_NAME=binggrnd +BING_CONNECTION_NAME=binggrnd +REASONING_MODEL_NAME=o4-mini +AZURE_TENANT_ID=${tenant_id} +AZURE_CLIENT_ID= +SUPPORTED_MODELS=[\"o3\",\"o4-mini\",\"gpt-4.1\",\"gpt-4.1-mini\"] +AZURE_STORAGE_BLOB_URL=${storage_blob_url:-} +AZURE_AI_PROJECT_ENDPOINT=${ai_project_endpoint:-} +AZURE_AI_AGENT_ENDPOINT=${ai_project_endpoint:-} +AZURE_AI_AGENT_API_VERSION=2025-05-01-preview +AZURE_AI_AGENT_PROJECT_CONNECTION_STRING=${ai_services_name:-}.services.ai.azure.com;${sub_id};${RESOURCE_GROUP};${ai_project_name:-}" + + generate_env_from_values "$env_values" +} + +generate_env_from_values() { + local raw_values="$1" + local env_file="$BACKEND_DIR/.env" + + log_info "Generating .env file at: $env_file" + + # Start with the fetched values, then override local dev settings + # Parse values into associative array + declare -A env_vars + + while IFS= read -r line; do + # Skip empty lines and comments + [[ -z "$line" || "$line" == \#* ]] && continue + # Remove surrounding quotes from values + local key="${line%%=*}" + local value="${line#*=}" + # Strip quotes + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + if [[ -n "$key" ]]; then + env_vars["$key"]="$value" + fi + done <<< "$raw_values" + + # Apply local development overrides + env_vars["APP_ENV"]="dev" + env_vars["BACKEND_API_URL"]="http://localhost:8000" + env_vars["FRONTEND_SITE_NAME"]="*" + env_vars["MCP_SERVER_ENDPOINT"]="http://localhost:9000/mcp" + env_vars["MCP_SERVER_NAME"]="MacaeMcpServer" + env_vars["MCP_SERVER_DESCRIPTION"]="MCP server with greeting, HR, and planning tools" + + # Write the .env file + { + echo "# ===================================================================" + echo "# MACAE Local Development Configuration" + echo "# Generated by setup_local_dev.sh on $(date '+%Y-%m-%d %H:%M:%S')" + echo "# ===================================================================" + echo "" + echo "# --- Local Development Settings (DO NOT CHANGE) ---" + echo "APP_ENV=dev" + echo "BACKEND_API_URL=http://localhost:8000" + echo "FRONTEND_SITE_NAME=*" + echo "MCP_SERVER_ENDPOINT=http://localhost:9000/mcp" + echo "MCP_SERVER_NAME=MacaeMcpServer" + echo 'MCP_SERVER_DESCRIPTION="MCP server with greeting, HR, and planning tools"' + echo "" + echo "# --- Azure Authentication ---" + echo "AZURE_TENANT_ID=${env_vars[AZURE_TENANT_ID]:-}" + echo "AZURE_CLIENT_ID=${env_vars[AZURE_CLIENT_ID]:-}" + echo "" + echo "# --- CosmosDB ---" + echo "COSMOSDB_ENDPOINT=${env_vars[COSMOSDB_ENDPOINT]:-}" + echo "COSMOSDB_DATABASE=${env_vars[COSMOSDB_DATABASE]:-macae}" + echo "COSMOSDB_CONTAINER=${env_vars[COSMOSDB_CONTAINER]:-memory}" + echo "" + echo "# --- Azure OpenAI ---" + echo "AZURE_OPENAI_ENDPOINT=${env_vars[AZURE_OPENAI_ENDPOINT]:-}" + echo "AZURE_OPENAI_MODEL_NAME=${env_vars[AZURE_OPENAI_MODEL_NAME]:-gpt-4.1-mini}" + echo "AZURE_OPENAI_DEPLOYMENT_NAME=${env_vars[AZURE_OPENAI_DEPLOYMENT_NAME]:-gpt-4.1-mini}" + echo "AZURE_OPENAI_RAI_DEPLOYMENT_NAME=${env_vars[AZURE_OPENAI_RAI_DEPLOYMENT_NAME]:-gpt-4.1}" + echo "AZURE_OPENAI_API_VERSION=${env_vars[AZURE_OPENAI_API_VERSION]:-2024-12-01-preview}" + echo "REASONING_MODEL_NAME=${env_vars[REASONING_MODEL_NAME]:-o4-mini}" + echo "SUPPORTED_MODELS=${env_vars[SUPPORTED_MODELS]:-'[\"o3\",\"o4-mini\",\"gpt-4.1\",\"gpt-4.1-mini\"]'}" + echo "" + echo "# --- Azure AI Foundry ---" + echo "AZURE_AI_SUBSCRIPTION_ID=${env_vars[AZURE_AI_SUBSCRIPTION_ID]:-}" + echo "AZURE_AI_RESOURCE_GROUP=${env_vars[AZURE_AI_RESOURCE_GROUP]:-}" + echo "AZURE_AI_PROJECT_NAME=${env_vars[AZURE_AI_PROJECT_NAME]:-}" + echo "AZURE_AI_PROJECT_ENDPOINT=${env_vars[AZURE_AI_PROJECT_ENDPOINT]:-}" + echo "AZURE_AI_AGENT_ENDPOINT=${env_vars[AZURE_AI_AGENT_ENDPOINT]:-}" + echo "AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME=${env_vars[AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME]:-gpt-4.1-mini}" + echo "AZURE_AI_AGENT_API_VERSION=${env_vars[AZURE_AI_AGENT_API_VERSION]:-2025-05-01-preview}" + echo "AZURE_AI_AGENT_PROJECT_CONNECTION_STRING=${env_vars[AZURE_AI_AGENT_PROJECT_CONNECTION_STRING]:-}" + echo "AZURE_COGNITIVE_SERVICES=${env_vars[AZURE_COGNITIVE_SERVICES]:-https://cognitiveservices.azure.com/.default}" + echo "" + echo "# --- Azure AI Search ---" + echo "AZURE_AI_SEARCH_CONNECTION_NAME=${env_vars[AZURE_AI_SEARCH_CONNECTION_NAME]:-}" + echo "AZURE_AI_SEARCH_ENDPOINT=${env_vars[AZURE_AI_SEARCH_ENDPOINT]:-}" + echo "" + echo "# --- Application Insights ---" + echo "APPLICATIONINSIGHTS_INSTRUMENTATION_KEY=${env_vars[APPLICATIONINSIGHTS_INSTRUMENTATION_KEY]:-}" + echo "APPLICATIONINSIGHTS_CONNECTION_STRING=${env_vars[APPLICATIONINSIGHTS_CONNECTION_STRING]:-}" + echo "" + echo "# --- Storage ---" + echo "AZURE_STORAGE_BLOB_URL=${env_vars[AZURE_STORAGE_BLOB_URL]:-}" + echo "" + echo "# --- Bing ---" + echo "AZURE_BING_CONNECTION_NAME=${env_vars[AZURE_BING_CONNECTION_NAME]:-binggrnd}" + echo "BING_CONNECTION_NAME=${env_vars[BING_CONNECTION_NAME]:-binggrnd}" + echo "" + echo "# --- Logging ---" + echo "AZURE_BASIC_LOGGING_LEVEL=${env_vars[AZURE_BASIC_LOGGING_LEVEL]:-INFO}" + echo "AZURE_PACKAGE_LOGGING_LEVEL=${env_vars[AZURE_PACKAGE_LOGGING_LEVEL]:-WARNING}" + echo "AZURE_LOGGING_PACKAGES=${env_vars[AZURE_LOGGING_PACKAGES]:-}" + } > "$env_file" + + log_success ".env file generated successfully" + + # Validate required keys + local required_keys=("COSMOSDB_ENDPOINT" "AZURE_OPENAI_ENDPOINT" "AZURE_AI_SUBSCRIPTION_ID" "AZURE_AI_RESOURCE_GROUP" "AZURE_AI_PROJECT_NAME" "AZURE_AI_AGENT_ENDPOINT") + local missing_keys=() + for key in "${required_keys[@]}"; do + local val="${env_vars[$key]:-}" + if [[ -z "$val" ]]; then + missing_keys+=("$key") + fi + done + + if [[ ${#missing_keys[@]} -gt 0 ]]; then + log_warn "The following required values are empty (edit .env manually):" + for k in "${missing_keys[@]}"; do + log_warn " - $k" + done + fi +} + +check_private_networking() { + # Check if resources have private endpoints / disabled public access + log_info "Checking network accessibility..." + + local cosmos_name + cosmos_name=$(az cosmosdb list --resource-group "$RESOURCE_GROUP" --query "[0].name" -o tsv 2>/dev/null || true) + + if [[ -n "$cosmos_name" ]]; then + local public_access + public_access=$(az cosmosdb show --name "$cosmos_name" --resource-group "$RESOURCE_GROUP" \ + --query "publicNetworkAccess" -o tsv 2>/dev/null || true) + if [[ "$public_access" == "Disabled" ]]; then + echo "" + log_warn "⚠️ PRIVATE NETWORKING DETECTED" + log_warn "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + log_warn "CosmosDB has public network access DISABLED." + log_warn "Local development may fail with connectivity errors." + log_warn "" + log_warn "Options:" + log_warn " 1. Use a jumpbox/VM inside the VNet" + log_warn " 2. Connect via VPN to the VNet" + log_warn " 3. Temporarily enable public access on resources" + log_warn "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + if ! confirm "Continue setup anyway?" "y"; then + exit 0 + fi + fi + fi +} + +# ============================================================================== +# Step 4: RBAC Assignment (Optional) +# ============================================================================== + +assign_rbac_roles() { + # Always assign RBAC when resource group is known (needed for local dev access) + if [[ -z "$RESOURCE_GROUP" ]]; then + log_info "No resource group specified, skipping RBAC assignment." + return 0 + fi + + log_step "Step 4: Assigning RBAC Roles" + + local user_object_id user_upn + user_object_id=$(az ad signed-in-user show --query id -o tsv 2>/dev/null || true) + user_upn=$(az ad signed-in-user show --query userPrincipalName -o tsv 2>/dev/null || true) + + if [[ -z "$user_object_id" || -z "$user_upn" ]]; then + log_error "Could not get current user info. Skipping RBAC." + return 0 + fi + + log_info "Assigning roles for: $user_upn ($user_object_id)" + + local sub_id + sub_id=$(az account show --query id -o tsv) + + # Cosmos DB (uses its own role system, not ARM RBAC) + local cosmos_name + cosmos_name=$(az cosmosdb list --resource-group "$RESOURCE_GROUP" --query "[0].name" -o tsv 2>/dev/null || true) + if [[ -n "$cosmos_name" ]]; then + # Check if role already assigned + local existing_cosmos + existing_cosmos=$(az cosmosdb sql role assignment list \ + --resource-group "$RESOURCE_GROUP" --account-name "$cosmos_name" \ + --query "[?principalId=='$user_object_id'].id" -o tsv 2>/dev/null || true) + if [[ -n "$existing_cosmos" ]]; then + log_success " Cosmos DB Data Contributor: already assigned ✓" + else + log_info " Assigning Cosmos DB Built-in Data Contributor..." + az cosmosdb sql role assignment create \ + --resource-group "$RESOURCE_GROUP" \ + --account-name "$cosmos_name" \ + --role-definition-name "Cosmos DB Built-in Data Contributor" \ + --principal-id "$user_object_id" \ + --scope "/subscriptions/$sub_id/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.DocumentDB/databaseAccounts/$cosmos_name" \ + 2>/dev/null && log_success " Cosmos DB role assigned" || log_warn " Cosmos DB role assignment failed (may need elevated permissions)" + fi + fi + + # AI Foundry / Cognitive Services + local ai_services_name + ai_services_name=$(az cognitiveservices account list --resource-group "$RESOURCE_GROUP" \ + --query "[?kind=='AIServices' || kind=='CognitiveServices'].name | [0]" -o tsv 2>/dev/null || true) + local ai_project_name + ai_project_name=$(az cognitiveservices account list --resource-group "$RESOURCE_GROUP" \ + --query "[?kind=='AIProject'].name | [0]" -o tsv 2>/dev/null || true) + + if [[ -n "$ai_services_name" && -n "$ai_project_name" ]]; then + local scope="/subscriptions/$sub_id/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.CognitiveServices/accounts/$ai_services_name/projects/$ai_project_name" + + for role in "Azure AI User" "Azure AI Developer" "Cognitive Services OpenAI User"; do + local existing + existing=$(az role assignment list --assignee "$user_object_id" --role "$role" --scope "$scope" \ + --query "[0].id" -o tsv 2>/dev/null || true) + if [[ -n "$existing" ]]; then + log_success " $role: already assigned ✓" + else + log_info " Assigning '$role'..." + az role assignment create --assignee "$user_upn" --role "$role" --scope "$scope" \ + 2>/dev/null && log_success " $role assigned" || log_warn " $role assignment failed" + fi + done + fi + + # Search Service + local search_name + search_name=$(az search service list --resource-group "$RESOURCE_GROUP" --query "[0].name" -o tsv 2>/dev/null || true) + if [[ -n "$search_name" ]]; then + local scope="/subscriptions/$sub_id/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.Search/searchServices/$search_name" + local existing + existing=$(az role assignment list --assignee "$user_object_id" --role "Search Index Data Contributor" --scope "$scope" \ + --query "[0].id" -o tsv 2>/dev/null || true) + if [[ -n "$existing" ]]; then + log_success " Search Index Data Contributor: already assigned ✓" + else + log_info " Assigning Search Index Data Contributor..." + az role assignment create --assignee "$user_upn" --role "Search Index Data Contributor" --scope "$scope" \ + 2>/dev/null && log_success " Search role assigned" || log_warn " Search role assignment failed" + fi + fi + + # Storage Account + local storage_name + storage_name=$(az storage account list --resource-group "$RESOURCE_GROUP" --query "[0].name" -o tsv 2>/dev/null || true) + if [[ -n "$storage_name" ]]; then + local scope="/subscriptions/$sub_id/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.Storage/storageAccounts/$storage_name" + local existing + existing=$(az role assignment list --assignee "$user_object_id" --role "Storage Blob Data Contributor" --scope "$scope" \ + --query "[0].id" -o tsv 2>/dev/null || true) + if [[ -n "$existing" ]]; then + log_success " Storage Blob Data Contributor: already assigned ✓" + else + log_info " Assigning Storage Blob Data Contributor..." + az role assignment create --assignee "$user_upn" --role "Storage Blob Data Contributor" --scope "$scope" \ + 2>/dev/null && log_success " Storage role assigned" || log_warn " Storage role assignment failed" + fi + fi + + log_success "RBAC assignment complete" + log_warn "Note: New role assignments may take 5-10 minutes to propagate" +} + +# ============================================================================== +# Step 5: Backend Setup +# ============================================================================== + +setup_backend() { + log_step "Step 5: Setting up Backend (src/backend)" + + cd "$BACKEND_DIR" + + # Handle existing .venv that may be locked + if [[ -d ".venv" ]]; then + if ! touch ".venv/.lock-test" 2>/dev/null; then + log_warn ".venv is locked by another process (likely VS Code Python extension)." + log_info "Attempting to auto-fix by killing locking Python processes..." + + VENV_ABS=$(cd .venv && pwd) + # Find and kill python processes running from this venv + LOCKING_PIDS=$(lsof +D "$VENV_ABS" 2>/dev/null | awk 'NR>1 {print $2}' | sort -u) + if [[ -n "$LOCKING_PIDS" ]]; then + for pid in $LOCKING_PIDS; do + log_info " Killing PID $pid" + kill -9 "$pid" 2>/dev/null || true + done + sleep 2 + fi + + # Retry deletion + if rm -rf ".venv" 2>/dev/null; then + log_info "Removed locked .venv successfully." + else + log_warn "Still cannot remove .venv after killing processes." + log_warn "Close VS Code completely and re-run the script." + exit 1 + fi + else + rm -f ".venv/.lock-test" + log_info "Existing virtual environment found, reusing it." + fi + fi + + # Create virtual environment if not exists + if [[ ! -d ".venv" ]]; then + log_info "Creating virtual environment..." + uv venv .venv + fi + + # Install dependencies + log_info "Installing dependencies..." + uv sync --python 3.12 --extra dev + + log_success "Backend setup complete" + cd "$SCRIPT_DIR" +} + +# ============================================================================== +# Step 6: MCP Server Setup +# ============================================================================== + +setup_mcp_server() { + log_step "Step 6: Setting up MCP Server (src/mcp_server)" + + cd "$MCP_DIR" + + # Create virtual environment + if [[ ! -d ".venv" ]]; then + log_info "Creating virtual environment..." + uv venv .venv + else + log_info "Virtual environment already exists" + fi + + # Install dependencies + log_info "Installing dependencies..." + uv sync --python 3.12 + + log_success "MCP Server setup complete" + cd "$SCRIPT_DIR" +} + +# ============================================================================== +# Step 7: Frontend Setup +# ============================================================================== + +setup_frontend() { + log_step "Step 7: Setting up Frontend (src/App)" + + cd "$FRONTEND_DIR" + + # Python venv for frontend server + if [[ ! -d ".venv" ]]; then + log_info "Creating Python virtual environment..." + python3 -m venv .venv + else + log_info "Python virtual environment already exists" + fi + + # Install Python dependencies + log_info "Installing Python dependencies..." + source .venv/bin/activate 2>/dev/null || . .venv/bin/activate + pip install -q -r requirements.txt + deactivate 2>/dev/null || true + + # Install Node.js dependencies + if [[ ! -d "node_modules" ]]; then + log_info "Installing npm dependencies..." + npm install + else + log_info "node_modules already exists, running npm install to update..." + npm install + fi + + # Build the frontend + log_info "Building frontend..." + npm run build + + log_success "Frontend setup complete" + cd "$SCRIPT_DIR" +} + +# ============================================================================== +# Step 8: VS Code Configuration +# ============================================================================== + +setup_vscode() { + if [[ "$SKIP_VSCODE" == true ]]; then + return 0 + fi + + log_step "Step 8: Configuring VS Code" + + mkdir -p "$SCRIPT_DIR/.vscode" + + # extensions.json + if [[ ! -f "$SCRIPT_DIR/.vscode/extensions.json" ]]; then + cat > "$SCRIPT_DIR/.vscode/extensions.json" << 'EOF' +{ + "recommendations": [ + "ms-python.python", + "ms-python.pylint", + "ms-python.black-formatter", + "ms-python.isort", + "ms-vscode-remote.remote-wsl", + "ms-vscode-remote.remote-containers", + "redhat.vscode-yaml", + "ms-vscode.azure-account", + "ms-python.mypy-type-checker" + ] +} +EOF + log_success "Created .vscode/extensions.json" + else + log_info ".vscode/extensions.json already exists" + fi + + # settings.json + if [[ ! -f "$SCRIPT_DIR/.vscode/settings.json" ]]; then + cat > "$SCRIPT_DIR/.vscode/settings.json" << 'EOF' +{ + "python.defaultInterpreterPath": "${workspaceFolder}/src/backend/.venv/bin/python", + "python.terminal.activateEnvironment": true, + "python.linting.enabled": true, + "python.linting.pylintEnabled": false, + "python.linting.flake8Enabled": true, + "python.formatting.provider": "black", + "python.debugging.logLevel": "Debug", + "debug.terminal.clearBeforeReusing": true, + "debug.onTaskErrors": "showErrors", + "debug.showBreakpointsInOverviewRuler": true, + "debug.inlineValues": "on", + "files.exclude": { + "**/__pycache__": true, + "**/*.pyc": true + }, + "python.analysis.extraPaths": [ + "${workspaceFolder}/src/backend", + "${workspaceFolder}/src/mcp_server" + ] +} +EOF + log_success "Created .vscode/settings.json" + else + log_info ".vscode/settings.json already exists" + fi + + log_success "VS Code configuration complete" +} + +# ============================================================================== +# Step 9: Summary & Run Instructions +# ============================================================================== + +print_summary() { + log_step "Setup Complete! 🎉" + + echo -e "${GREEN}All services have been set up successfully.${NC}" + echo "" + echo -e "${CYAN}To start the application, open 3 separate terminal windows:${NC}" + echo "" + echo -e " ${YELLOW}Terminal 1 - Backend (port 8000):${NC}" + echo " cd src/backend" + echo " source .venv/bin/activate" + echo " python app.py" + echo "" + echo -e " ${YELLOW}Terminal 2 - MCP Server (port 9000):${NC}" + echo " cd src/mcp_server" + echo " source .venv/bin/activate" + echo " python mcp_server.py --transport streamable-http --host 0.0.0.0 --port 9000" + echo "" + echo -e " ${YELLOW}Terminal 3 - Frontend (port 3000):${NC}" + echo " cd src/App" + echo " source .venv/bin/activate" + echo " python frontend_server.py" + echo "" + echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e " ${GREEN}Application URL:${NC} http://localhost:3000" + echo -e " ${GREEN}Backend API:${NC} http://localhost:8000" + echo -e " ${GREEN}API Docs:${NC} http://localhost:8000/docs" + echo -e " ${GREEN}MCP Server:${NC} http://localhost:9000" + echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" + + if [[ "$ASSIGN_RBAC" == true ]]; then + log_warn "RBAC roles were assigned. Wait 5-10 minutes for propagation before testing." + fi + + # Quick-start helper script + create_start_script +} + +create_start_script() { + local start_script="$SCRIPT_DIR/start_all_services.sh" + + cat > "$start_script" << 'EOF' +#!/usr/bin/env bash +# Quick-start script: launches all 3 MACAE services in background +# Use: ./start_all_services.sh +# Stop: ./start_all_services.sh stop + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +stop_services() { + echo -e "${YELLOW}Stopping MACAE services...${NC}" + for pidfile in "$SCRIPT_DIR"/.macae_*.pid; do + if [[ -f "$pidfile" ]]; then + local pid + pid=$(cat "$pidfile") + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + echo -e "${GREEN}Stopped PID $pid${NC}" + fi + rm -f "$pidfile" + fi + done + echo -e "${GREEN}All services stopped.${NC}" + exit 0 +} + +if [[ "${1:-}" == "stop" ]]; then + stop_services +fi + +echo -e "${GREEN}Starting MACAE services...${NC}" + +# Backend +cd "$SCRIPT_DIR/src/backend" +source .venv/bin/activate +python app.py & +echo $! > "$SCRIPT_DIR/.macae_backend.pid" +echo -e " ${GREEN}Backend${NC} started (PID: $!)" +deactivate 2>/dev/null || true + +# MCP Server +cd "$SCRIPT_DIR/src/mcp_server" +source .venv/bin/activate +python mcp_server.py --transport streamable-http --host 0.0.0.0 --port 9000 & +echo $! > "$SCRIPT_DIR/.macae_mcp.pid" +echo -e " ${GREEN}MCP Server${NC} started (PID: $!)" +deactivate 2>/dev/null || true + +# Frontend +cd "$SCRIPT_DIR/src/App" +source .venv/bin/activate +python frontend_server.py & +echo $! > "$SCRIPT_DIR/.macae_frontend.pid" +echo -e " ${GREEN}Frontend${NC} started (PID: $!)" +deactivate 2>/dev/null || true + +cd "$SCRIPT_DIR" + +echo "" +echo -e "${GREEN}All services running!${NC}" +echo -e " Backend: http://localhost:8000" +echo -e " MCP: http://localhost:9000" +echo -e " Frontend: http://localhost:3000" +echo "" +echo -e "To stop: ${YELLOW}./start_all_services.sh stop${NC}" +EOF + + chmod +x "$start_script" + log_success "Created start_all_services.sh (quick-start helper)" +} + +# ============================================================================== +# Main +# ============================================================================== + +main() { + echo -e "${CYAN}" + echo "╔══════════════════════════════════════════════════════════════╗" + echo "║ MACAE - Local Development Setup ║" + echo "║ Multi-Agent Custom Automation Engine ║" + echo "╚══════════════════════════════════════════════════════════════╝" + echo -e "${NC}" + + parse_args "$@" + + # Verify we're in the repo root + if [[ ! -f "$SCRIPT_DIR/src/backend/app.py" ]]; then + log_error "This script must be run from the repository root directory" + log_error "Expected to find: src/backend/app.py" + exit 1 + fi + + check_prerequisites + check_azure_auth + fetch_configuration + assign_rbac_roles + setup_backend + setup_mcp_server + setup_frontend + setup_vscode + print_summary +} + +main "$@" From 46ab93556224f28258199c28cb25b7d3e9af8dfa Mon Sep 17 00:00:00 2001 From: Abdul-Microsoft Date: Fri, 8 May 2026 08:01:28 +0530 Subject: [PATCH 02/20] Enhance Azure deployment script with improved error handling, role assignment, and change detection --- deploy_to_azure.ps1 | 342 +++++++++++++++++++-------- deploy_to_azure.sh | 462 +++++++++++++++++++++++++------------ docs/DeployLocalChanges.md | 197 ++++++++++++++++ 3 files changed, 759 insertions(+), 242 deletions(-) create mode 100644 docs/DeployLocalChanges.md diff --git a/deploy_to_azure.ps1 b/deploy_to_azure.ps1 index de4f5f0d9..4c2f59db8 100644 --- a/deploy_to_azure.ps1 +++ b/deploy_to_azure.ps1 @@ -20,7 +20,8 @@ param( [string]$Tag = "", [switch]$DryRun, [switch]$BuildOnly, - [switch]$DeployOnly + [switch]$DeployOnly, + [switch]$SkipRoleAssignment ) $ErrorActionPreference = "Stop" @@ -48,6 +49,27 @@ function Write-LogWarn { param([string]$msg) Write-Host "[!] $msg" -Foregroun function Write-LogError { param([string]$msg) Write-Host "[✗] $msg" -ForegroundColor Red } function Write-LogStep { param([string]$msg) Write-Host "`n━━━ $msg ━━━`n" -ForegroundColor Cyan } +# Retry az command up to 4 times on transient network/operation-in-progress errors +function Invoke-AzRetry { + param([string[]]$AzArgs) + $attempt = 1 + while ($attempt -le 4) { + $out = az @AzArgs 2>&1 + if ($LASTEXITCODE -eq 0) { return $out } + $outStr = $out -join "`n" + if ($outStr -match 'OperationInProgress|ContainerAppOperation') { + Write-LogWarn "Azure operation in progress (attempt $attempt/4), retrying in 30s..." -ForegroundColor Yellow + Start-Sleep -Seconds 30; $attempt++ + } elseif ($outStr -match 'RemoteDisconnected|Connection aborted|timed out|ECONNRESET|HTTPSConnectionPool|Max retries exceeded|NewConnectionError|getaddrinfo|Failed to establish') { + Write-LogWarn "Transient network error (attempt $attempt/4), retrying in 15s..." + Start-Sleep -Seconds 15; $attempt++ + } else { + return $out + } + } + return $out +} + # ============================================================================== # Step 1: Prerequisites # ============================================================================== @@ -191,50 +213,75 @@ function Discover-Resources { # Step 3: Resolve ACR # ============================================================================== +# Resolve ACR resource ID reliably: +# 1. Try with -ResourceGroup (fastest, most reliable for RG-scoped ACRs) +# 2. Try global lookup (for ACRs in a different RG) +# 3. Build from known parts as fallback (handles post-create propagation delay) +function Get-AcrResourceId([string]$acrName, [string]$rg = $ResourceGroup) { + $id = az acr show --name $acrName --resource-group $rg --query "id" -o tsv 2>$null + if (-not $id) { + $id = az acr show --name $acrName --query "id" -o tsv 2>$null + } + if (-not $id) { + $subId = az account show --query id -o tsv 2>$null + $id = "/subscriptions/$subId/resourceGroups/$rg/providers/Microsoft.ContainerRegistry/registries/$acrName" + } + return $id +} + function Resolve-Acr { Write-LogStep "Step 3: Resolving Container Registry" if ($Acr) { + # User provided ACR via -Acr flag — try RG-scoped lookup first, then global $input = $Acr -replace '\.azurecr\.io$', '' - $script:AcrName = az acr show --name $input --query "name" -o tsv 2>$null + $script:AcrName = az acr list --resource-group $ResourceGroup --query "[?name=='$input'].name | [0]" -o tsv 2>$null + if (-not $script:AcrName) { + $script:AcrName = az acr show --name $input --query "name" -o tsv 2>$null + } if (-not $script:AcrName) { Write-LogError "ACR '$Acr' not found or not accessible." exit 1 } - $script:AcrLoginServer = az acr show --name $script:AcrName --query "loginServer" -o tsv - $script:AcrId = az acr show --name $script:AcrName --query "id" -o tsv + $script:AcrLoginServer = az acr show --name $script:AcrName --query "loginServer" -o tsv 2>$null + if (-not $script:AcrLoginServer) { $script:AcrLoginServer = az acr show --name $script:AcrName --resource-group $ResourceGroup --query "loginServer" -o tsv 2>$null } + $script:AcrId = Get-AcrResourceId $script:AcrName Write-LogSuccess "Using specified ACR: $script:AcrName ($script:AcrLoginServer)" + Assign-AcrPullRoles return } - # Discover in RG - Write-LogInfo "Looking for ACR in resource group..." - $script:AcrName = az acr list --resource-group $ResourceGroup --query "[0].name" -o tsv 2>$null - - if ($script:AcrName) { - $script:AcrLoginServer = az acr show --name $script:AcrName --query "loginServer" -o tsv - $script:AcrId = az acr show --name $script:AcrName --query "id" -o tsv - Write-LogSuccess "Found ACR in RG: $script:AcrName ($script:AcrLoginServer)" - return - } - - # Ask user - Write-LogWarn "No ACR found in resource group '$ResourceGroup'." + # Always ask first — no pre-discovery Write-Host "" - $userAcr = Read-Host "Do you have an existing ACR? Enter its name (or press Enter to create one)" + $userAcr = Read-Host "Enter ACR name to use (or press Enter to see available ACRs / create new)" if ($userAcr) { $input = $userAcr -replace '\.azurecr\.io$', '' $script:AcrName = az acr show --name $input --query "name" -o tsv 2>$null if (-not $script:AcrName) { - Write-LogError "ACR '$userAcr' not found." + Write-LogError "ACR '$userAcr' not found or not accessible." exit 1 } - $script:AcrLoginServer = az acr show --name $script:AcrName --query "loginServer" -o tsv - $script:AcrId = az acr show --name $script:AcrName --query "id" -o tsv + $script:AcrLoginServer = az acr show --name $script:AcrName --resource-group $ResourceGroup --query "loginServer" -o tsv 2>$null + if (-not $script:AcrLoginServer) { $script:AcrLoginServer = az acr show --name $script:AcrName --query "loginServer" -o tsv 2>$null } + $script:AcrId = Get-AcrResourceId $script:AcrName Write-LogSuccess "Using ACR: $script:AcrName ($script:AcrLoginServer)" + Assign-AcrPullRoles + return + } + + # Empty input — discover what's in the RG and auto-select or auto-create + Write-LogInfo "Looking for ACR(s) in resource group '$ResourceGroup'..." + $foundAcrs = @(az acr list --resource-group $ResourceGroup --query "[].name" -o tsv 2>$null | Where-Object { $_ }) + + if ($foundAcrs.Count -gt 0) { + $script:AcrName = $foundAcrs[0] + $script:AcrLoginServer = az acr show --name $script:AcrName --resource-group $ResourceGroup --query "loginServer" -o tsv + $script:AcrId = Get-AcrResourceId $script:AcrName + Write-LogSuccess "Found and using ACR: $script:AcrName ($script:AcrLoginServer)" + Assign-AcrPullRoles } else { - # Create new ACR + # Create new ACR in the same RG $suffix = ($ResourceGroup -replace '[^a-zA-Z0-9]', '').Substring(0, [Math]::Min(15, ($ResourceGroup -replace '[^a-zA-Z0-9]', '').Length)) $ts = (Get-Date).ToString("HHmmss") $newAcrName = ("acr$suffix$ts").ToLower().Substring(0, [Math]::Min(50, ("acr$suffix$ts").Length)) @@ -248,10 +295,9 @@ function Resolve-Acr { --output none $script:AcrName = $newAcrName - $script:AcrLoginServer = az acr show --name $script:AcrName --query "loginServer" -o tsv - $script:AcrId = az acr show --name $script:AcrName --query "id" -o tsv + $script:AcrLoginServer = az acr show --name $script:AcrName --resource-group $ResourceGroup --query "loginServer" -o tsv + $script:AcrId = Get-AcrResourceId $script:AcrName Write-LogSuccess "Created ACR: $script:AcrName ($script:AcrLoginServer)" - Assign-AcrPullRoles } } @@ -261,37 +307,69 @@ function Resolve-Acr { # ============================================================================== function Assign-AcrPullRoles { + if ($SkipRoleAssignment) { + Write-LogInfo "Skipping AcrPull role assignment (-SkipRoleAssignment set)." + return + } + Write-LogInfo "Assigning AcrPull role to service identities..." + if (-not $script:AcrId) { + Write-LogError "ACR resource ID is empty — cannot assign roles. Aborting." + exit 1 + } + $acrPullRole = "7f951dda-4ed3-4680-a7ca-43fe172d538d" + $anyFailed = $false - # Backend - if ($script:BackendCA) { - $identity = az containerapp show --name $script:BackendCA --resource-group $ResourceGroup ` + # Helper: resolve principal ID from a Container App (system-assigned first, then user-assigned) + function Get-CAPrincipalId([string]$caName) { + $id = az containerapp show --name $caName --resource-group $ResourceGroup ` --query "identity.principalId" -o tsv 2>$null - if ($identity -and $identity -ne "null") { - $existing = az role assignment list --assignee $identity --role $acrPullRole --scope $script:AcrId --query "[0].id" -o tsv 2>$null - if (-not $existing) { - az role assignment create --assignee $identity --role $acrPullRole --scope $script:AcrId --output none 2>$null - Write-LogSuccess " AcrPull assigned to backend identity" + if (-not $id -or $id -eq "null") { + $id = az containerapp show --name $caName --resource-group $ResourceGroup ` + --query "identity.userAssignedIdentities.*.principalId | [0]" -o tsv 2>$null + } + return $id + } + + function Assign-Role([string]$identity, [string]$label) { + $existing = az role assignment list --assignee $identity --role $acrPullRole --scope $script:AcrId --query "[0].id" -o tsv 2>$null + if (-not $existing) { + $createOutput = az role assignment create --assignee $identity --role $acrPullRole --scope $script:AcrId --output none 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-LogError " Failed to assign AcrPull to $label identity" + Write-LogError " Azure: $createOutput" + $script:RoleFailed = $true } else { - Write-LogInfo " AcrPull already assigned to backend identity ✓" + Write-LogSuccess " AcrPull assigned to $label identity" } + } else { + Write-LogInfo " AcrPull already assigned to $label identity ✓" + } + } + + $script:RoleFailed = $false + + # Backend + if ($script:BackendCA) { + $identity = Get-CAPrincipalId $script:BackendCA + if ($identity -and $identity -ne "null") { + Assign-Role $identity "backend" + } else { + Write-LogWarn " No identity found on backend Container App — cannot assign AcrPull" + $script:RoleFailed = $true } } # MCP if ($script:McpCA) { - $identity = az containerapp show --name $script:McpCA --resource-group $ResourceGroup ` - --query "identity.principalId" -o tsv 2>$null + $identity = Get-CAPrincipalId $script:McpCA if ($identity -and $identity -ne "null") { - $existing = az role assignment list --assignee $identity --role $acrPullRole --scope $script:AcrId --query "[0].id" -o tsv 2>$null - if (-not $existing) { - az role assignment create --assignee $identity --role $acrPullRole --scope $script:AcrId --output none 2>$null - Write-LogSuccess " AcrPull assigned to MCP identity" - } else { - Write-LogInfo " AcrPull already assigned to MCP identity ✓" - } + Assign-Role $identity "MCP" + } else { + Write-LogWarn " No identity found on MCP Container App — cannot assign AcrPull" + $script:RoleFailed = $true } } @@ -299,22 +377,50 @@ function Assign-AcrPullRoles { if ($script:FrontendApp) { $identity = az webapp show --name $script:FrontendApp --resource-group $ResourceGroup ` --query "identity.principalId" -o tsv 2>$null + if (-not $identity -or $identity -eq "null") { + $identity = az webapp show --name $script:FrontendApp --resource-group $ResourceGroup ` + --query "identity.userAssignedIdentities.*.principalId | [0]" -o tsv 2>$null + } if ($identity -and $identity -ne "null") { - $existing = az role assignment list --assignee $identity --role $acrPullRole --scope $script:AcrId --query "[0].id" -o tsv 2>$null - if (-not $existing) { - az role assignment create --assignee $identity --role $acrPullRole --scope $script:AcrId --output none 2>$null - Write-LogSuccess " AcrPull assigned to frontend identity" - } else { - Write-LogInfo " AcrPull already assigned to frontend identity ✓" - } + Assign-Role $identity "frontend" + } else { + Write-LogWarn " No identity found on frontend Web App — cannot assign AcrPull" + $script:RoleFailed = $true } } + + if ($script:RoleFailed) { + Write-Host "" + Write-LogError "One or more AcrPull role assignments failed." + Write-LogError "The container(s) will NOT be able to pull images from $($script:AcrLoginServer)." + Write-LogError "" + Write-LogError "This usually means your account lacks 'Microsoft.Authorization/roleAssignments/write'." + Write-LogError "Ask your subscription Owner to grant you 'User Access Administrator' on the RG," + Write-LogError "or run: az role assignment create --assignee --role 'Owner' --scope /subscriptions/" + Write-LogError "" + Write-LogError "If AcrPull roles are already assigned, re-run with: -SkipRoleAssignment" + exit 1 + } } # ============================================================================== # Step 4: Determine Services # ============================================================================== +function Get-ChangedServices { + # Only detect uncommitted changes (staged + unstaged vs last commit). + # We intentionally skip 'commits ahead of origin/main' to avoid false positives + # from other work on the feature branch that the user hasn't actively changed. + $changed = git diff --name-only HEAD 2>$null + if (-not $changed) { return @() } + + $services = @() + if ($changed -match '^src/backend/') { $services += "backend" } + if ($changed -match '^src/mcp_server/') { $services += "mcp" } + if ($changed -match '^src/App/') { $services += "frontend" } + return $services +} + function Determine-Services { Write-LogStep "Step 4: Determining Services to Deploy" @@ -333,9 +439,32 @@ function Determine-Services { } } } else { - $script:DeployBackend = $true - $script:DeployMcp = $true - $script:DeployFrontend = $true + # Auto-detect changed services from git + Write-LogInfo "No -Services specified — detecting changed services via git..." + $detected = Get-ChangedServices + + if ($detected.Count -gt 0) { + Write-LogInfo "Git detected changes in: $($detected -join ', ')" + foreach ($svc in $detected) { + switch ($svc) { + "backend" { $script:DeployBackend = $true } + "mcp" { $script:DeployMcp = $true } + "frontend" { $script:DeployFrontend = $true } + } + } + } else { + Write-LogWarn "No service-specific changes detected (no git diff vs HEAD or origin/main)." + Write-Host "" + $confirm = Read-Host "No changes detected. Deploy all services anyway? [y/N]" + if ($confirm -match '^[Yy](es)?$') { + $script:DeployBackend = $true + $script:DeployMcp = $true + $script:DeployFrontend = $true + } else { + Write-LogInfo "Nothing to deploy. Exiting." + exit 0 + } + } } Write-Host " Services to deploy:" @@ -389,8 +518,10 @@ function Build-AndPush { Write-LogInfo "[DRY RUN] Would build: docker build -t $fullImage $BackendDir" } else { docker build -t $fullImage $BackendDir + if ($LASTEXITCODE -ne 0) { Write-LogError "Backend image build FAILED"; exit 1 } Write-LogSuccess "Backend image built" docker push $fullImage + if ($LASTEXITCODE -ne 0) { Write-LogError "Backend image push FAILED"; exit 1 } Write-LogSuccess "Backend image pushed: $fullImage" } } @@ -402,8 +533,10 @@ function Build-AndPush { Write-LogInfo "[DRY RUN] Would build: docker build -t $fullImage $McpDir" } else { docker build -t $fullImage $McpDir + if ($LASTEXITCODE -ne 0) { Write-LogError "MCP image build FAILED"; exit 1 } Write-LogSuccess "MCP image built" docker push $fullImage + if ($LASTEXITCODE -ne 0) { Write-LogError "MCP image push FAILED"; exit 1 } Write-LogSuccess "MCP image pushed: $fullImage" } } @@ -415,8 +548,10 @@ function Build-AndPush { Write-LogInfo "[DRY RUN] Would build: docker build -t $fullImage $FrontendDir" } else { docker build -t $fullImage $FrontendDir + if ($LASTEXITCODE -ne 0) { Write-LogError "Frontend image build FAILED"; exit 1 } Write-LogSuccess "Frontend image built" docker push $fullImage + if ($LASTEXITCODE -ne 0) { Write-LogError "Frontend image push FAILED"; exit 1 } Write-LogSuccess "Frontend image pushed: $fullImage" } } @@ -430,55 +565,54 @@ function Build-AndPush { # Step 7: Configure ACR on Resources (if changed) # ============================================================================== +function Set-CaRegistry([string]$caName, [string]$label) { + # Skip if registry + identity already correctly configured + $currentServer = az containerapp show --name $caName --resource-group $ResourceGroup ` + --query "properties.configuration.registries[0].server" -o tsv 2>$null + $currentIdentity = az containerapp show --name $caName --resource-group $ResourceGroup ` + --query "properties.configuration.registries[0].identity" -o tsv 2>$null + if ($currentServer -eq $script:AcrLoginServer -and $currentIdentity -and $currentIdentity -ne "null") { + Write-LogSuccess "$label`: ACR registry already configured — skipping" + return + } + $identityId = az containerapp show --name $caName --resource-group $ResourceGroup ` + --query "identity.userAssignedIdentities | keys(@) | [0]" -o tsv 2>$null + $identityArg = if ($identityId -and $identityId -ne "null") { $identityId } else { "system" } + Write-LogInfo "Configuring $label registry → $($script:AcrLoginServer)..." + $regOut = az containerapp registry set --name $caName --resource-group $ResourceGroup ` + --server $script:AcrLoginServer --identity $identityArg --output none 2>&1 + if ($LASTEXITCODE -eq 0) { + Write-LogSuccess "$label registry configured" + } elseif ($regOut -match 'Operation expired|OperationInProgress|ContainerAppOperation|HTTPSConnectionPool|Max retries exceeded|NewConnectionError|getaddrinfo|Failed to establish|RemoteDisconnected|Connection aborted') { + Write-LogWarn "$label registry config accepted but status polling failed (network/timeout). The app will pull correctly once the revision is ready." + } else { + Write-LogError "$label registry set FAILED — $regOut" + throw "$label registry set failed" + } +} + function Configure-AcrOnResources { if ($script:DeployBackend -and $script:BackendCA) { - $currentRegistry = az containerapp show --name $script:BackendCA --resource-group $ResourceGroup ` - --query "properties.configuration.registries[0].server" -o tsv 2>$null - if ($currentRegistry -and $currentRegistry -ne $script:AcrLoginServer) { - Write-LogInfo "Updating backend Container App registry to $($script:AcrLoginServer)..." - if (-not $DryRun) { - $identityId = az containerapp show --name $script:BackendCA --resource-group $ResourceGroup ` - --query "identity.userAssignedIdentities | keys(@) | [0]" -o tsv 2>$null - if ($identityId -and $identityId -ne "null") { - az containerapp registry set --name $script:BackendCA --resource-group $ResourceGroup ` - --server $script:AcrLoginServer --identity $identityId --output none 2>$null - } else { - az containerapp registry set --name $script:BackendCA --resource-group $ResourceGroup ` - --server $script:AcrLoginServer --identity system --output none 2>$null - } - Write-LogSuccess "Backend registry updated" - } - } + if ($DryRun) { Write-LogInfo "[DRY RUN] Would configure backend registry" } + else { Set-CaRegistry $script:BackendCA "Backend" } } - if ($script:DeployMcp -and $script:McpCA) { - $currentRegistry = az containerapp show --name $script:McpCA --resource-group $ResourceGroup ` - --query "properties.configuration.registries[0].server" -o tsv 2>$null - if ($currentRegistry -and $currentRegistry -ne $script:AcrLoginServer) { - Write-LogInfo "Updating MCP Container App registry to $($script:AcrLoginServer)..." - if (-not $DryRun) { - $identityId = az containerapp show --name $script:McpCA --resource-group $ResourceGroup ` - --query "identity.userAssignedIdentities | keys(@) | [0]" -o tsv 2>$null - if ($identityId -and $identityId -ne "null") { - az containerapp registry set --name $script:McpCA --resource-group $ResourceGroup ` - --server $script:AcrLoginServer --identity $identityId --output none 2>$null - } else { - az containerapp registry set --name $script:McpCA --resource-group $ResourceGroup ` - --server $script:AcrLoginServer --identity system --output none 2>$null - } - Write-LogSuccess "MCP registry updated" - } - } + if ($DryRun) { Write-LogInfo "[DRY RUN] Would configure MCP registry" } + else { Set-CaRegistry $script:McpCA "MCP" } } - if ($script:DeployFrontend -and $script:FrontendApp) { - Write-LogInfo "Updating frontend App Service registry config..." - if (-not $DryRun) { + if ($DryRun) { Write-LogInfo "[DRY RUN] Would update frontend App Service registry config" } + else { + Write-LogInfo "Updating frontend App Service registry config..." az webapp config appsettings set --name $script:FrontendApp --resource-group $ResourceGroup ` - --settings DOCKER_REGISTRY_SERVER_URL="https://$($script:AcrLoginServer)" --output none 2>$null + --settings DOCKER_REGISTRY_SERVER_URL="https://$($script:AcrLoginServer)" --output none az webapp config set --name $script:FrontendApp --resource-group $ResourceGroup ` - --generic-configurations '{\"acrUseManagedIdentityCreds\": true}' --output none 2>$null - Write-LogSuccess "Frontend registry config updated" + --generic-configurations '{\"acrUseManagedIdentityCreds\": true}' --output none + if ($LASTEXITCODE -ne 0) { + Write-LogError "Frontend registry config FAILED — image pull may fail." + } else { + Write-LogSuccess "Frontend registry config updated" + } } } } @@ -504,8 +638,14 @@ function Update-AzureResources { if ($DryRun) { Write-LogInfo "[DRY RUN] Would run: az containerapp update --name $($script:BackendCA) --image $fullImage" } else { - az containerapp update --name $script:BackendCA --resource-group $ResourceGroup --image $fullImage --output none - Write-LogSuccess "Backend updated successfully" + $updOut = Invoke-AzRetry @('containerapp','update','--name',$script:BackendCA,'--resource-group',$ResourceGroup,'--image',$fullImage,'--output','none') + if ($LASTEXITCODE -eq 0) { + Write-LogSuccess "Backend updated successfully" + } elseif ($updOut -match 'Operation expired|OperationInProgress|ContainerAppOperation|HTTPSConnectionPool|Max retries exceeded|NewConnectionError|getaddrinfo|Failed to establish|RemoteDisconnected|Connection aborted') { + Write-LogWarn "Backend image update accepted but status polling failed (network/timeout). Azure will complete provisioning shortly." + } else { + Write-LogError "Backend update failed: $updOut"; throw "Backend update failed" + } } } } @@ -520,8 +660,14 @@ function Update-AzureResources { if ($DryRun) { Write-LogInfo "[DRY RUN] Would run: az containerapp update --name $($script:McpCA) --image $fullImage" } else { - az containerapp update --name $script:McpCA --resource-group $ResourceGroup --image $fullImage --output none - Write-LogSuccess "MCP updated successfully" + $updOut = Invoke-AzRetry @('containerapp','update','--name',$script:McpCA,'--resource-group',$ResourceGroup,'--image',$fullImage,'--output','none') + if ($LASTEXITCODE -eq 0) { + Write-LogSuccess "MCP updated successfully" + } elseif ($updOut -match 'Operation expired|OperationInProgress|ContainerAppOperation|HTTPSConnectionPool|Max retries exceeded|NewConnectionError|getaddrinfo|Failed to establish|RemoteDisconnected|Connection aborted') { + Write-LogWarn "MCP image update accepted but status polling failed (network/timeout). Azure will complete provisioning shortly." + } else { + Write-LogError "MCP update failed: $updOut"; throw "MCP update failed" + } } } } diff --git a/deploy_to_azure.sh b/deploy_to_azure.sh index 49f0c93ee..9015eddaa 100644 --- a/deploy_to_azure.sh +++ b/deploy_to_azure.sh @@ -18,6 +18,23 @@ set -euo pipefail +# On Windows Git Bash (MSYS/MinGW), paths starting with / get converted to Windows +# paths when passed to native .exe programs. This breaks ARM resource IDs like +# /subscriptions/... but we NEED normal conversion for docker build context paths. +# Solution: wrap 'az' with MSYS_NO_PATHCONV=1 so only az calls skip conversion. +# See: https://github.com/Azure/azure-cli/issues/13009 +az() { MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL="*" command az "$@"; } + +# Convert a path to Windows-native format for tools like docker.exe that need it. +# On non-MSYS systems (Linux/macOS) this is a no-op. +_winpath() { + if command -v cygpath &>/dev/null; then + cygpath -w "$1" + else + echo "$1" + fi +} + # ============================================================================== # Configuration # ============================================================================== @@ -30,6 +47,7 @@ CUSTOM_TAG="" DRY_RUN=false BUILD_ONLY=false DEPLOY_ONLY=false +SKIP_ROLE_ASSIGNMENT=false # Image names (matching infra conventions) BACKEND_IMAGE_NAME="macaebackend" @@ -54,6 +72,26 @@ log_warn() { echo -e "${YELLOW}[!]${NC} $*"; } log_error() { echo -e "${RED}[✗]${NC} $*"; } log_step() { echo -e "\n${CYAN}━━━ $* ━━━${NC}\n"; } +# Retry an az command up to 3 times on transient network errors +az_retry() { + local attempt=1 out rc delay + while [[ $attempt -le 4 ]]; do + out=$(az "$@" 2>&1) && rc=0 || rc=$? + if [[ $rc -eq 0 ]]; then echo "$out"; return 0; fi + if echo "$out" | grep -qiE "OperationInProgress|ContainerAppOperation"; then + delay=30 + log_warn "Azure operation in progress (attempt $attempt/4), retrying in ${delay}s..." >&2 + elif echo "$out" | grep -qiE "RemoteDisconnected|Connection aborted|timed out|ECONNRESET|HTTPSConnectionPool|Max retries exceeded|NewConnectionError|getaddrinfo|Failed to establish"; then + delay=15 + log_warn "Transient network error (attempt $attempt/4), retrying in ${delay}s..." >&2 + else + echo "$out"; return $rc + fi + sleep $delay; (( attempt++ )) + done + echo "$out"; return $rc +} + # ============================================================================== # Argument Parsing # ============================================================================== @@ -75,6 +113,8 @@ parse_args() { BUILD_ONLY=true; shift ;; --deploy-only) DEPLOY_ONLY=true; shift ;; + --skip-role-assignment) + SKIP_ROLE_ASSIGNMENT=true; shift ;; -h|--help) show_help; exit 0 ;; *) @@ -99,6 +139,7 @@ Options: --dry-run Preview what would happen without making changes --build-only Build and push images only, don't update Azure resources --deploy-only Update Azure resources only (images must already exist) + --skip-role-assignment Skip AcrPull role assignment (use if roles already exist) -h, --help Show this help message Examples: @@ -230,7 +271,7 @@ validate_and_discover() { # Discover frontend web app FRONTEND_APP="" - FRONTEND_APP=$(az webapp list --resource-group "$RESOURCE_GROUP" --query "[0].name" -o tsv 2>/dev/null || true) + FRONTEND_APP=$(az webapp list --resource-group "$RESOURCE_GROUP" --query "[0].name" -o tsv 2>/dev/null | tr -d '\r' || true) if [[ -n "$FRONTEND_APP" ]]; then log_success "Frontend Web App: $FRONTEND_APP" @@ -241,19 +282,19 @@ validate_and_discover() { # Capture current images for rollback if [[ -n "$BACKEND_CA" ]]; then OLD_BACKEND_IMAGE=$(az containerapp show --name "$BACKEND_CA" --resource-group "$RESOURCE_GROUP" \ - --query "properties.template.containers[0].image" -o tsv 2>/dev/null || echo "unknown") + --query "properties.template.containers[0].image" -o tsv 2>/dev/null | tr -d '\r' || echo "unknown") log_info "Current backend image: $OLD_BACKEND_IMAGE" fi if [[ -n "$MCP_CA" ]]; then OLD_MCP_IMAGE=$(az containerapp show --name "$MCP_CA" --resource-group "$RESOURCE_GROUP" \ - --query "properties.template.containers[0].image" -o tsv 2>/dev/null || echo "unknown") + --query "properties.template.containers[0].image" -o tsv 2>/dev/null | tr -d '\r' || echo "unknown") log_info "Current MCP image: $OLD_MCP_IMAGE" fi if [[ -n "$FRONTEND_APP" ]]; then OLD_FRONTEND_IMAGE=$(az webapp config show --name "$FRONTEND_APP" --resource-group "$RESOURCE_GROUP" \ - --query "linuxFxVersion" -o tsv 2>/dev/null || echo "unknown") + --query "linuxFxVersion" -o tsv 2>/dev/null | tr -d '\r' || echo "unknown") log_info "Current frontend image: $OLD_FRONTEND_IMAGE" fi } @@ -262,51 +303,81 @@ validate_and_discover() { # Step 3: Resolve ACR # ============================================================================== +# Resolve ACR resource ID reliably: +# 1. Try with --resource-group (fastest, most reliable for RG-scoped ACRs) +# 2. Try global lookup (for ACRs in a different RG) +# 3. Build from known parts as fallback (handles post-create propagation delay) +_get_acr_id() { + local name="$1" + local rg="${2:-$RESOURCE_GROUP}" + local id + id=$(az acr show --name "$name" --resource-group "$rg" --query "id" -o tsv 2>/dev/null | tr -d '\r' || true) + if [[ -z "$id" ]]; then + id=$(az acr show --name "$name" --query "id" -o tsv 2>/dev/null | tr -d '\r' || true) + fi + if [[ -z "$id" ]]; then + local sub_id + sub_id=$(az account show --query id -o tsv 2>/dev/null | tr -d '\r') + id="/subscriptions/$sub_id/resourceGroups/$rg/providers/Microsoft.ContainerRegistry/registries/$name" + fi + echo "$id" +} + resolve_acr() { log_step "Step 3: Resolving Container Registry" if [[ -n "$ACR_INPUT" ]]; then - # User provided ACR — normalize to name and login server - local input="${ACR_INPUT%.azurecr.io}" # strip suffix if provided - ACR_NAME=$(az acr show --name "$input" --query "name" -o tsv 2>/dev/null || true) + # User provided ACR via --acr flag — normalize to name and login server + local input="${ACR_INPUT%.azurecr.io}" + ACR_NAME=$(az acr list --resource-group "$RESOURCE_GROUP" --query "[?name=='$input'].name | [0]" -o tsv 2>/dev/null | tr -d '\r' || true) + if [[ -z "$ACR_NAME" ]]; then + ACR_NAME=$(az acr show --name "$input" --query "name" -o tsv 2>/dev/null | tr -d '\r' || true) + fi if [[ -z "$ACR_NAME" ]]; then log_error "ACR '$ACR_INPUT' not found or not accessible." exit 1 fi - ACR_LOGIN_SERVER=$(az acr show --name "$ACR_NAME" --query "loginServer" -o tsv) - ACR_ID=$(az acr show --name "$ACR_NAME" --query "id" -o tsv) + ACR_LOGIN_SERVER=$(az acr show --name "$ACR_NAME" --query "loginServer" -o tsv 2>/dev/null | tr -d '\r' || az acr show --name "$ACR_NAME" --resource-group "$RESOURCE_GROUP" --query "loginServer" -o tsv | tr -d '\r') + ACR_ID=$(_get_acr_id "$ACR_NAME") log_success "Using specified ACR: $ACR_NAME ($ACR_LOGIN_SERVER)" + assign_acr_pull_roles return fi - # Try to discover ACR in the RG - log_info "Looking for ACR in resource group..." - ACR_NAME=$(az acr list --resource-group "$RESOURCE_GROUP" --query "[0].name" -o tsv 2>/dev/null || true) - - if [[ -n "$ACR_NAME" ]]; then - ACR_LOGIN_SERVER=$(az acr show --name "$ACR_NAME" --query "loginServer" -o tsv) - ACR_ID=$(az acr show --name "$ACR_NAME" --query "id" -o tsv) - log_success "Found ACR in RG: $ACR_NAME ($ACR_LOGIN_SERVER)" - return - fi - - # No ACR found — ask user - log_warn "No ACR found in resource group '$RESOURCE_GROUP'." + # Always ask first — no pre-discovery echo "" - read -rp "Do you have an existing ACR? Enter its name (or press Enter to create one): " user_acr + read -rp "Enter ACR name to use (or press Enter to see available ACRs / create new): " user_acr if [[ -n "$user_acr" ]]; then local input="${user_acr%.azurecr.io}" - ACR_NAME=$(az acr show --name "$input" --query "name" -o tsv 2>/dev/null || true) + ACR_NAME=$(az acr show --name "$input" --query "name" -o tsv 2>/dev/null | tr -d '\r' || true) if [[ -z "$ACR_NAME" ]]; then - log_error "ACR '$user_acr' not found." + log_error "ACR '$user_acr' not found or not accessible." exit 1 fi - ACR_LOGIN_SERVER=$(az acr show --name "$ACR_NAME" --query "loginServer" -o tsv) - ACR_ID=$(az acr show --name "$ACR_NAME" --query "id" -o tsv) + ACR_LOGIN_SERVER=$(az acr show --name "$ACR_NAME" --resource-group "$RESOURCE_GROUP" --query "loginServer" -o tsv 2>/dev/null | tr -d '\r' || az acr show --name "$ACR_NAME" --query "loginServer" -o tsv | tr -d '\r') + ACR_ID=$(_get_acr_id "$ACR_NAME") log_success "Using ACR: $ACR_NAME ($ACR_LOGIN_SERVER)" + assign_acr_pull_roles + return + fi + + # Empty input — discover what's in the RG and auto-select or auto-create + log_info "Looking for ACR(s) in resource group '$RESOURCE_GROUP'..." + local found_acrs + found_acrs=$(az acr list --resource-group "$RESOURCE_GROUP" --query "[].name" -o tsv 2>/dev/null | tr -d '\r' || true) + + local chosen_acr + chosen_acr=$(echo "$found_acrs" | head -1 | tr -d '[:space:]') + + if [[ -n "$chosen_acr" ]]; then + ACR_NAME="$chosen_acr" + ACR_LOGIN_SERVER=$(az acr show --name "$ACR_NAME" --resource-group "$RESOURCE_GROUP" --query "loginServer" -o tsv | tr -d '\r') + ACR_ID=$(_get_acr_id "$ACR_NAME") + log_success "Found and using ACR: $ACR_NAME ($ACR_LOGIN_SERVER)" + assign_acr_pull_roles else - # Create new ACR + # Create new ACR in the same RG local suffix suffix=$(echo "$RESOURCE_GROUP" | sed 's/[^a-zA-Z0-9]//g' | tail -c 15) local new_acr_name="acr${suffix}$(date +%s | tail -c 6)" @@ -321,11 +392,9 @@ resolve_acr() { --output none ACR_NAME="$new_acr_name" - ACR_LOGIN_SERVER=$(az acr show --name "$ACR_NAME" --query "loginServer" -o tsv) - ACR_ID=$(az acr show --name "$ACR_NAME" --query "id" -o tsv) + ACR_LOGIN_SERVER=$(az acr show --name "$ACR_NAME" --resource-group "$RESOURCE_GROUP" --query "loginServer" -o tsv | tr -d '\r') + ACR_ID=$(_get_acr_id "$ACR_NAME") log_success "Created ACR: $ACR_NAME ($ACR_LOGIN_SERVER)" - - # Assign AcrPull to resource identities assign_acr_pull_roles fi } @@ -334,25 +403,61 @@ resolve_acr() { # ACR Pull Role Assignment # ============================================================================== +_assign_one_role() { + # _assign_one_role