diff --git a/plugin/skills/azure-prepare/references/aspire.md b/plugin/skills/azure-prepare/references/aspire.md index 96361b131..af6c57529 100644 --- a/plugin/skills/azure-prepare/references/aspire.md +++ b/plugin/skills/azure-prepare/references/aspire.md @@ -15,61 +15,53 @@ Guidance for preparing .NET Aspire applications for Azure deployment. .NET Aspire is an opinionated, cloud-ready stack for building observable, production-ready distributed applications. Aspire projects use an AppHost orchestrator to define and configure the application's components, services, and dependencies. -## Detection - -A .NET Aspire project is identified by: - -| Indicator | Description | -|-----------|-------------| -| `*.AppHost.csproj` | AppHost orchestrator project file | -| `Aspire.Hosting` package | Core Aspire hosting package reference | -| `Aspire.Hosting.AppHost` | Alternative Aspire hosting package | - -**Example project structure:** -``` -orleans-voting/ -├── OrleansVoting.sln -├── OrleansVoting.AppHost/ -│ └── OrleansVoting.AppHost.csproj ← AppHost indicator -├── OrleansVoting.Web/ -├── OrleansVoting.Api/ -└── OrleansVoting.Grains/ -``` - ## Azure Preparation Workflow ### Step 1: Detection -When scanning the codebase (per [scan.md](scan.md)), detect Aspire by: +When scanning the codebase (per [scan.md](scan.md)), run [detect-aspire.sh](scripts/detect-aspire.sh)/[detect-aspire.ps1](scripts/detect-aspire.ps1) to determine if this is an Aspire application. Once confirmed, gather the full set of facts by running [gather-aspire-info.sh](scripts/gather-aspire-info.sh)/[gather-aspire-info.ps1](scripts/gather-aspire-info.ps1). It performs the full deterministic detection sequence in one pass and prints `key=value` lines plus a human-readable summary, so you can branch on the result instead of parsing raw `find`/`grep` output. +**bash:** ```bash -# Check for AppHost project -find . -name "*.AppHost.csproj" +./scripts/gather-aspire-info.sh [workspace-root] +``` -# Or check for Aspire.Hosting package reference -grep -r "Aspire.Hosting" . --include="*.csproj" +**PowerShell:** +```powershell +./scripts/gather-aspire-info.ps1 -WorkspaceRoot ``` -### ⛔ Step 1a: Pre-Check for Custom/Non-Deployable Resources (MANDATORY) +`workspace-root` defaults to the current directory. The script reports these fields: -**Before running `azd init --from-code`, scan the AppHost source code to understand whether the app may contain local-only custom resources.** +| Field | Meaning | +|-------|---------| +| `isAspire` | `true` if a `*.AppHost.csproj` or an `Aspire.Hosting` / `Aspire.AppHost.Sdk` package reference was found | +| `appHostPath` | Path to the AppHost project file (empty if none) | +| `appHostDir` | AppHost source directory derived from `appHostPath` | +| `hasExcludeFromManifest` | `true` if `ExcludeFromManifest` appears in AppHost `*.cs` (informational — see Step 1a) | +| `hasFunctions` | `true` if `AddAzureFunctionsProject` appears in AppHost `*.cs` (see Step 4b) | +| `secretStorageConfigured` | `true` if `AzureWebJobsSecretStorageType` is already configured (see Step 4b) | -```bash -# Find the AppHost project and scan only its source directory -APPHOST_PROJECT=$(find . -name "*.AppHost.csproj" | head -1) -APPHOST_DIR=$(dirname "$APPHOST_PROJECT") -grep -r "ExcludeFromManifest" "$APPHOST_DIR" --include="*.cs" | head -20 +**Example output:** ``` - -**PowerShell:** -```powershell -# Find the AppHost project and scan only its source directory -$appHostProject = Get-ChildItem -Recurse -Filter "*.AppHost.csproj" | Select-Object -First 1 -$appHostDir = $appHostProject.DirectoryName -Get-ChildItem -Path $appHostDir -Recurse -Filter "*.cs" | Select-String "ExcludeFromManifest" | Select-Object -First 20 +isAspire=true +appHostPath=./OrleansVoting.AppHost/OrleansVoting.AppHost.csproj +appHostDir=./OrleansVoting.AppHost +hasExcludeFromManifest=false +hasFunctions=true +secretStorageConfigured=false ``` -This scan is informational. `.ExcludeFromManifest()` can appear alongside deployable resources, so a positive match does **not** immediately block deployment. What matters is the final `azure.yaml` output after `azd init --from-code` completes: +If `isAspire=false`, this is not an Aspire app — continue with the normal recipe selection. + +### ⛔ Step 1a: Pre-Check for Custom/Non-Deployable Resources (MANDATORY) + +**Before running `azd init --from-code`, understand whether the app may contain local-only custom resources.** The `gather-aspire-info` run from Step 1 already reports this as the `hasExcludeFromManifest` field — no separate scan is needed. + +- `hasExcludeFromManifest=true` → the AppHost source uses `.ExcludeFromManifest()`. +- `hasExcludeFromManifest=false` → no such usage was found. + +This signal is informational. `.ExcludeFromManifest()` can appear alongside deployable resources, so a positive match does **not** immediately block deployment. What matters is the final `azure.yaml` output after `azd init --from-code` completes: - If `azd init` **fails** with `unsupported resource type` → see Step 2 error guidance below. - If `azd init` **succeeds** but `azure.yaml` has an empty or missing `services` section → see Step 4a below. @@ -185,31 +177,25 @@ This step **MUST** run BEFORE `azd up` or `azd provision`. Skipping it causes a **1. Detect Azure Functions in the AppHost:** +Use the `hasFunctions` field from the Step 1 `gather-aspire-info` run. If you have not run it yet (or the workspace changed), re-run it: + +**bash:** ```bash -APPHOST_DIR=$(dirname "$(find . -name '*.AppHost.csproj' | head -1)") -grep -n "AddAzureFunctionsProject" "$APPHOST_DIR"/*.cs +./scripts/gather-aspire-info.sh [workspace-root] ``` **PowerShell:** ```powershell -$appHostDir = (Get-ChildItem -Recurse -Filter "*.AppHost.csproj" | Select-Object -First 1).DirectoryName -Get-ChildItem -Path $appHostDir -Filter "*.cs" | Select-String "AddAzureFunctionsProject" +./scripts/gather-aspire-info.ps1 -WorkspaceRoot ``` -**If `AddAzureFunctionsProject` is NOT found → skip this step.** +**If `hasFunctions=false` → skip this step.** **2. Check if `AzureWebJobsSecretStorageType` is already configured:** -```bash -grep -n "AzureWebJobsSecretStorageType" "$APPHOST_DIR"/*.cs -``` - -**PowerShell:** -```powershell -Get-ChildItem -Path $appHostDir -Filter "*.cs" | Select-String "AzureWebJobsSecretStorageType" -``` +The same script reports this as the `secretStorageConfigured` field. -**If already present → skip this step.** +**If `secretStorageConfigured=true` → skip this step.** **3. Add the environment variable to the Functions builder chain:** diff --git a/plugin/skills/azure-prepare/references/generate.md b/plugin/skills/azure-prepare/references/generate.md index fb2917fb5..f5bfb48fe 100644 --- a/plugin/skills/azure-prepare/references/generate.md +++ b/plugin/skills/azure-prepare/references/generate.md @@ -4,16 +4,20 @@ Generate infrastructure and configuration files based on selected recipe. ## ⛔ CRITICAL: Check for .NET Aspire Projects FIRST -**MANDATORY: Before generating any files, detect .NET Aspire projects:** +**MANDATORY: Before generating any files, always check for .NET Aspire projects** using the presence script ([detect-aspire.sh](scripts/detect-aspire.sh) / [detect-aspire.ps1](scripts/detect-aspire.ps1)). It reports `isAspire` and `appHostPath`: +**bash:** ```bash -# Method 1: Find AppHost project files -find . -name "*.AppHost.csproj" -o -name "*AppHost.csproj" +./scripts/detect-aspire.sh [workspace-root] +``` -# Method 2: Search for Aspire packages -grep -r "Aspire\.Hosting\|Aspire\.AppHost\.Sdk" . --include="*.csproj" +**PowerShell:** +```powershell +./scripts/detect-aspire.ps1 -WorkspaceRoot ``` +If the result includes `isAspire=true`, treat the project as Aspire. See [aspire.md](aspire.md) Step 1 for gathering the full set of Aspire facts with `gather-aspire-info`. + **If Aspire is detected:** 1. ⛔ **STOP** - Do NOT manually create `azure.yaml` 2. ⛔ **STOP** - Do NOT manually create `infra/` files diff --git a/plugin/skills/azure-prepare/references/recipe-selection.md b/plugin/skills/azure-prepare/references/recipe-selection.md index 2168591a5..a056127c7 100644 --- a/plugin/skills/azure-prepare/references/recipe-selection.md +++ b/plugin/skills/azure-prepare/references/recipe-selection.md @@ -8,7 +8,17 @@ Choose the deployment recipe based on project needs and existing tooling. | Project Type | Detection | Recipe Selection | |--------------|-----------|------------------| -| **.NET Aspire** | `*.AppHost.csproj` or `Aspire.Hosting` package | **AZD (auto via `azd init --from-code`)** → [aspire.md](aspire.md) | +| **.NET Aspire** | run [detect-aspire.sh](scripts/detect-aspire.sh) / [detect-aspire.ps1](scripts/detect-aspire.ps1) | **AZD (auto via `azd init --from-code`)** → [aspire.md](aspire.md) | + +**bash:** +```bash +./scripts/detect-aspire.sh [workspace-root] +``` + +**PowerShell:** +```powershell +./scripts/detect-aspire.ps1 -WorkspaceRoot +``` > 💡 **Tip:** .NET Aspire projects always use AZD recipe with auto-generated configuration. Do not manually select recipe or create artifacts. diff --git a/plugin/skills/azure-prepare/references/recipes/azd/README.md b/plugin/skills/azure-prepare/references/recipes/azd/README.md index 98a7a5ad5..b13d27a80 100644 --- a/plugin/skills/azure-prepare/references/recipes/azd/README.md +++ b/plugin/skills/azure-prepare/references/recipes/azd/README.md @@ -29,7 +29,7 @@ Azure Developer CLI workflow for preparing Azure deployments. | Pattern | Detection | Action | |---------|-----------|--------| -| **.NET Aspire** | `*.AppHost.csproj` or `Aspire.Hosting` package | Use `azd init --from-code -e ` → [aspire.md](../../aspire.md) | +| **.NET Aspire** | run [detect-aspire.sh](../../scripts/detect-aspire.sh) / [detect-aspire.ps1](../../scripts/detect-aspire.ps1) | Use `azd init --from-code -e ` → [aspire.md](../../aspire.md) | | **Existing azure.yaml** | `azure.yaml` present | MODIFY mode - update existing config | | **New project** | No azure.yaml, no special patterns | Manual generation (steps below) | diff --git a/plugin/skills/azure-prepare/references/recipes/azd/aspire.md b/plugin/skills/azure-prepare/references/recipes/azd/aspire.md index caed8ee2c..b5e6747d2 100644 --- a/plugin/skills/azure-prepare/references/recipes/azd/aspire.md +++ b/plugin/skills/azure-prepare/references/recipes/azd/aspire.md @@ -4,11 +4,7 @@ ## Detection -| Indicator | How to Detect | -|-----------|---------------| -| `*.AppHost.csproj` | `find . -name "*.AppHost.csproj"` | -| `Aspire.Hosting` package | `grep -r "Aspire\.Hosting" . --include="*.csproj"` | -| `Aspire.AppHost.Sdk` | `grep -r "Aspire\.AppHost\.Sdk" . --include="*.csproj"` | +Run [detect-aspire.sh](../../scripts/detect-aspire.sh)/[detect-aspire.ps1](../../scripts/detect-aspire.ps1) to definitively determine if this is an Aspire app. If it is, run [gather-aspire-info.sh](../../scripts/gather-aspire-info.sh)/[gather-aspire-info.ps1](../../scripts/gather-aspire-info.ps1) to gather further information that will be needed in this workflow. ## Workflow diff --git a/plugin/skills/azure-prepare/references/scan.md b/plugin/skills/azure-prepare/references/scan.md index 8f36c5af2..effc74570 100644 --- a/plugin/skills/azure-prepare/references/scan.md +++ b/plugin/skills/azure-prepare/references/scan.md @@ -56,16 +56,25 @@ Before classifying components, grep dependency files for SDKs that require a spe ### .NET Aspire Detection -**.NET Aspire projects** are identified by: -- A project ending with `.AppHost.csproj` (e.g., `OrleansVoting.AppHost.csproj`) -- Reference to `Aspire.Hosting` or `Aspire.Hosting.AppHost` package in .csproj files -- Multiple .NET projects in a solution, typically including an AppHost orchestrator +_Always_ check **.NET Aspire projects** by running ([detect-aspire.sh](scripts/detect-aspire.sh) / [detect-aspire.ps1](scripts/detect-aspire.ps1)), which reports just `isAspire` and `appHostPath` so you can branch quickly. Once Aspire is confirmed, [aspire.md](aspire.md) gathers the deeper facts (`ExcludeFromManifest`, Azure Functions signals) via `gather-aspire-info`: + +**bash:** +```bash +./scripts/detect-aspire.sh [workspace-root] +``` + +**PowerShell:** +```powershell +./scripts/detect-aspire.ps1 -WorkspaceRoot +``` + +A project is Aspire when the script returns `isAspire=true` (a `*.AppHost.csproj` or an `Aspire.Hosting` / `Aspire.Hosting.AppHost` / `Aspire.AppHost.Sdk` package reference was found). See [aspire.md](aspire.md) Step 1 for the full field list gathered by `gather-aspire-info`. **When Aspire is detected:** - Use `azd init --from-code -e ` instead of manual azure.yaml creation - The `--from-code` flag automatically detects the AppHost and generates appropriate configuration - The `-e` flag is **required** for non-interactive environments (agents, CI/CD) -- ⚠️ **CRITICAL:** If the AppHost contains `AddAzureFunctionsProject`, you **MUST** add `.WithEnvironment("AzureWebJobsSecretStorageType", "Files")` to the Functions builder chain BEFORE deployment. Without this, Functions will fail at startup with `Secret initialization from Blob storage failed`. See [aspire.md](aspire.md) Step 4b for the complete detection and fix procedure. +- ⚠️ **CRITICAL:** Run `gather-aspire-info` (per [aspire.md](aspire.md) Step 1). If it reports `hasFunctions=true` and `secretStorageConfigured=false`, you **MUST** add `.WithEnvironment("AzureWebJobsSecretStorageType", "Files")` to the Functions builder chain BEFORE deployment. Without this, Functions will fail at startup with `Secret initialization from Blob storage failed`. See [aspire.md](aspire.md) Step 4b for the complete fix procedure. - See [aspire.md](aspire.md) for detailed Aspire-specific guidance ## Output diff --git a/plugin/skills/azure-prepare/references/scripts/detect-aspire.ps1 b/plugin/skills/azure-prepare/references/scripts/detect-aspire.ps1 new file mode 100644 index 000000000..2a020bbeb --- /dev/null +++ b/plugin/skills/azure-prepare/references/scripts/detect-aspire.ps1 @@ -0,0 +1,91 @@ +<# +.SYNOPSIS + Presence check: determines whether a workspace is a .NET Aspire application. + Use this at detection/routing points where you only need a yes/no answer. + To gather the deeper deployment facts (ExcludeFromManifest, Azure Functions, + secret storage, AppHost source dir), use gather-aspire-info.ps1 instead. + +.DESCRIPTION + Runs the minimal deterministic presence sequence: + 1. Find the AppHost project (*.AppHost.csproj) + 2. Confirm Aspire.Hosting or Aspire.AppHost.Sdk package references + + Output: key=value lines (isAspire, appHostPath) followed by a short + human-readable summary. + +.PARAMETER WorkspaceRoot + Workspace root directory to scan. Defaults to the current directory. + +.EXAMPLE + ./detect-aspire.ps1 + Scan the current directory. + +.EXAMPLE + ./detect-aspire.ps1 -WorkspaceRoot ./src/MyApp + Scan a specific workspace root. +#> + +param( + [string]$WorkspaceRoot = "." +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $WorkspaceRoot -PathType Container)) { + Write-Error "workspace root '$WorkspaceRoot' is not a directory" + exit 1 +} + +# Defaults (emitted when the workspace is not an Aspire app) +$isAspire = $false +$appHostPath = "" + +# Step 1: Find the AppHost project (sorted for deterministic selection) +$appHostProject = @(Get-ChildItem -LiteralPath $WorkspaceRoot -Recurse -Filter "*.AppHost.csproj" -File -ErrorAction SilentlyContinue | + Sort-Object FullName) | Select-Object -First 1 + +# Step 2: Confirm Aspire package references anywhere in the workspace +$hasAspirePackage = $false +$csprojFiles = Get-ChildItem -LiteralPath $WorkspaceRoot -Recurse -Filter "*.csproj" -File -ErrorAction SilentlyContinue +if ($csprojFiles) { + if ($csprojFiles | Select-String -Pattern "Aspire\.Hosting|Aspire\.AppHost\.Sdk" -List -ErrorAction SilentlyContinue) { + $hasAspirePackage = $true + } +} + +if ($appHostProject -or $hasAspirePackage) { + $isAspire = $true +} + +if ($appHostProject) { + # Emit a workspace-relative, forward-slash path (matches the bash output contract) + Push-Location -LiteralPath $WorkspaceRoot + try { + $appHostPath = (Resolve-Path -LiteralPath $appHostProject.FullName -Relative) -replace '\\', '/' + } finally { + Pop-Location + } +} + +function ConvertTo-Lower([bool]$value) { + if ($value) { "true" } else { "false" } +} + +# Machine-readable result +Write-Output "isAspire=$(ConvertTo-Lower $isAspire)" +Write-Output "appHostPath=$appHostPath" + +# Human-readable summary +Write-Output "" +Write-Output "Summary:" +if (-not $isAspire) { + Write-Output "- No .NET Aspire app detected in '$WorkspaceRoot' (no *.AppHost.csproj or Aspire.Hosting / Aspire.AppHost.Sdk package reference)." + exit 0 +} + +if ($appHostPath) { + Write-Output "- .NET Aspire app detected. AppHost project: $appHostPath" +} else { + Write-Output "- Aspire.Hosting / Aspire.AppHost.Sdk package reference found, but no *.AppHost.csproj was located." +} +Write-Output "- Run gather-aspire-info.ps1 to gather other essential deployment information." diff --git a/plugin/skills/azure-prepare/references/scripts/detect-aspire.sh b/plugin/skills/azure-prepare/references/scripts/detect-aspire.sh new file mode 100644 index 000000000..b30046ff6 --- /dev/null +++ b/plugin/skills/azure-prepare/references/scripts/detect-aspire.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# detect-aspire.sh +# Presence check: determines whether a workspace is a .NET Aspire application. +# Use this at detection/routing points where you only need a yes/no answer. +# To gather the deeper deployment facts (ExcludeFromManifest, Azure Functions, +# secret storage, AppHost source dir), use gather-aspire-info.sh instead. +# +# It runs the minimal deterministic presence sequence: +# 1. Find the AppHost project (*.AppHost.csproj) +# 2. Confirm Aspire.Hosting or Aspire.AppHost.Sdk package references +# +# Output: key=value lines (isAspire, appHostPath) followed by a short +# human-readable summary. +# +# Usage: +# ./detect-aspire.sh [workspace-root] +# +# Examples: +# ./detect-aspire.sh # Scan the current directory +# ./detect-aspire.sh ./src/MyApp # Scan a specific workspace root + +set -euo pipefail + +WORKSPACE_ROOT="${1:-.}" + +if [ ! -d "$WORKSPACE_ROOT" ]; then + echo "Error: workspace root '$WORKSPACE_ROOT' is not a directory" >&2 + exit 1 +fi + +# Strip the workspace-root prefix and emit a "./"-prefixed, forward-slash +# workspace-relative path. Keeps output identical across shells/platforms. +to_relative() { + local p="$1" + case "$p" in + "$WORKSPACE_ROOT"/*) p="${p#"$WORKSPACE_ROOT"/}" ;; + ./*) p="${p#./}" ;; + esac + printf './%s' "$p" +} + +# Portable recursive match over *.csproj files (BSD/macOS-safe: no `grep --include`). +# Returns 0 if the extended-regex pattern is found in any *.csproj under the directory. +csproj_match() { + local dir="$1" pattern="$2" + [ -n "$(find "$dir" -type f -name '*.csproj' -exec grep -lE "$pattern" {} + 2>/dev/null)" ] +} + +# Defaults (emitted when the workspace is not an Aspire app) +IS_ASPIRE="false" +APPHOST_PATH="" + +# Step 1: Find the AppHost project (case-insensitive sort for deterministic, +# cross-shell-consistent selection) +APPHOST_RAW=$(find "$WORKSPACE_ROOT" -type f -name "*.AppHost.csproj" 2>/dev/null | sort -f | head -1 || true) + +# Step 2: Confirm Aspire package references anywhere in the workspace +HAS_ASPIRE_PACKAGE="false" +if csproj_match "$WORKSPACE_ROOT" "Aspire\.Hosting|Aspire\.AppHost\.Sdk"; then + HAS_ASPIRE_PACKAGE="true" +fi + +if [ -n "$APPHOST_RAW" ] || [ "$HAS_ASPIRE_PACKAGE" = "true" ]; then + IS_ASPIRE="true" +fi + +if [ -n "$APPHOST_RAW" ]; then + APPHOST_PATH=$(to_relative "$APPHOST_RAW") +fi + +# Machine-readable result +echo "isAspire=$IS_ASPIRE" +echo "appHostPath=$APPHOST_PATH" + +# Human-readable summary +echo "" +echo "Summary:" +if [ "$IS_ASPIRE" != "true" ]; then + echo "- No .NET Aspire app detected in '$WORKSPACE_ROOT' (no *.AppHost.csproj or Aspire.Hosting / Aspire.AppHost.Sdk package reference)." + exit 0 +fi + +if [ -n "$APPHOST_PATH" ]; then + echo "- .NET Aspire app detected. AppHost project: $APPHOST_PATH" +else + echo "- Aspire.Hosting / Aspire.AppHost.Sdk package reference found, but no *.AppHost.csproj was located." +fi +echo "- Run gather-aspire-info.sh to gather other essential deployment information." diff --git a/plugin/skills/azure-prepare/references/scripts/gather-aspire-info.ps1 b/plugin/skills/azure-prepare/references/scripts/gather-aspire-info.ps1 new file mode 100644 index 000000000..740f64dd1 --- /dev/null +++ b/plugin/skills/azure-prepare/references/scripts/gather-aspire-info.ps1 @@ -0,0 +1,140 @@ +<# +.SYNOPSIS + Gathers the detailed facts the azure-prepare skill needs to plan deployment + of a .NET Aspire application. Use this in Aspire-specific reference files, + AFTER presence has been established (see detect-aspire.ps1). + +.DESCRIPTION + Runs the full deterministic detection sequence in one pass: + 1. Find the AppHost project (*.AppHost.csproj) + 2. Confirm Aspire.Hosting or Aspire.AppHost.Sdk package references + 3. Derive the AppHost source directory + 4. Scan the AppHost *.cs for ExcludeFromManifest (informational) + 5. Scan for AddAzureFunctionsProject, and if present, check whether + AzureWebJobsSecretStorageType is already configured + + Output: key=value lines the agent can branch on, followed by a + human-readable summary. The remediation decision (whether/how to add + .WithEnvironment("AzureWebJobsSecretStorageType", "Files")) stays with the agent. + +.PARAMETER WorkspaceRoot + Workspace root directory to scan. Defaults to the current directory. + +.EXAMPLE + ./gather-aspire-info.ps1 + Scan the current directory. + +.EXAMPLE + ./gather-aspire-info.ps1 -WorkspaceRoot ./src/MyApp + Scan a specific workspace root. +#> + +param( + [string]$WorkspaceRoot = "." +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $WorkspaceRoot -PathType Container)) { + Write-Error "workspace root '$WorkspaceRoot' is not a directory" + exit 1 +} + +# Defaults (emitted when the workspace is not an Aspire app) +$isAspire = $false +$appHostPath = "" +$appHostDir = "" +$hasExcludeFromManifest = $false +$hasFunctions = $false +$secretStorageConfigured = $false + +# Step 1: Find the AppHost project (sorted for deterministic selection) +$appHostProject = @(Get-ChildItem -LiteralPath $WorkspaceRoot -Recurse -Filter "*.AppHost.csproj" -File -ErrorAction SilentlyContinue | + Sort-Object FullName) | Select-Object -First 1 + +# Step 2: Confirm Aspire package references anywhere in the workspace +$hasAspirePackage = $false +$csprojFiles = Get-ChildItem -LiteralPath $WorkspaceRoot -Recurse -Filter "*.csproj" -File -ErrorAction SilentlyContinue +if ($csprojFiles) { + if ($csprojFiles | Select-String -Pattern "Aspire\.Hosting|Aspire\.AppHost\.Sdk" -List -ErrorAction SilentlyContinue) { + $hasAspirePackage = $true + } +} + +if ($appHostProject -or $hasAspirePackage) { + $isAspire = $true +} + +if ($appHostProject) { + # Emit a workspace-relative, forward-slash path (matches the bash output contract) + Push-Location -LiteralPath $WorkspaceRoot + try { + $appHostPath = (Resolve-Path -LiteralPath $appHostProject.FullName -Relative) -replace '\\', '/' + } finally { + Pop-Location + } + + # Step 3: Derive the AppHost source directory + $appHostDir = $appHostPath -replace '/[^/]+$', '' + $appHostDirFull = $appHostProject.DirectoryName + + # Scan AppHost *.cs, excluding bin/ and obj/ build output + $appHostCs = Get-ChildItem -LiteralPath $appHostDirFull -Recurse -Filter "*.cs" -File -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -notmatch '[\\/](bin|obj)[\\/]' } + + # Step 4: Scan the AppHost source for ExcludeFromManifest (informational) + if ($appHostCs -and ($appHostCs | Select-String -Pattern "ExcludeFromManifest" -SimpleMatch -List -ErrorAction SilentlyContinue)) { + $hasExcludeFromManifest = $true + } + + # Step 5: Detect Azure Functions and secret-storage configuration + if ($appHostCs -and ($appHostCs | Select-String -Pattern "AddAzureFunctionsProject" -SimpleMatch -List -ErrorAction SilentlyContinue)) { + $hasFunctions = $true + if ($appHostCs | Select-String -Pattern "AzureWebJobsSecretStorageType" -SimpleMatch -List -ErrorAction SilentlyContinue) { + $secretStorageConfigured = $true + } + } +} + +function ConvertTo-Lower([bool]$value) { + if ($value) { "true" } else { "false" } +} + +# Machine-readable result +Write-Output "isAspire=$(ConvertTo-Lower $isAspire)" +Write-Output "appHostPath=$appHostPath" +Write-Output "appHostDir=$appHostDir" +Write-Output "hasExcludeFromManifest=$(ConvertTo-Lower $hasExcludeFromManifest)" +Write-Output "hasFunctions=$(ConvertTo-Lower $hasFunctions)" +Write-Output "secretStorageConfigured=$(ConvertTo-Lower $secretStorageConfigured)" + +# Human-readable summary +Write-Output "" +Write-Output "Summary:" +if (-not $isAspire) { + Write-Output "- No .NET Aspire app detected in '$WorkspaceRoot' (no *.AppHost.csproj or Aspire.Hosting / Aspire.AppHost.Sdk package reference)." + exit 0 +} + +if ($appHostPath) { + Write-Output "- .NET Aspire app detected. AppHost project: $appHostPath" + Write-Output "- AppHost source directory: $appHostDir" +} else { + Write-Output "- Aspire.Hosting / Aspire.AppHost.Sdk package reference found, but no *.AppHost.csproj was located." +} + +if ($hasExcludeFromManifest) { + Write-Output "- ExcludeFromManifest found in AppHost source (informational): the app may contain local-only resources." +} else { + Write-Output "- No ExcludeFromManifest usage found in AppHost source." +} + +if ($hasFunctions) { + if ($secretStorageConfigured) { + Write-Output "- AddAzureFunctionsProject found; AzureWebJobsSecretStorageType is configured in the AppHost source." + } else { + Write-Output "- AddAzureFunctionsProject found; AzureWebJobsSecretStorageType is not configured in the AppHost source." + } +} else { + Write-Output "- AddAzureFunctionsProject not found in AppHost source." +} diff --git a/plugin/skills/azure-prepare/references/scripts/gather-aspire-info.sh b/plugin/skills/azure-prepare/references/scripts/gather-aspire-info.sh new file mode 100644 index 000000000..5a5bfdd46 --- /dev/null +++ b/plugin/skills/azure-prepare/references/scripts/gather-aspire-info.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# gather-aspire-info.sh +# Gathers the detailed facts the azure-prepare skill needs to plan deployment of +# a .NET Aspire application. Use this in Aspire-specific reference files, AFTER +# presence has been established (see detect-aspire.sh). +# +# It runs the full deterministic detection sequence in one pass: +# 1. Find the AppHost project (*.AppHost.csproj) +# 2. Confirm Aspire.Hosting or Aspire.AppHost.Sdk package references +# 3. Derive the AppHost source directory +# 4. Scan the AppHost *.cs for ExcludeFromManifest (informational) +# 5. Scan for AddAzureFunctionsProject, and if present, check whether +# AzureWebJobsSecretStorageType is already configured +# +# Output: key=value lines the agent can branch on, followed by a human-readable +# summary. The remediation decision (whether/how to add +# .WithEnvironment("AzureWebJobsSecretStorageType", "Files")) stays with the agent. +# +# Usage: +# ./gather-aspire-info.sh [workspace-root] +# +# Examples: +# ./gather-aspire-info.sh # Scan the current directory +# ./gather-aspire-info.sh ./src/MyApp # Scan a specific workspace root + +set -euo pipefail + +WORKSPACE_ROOT="${1:-.}" + +if [ ! -d "$WORKSPACE_ROOT" ]; then + echo "Error: workspace root '$WORKSPACE_ROOT' is not a directory" >&2 + exit 1 +fi + +# Strip the workspace-root prefix and emit a "./"-prefixed, forward-slash +# workspace-relative path. Keeps output identical across shells/platforms. +to_relative() { + local p="$1" + case "$p" in + "$WORKSPACE_ROOT"/*) p="${p#"$WORKSPACE_ROOT"/}" ;; + ./*) p="${p#./}" ;; + esac + printf './%s' "$p" +} + +# Portable recursive match over *.csproj files (BSD/macOS-safe: no `grep --include`). +# Returns 0 if the extended-regex pattern is found in any *.csproj under the directory. +csproj_match() { + local dir="$1" pattern="$2" + [ -n "$(find "$dir" -type f -name '*.csproj' -exec grep -lE "$pattern" {} + 2>/dev/null)" ] +} + +# Portable recursive match over *.cs files, pruning bin/ and obj/ build output. +# Returns 0 if the extended-regex pattern is found in any *.cs under the directory. +cs_match() { + local dir="$1" pattern="$2" + [ -n "$(find "$dir" -type d \( -name bin -o -name obj \) -prune -o \ + -type f -name '*.cs' -exec grep -lE "$pattern" {} + 2>/dev/null)" ] +} + +# Defaults (emitted when the workspace is not an Aspire app) +IS_ASPIRE="false" +APPHOST_PATH="" +APPHOST_DIR="" +HAS_EXCLUDE_FROM_MANIFEST="false" +HAS_FUNCTIONS="false" +SECRET_STORAGE_CONFIGURED="false" + +# Step 1: Find the AppHost project (case-insensitive sort for deterministic, +# cross-shell-consistent selection) +APPHOST_RAW=$(find "$WORKSPACE_ROOT" -type f -name "*.AppHost.csproj" 2>/dev/null | sort -f | head -1 || true) + +# Step 2: Confirm Aspire package references anywhere in the workspace +HAS_ASPIRE_PACKAGE="false" +if csproj_match "$WORKSPACE_ROOT" "Aspire\.Hosting|Aspire\.AppHost\.Sdk"; then + HAS_ASPIRE_PACKAGE="true" +fi + +if [ -n "$APPHOST_RAW" ] || [ "$HAS_ASPIRE_PACKAGE" = "true" ]; then + IS_ASPIRE="true" +fi + +if [ -n "$APPHOST_RAW" ]; then + APPHOST_PATH=$(to_relative "$APPHOST_RAW") + + # Step 3: Derive the AppHost source directory (scan the real path on disk) + APPHOST_DIR=$(dirname "$APPHOST_PATH") + APPHOST_DIR_RAW=$(dirname "$APPHOST_RAW") + + # Step 4: Scan the AppHost source for ExcludeFromManifest (informational) + if cs_match "$APPHOST_DIR_RAW" "ExcludeFromManifest"; then + HAS_EXCLUDE_FROM_MANIFEST="true" + fi + + # Step 5: Detect Azure Functions and secret-storage configuration + if cs_match "$APPHOST_DIR_RAW" "AddAzureFunctionsProject"; then + HAS_FUNCTIONS="true" + if cs_match "$APPHOST_DIR_RAW" "AzureWebJobsSecretStorageType"; then + SECRET_STORAGE_CONFIGURED="true" + fi + fi +fi + +# Machine-readable result +echo "isAspire=$IS_ASPIRE" +echo "appHostPath=$APPHOST_PATH" +echo "appHostDir=$APPHOST_DIR" +echo "hasExcludeFromManifest=$HAS_EXCLUDE_FROM_MANIFEST" +echo "hasFunctions=$HAS_FUNCTIONS" +echo "secretStorageConfigured=$SECRET_STORAGE_CONFIGURED" + +# Human-readable summary +echo "" +echo "Summary:" +if [ "$IS_ASPIRE" != "true" ]; then + echo "- No .NET Aspire app detected in '$WORKSPACE_ROOT' (no *.AppHost.csproj or Aspire.Hosting / Aspire.AppHost.Sdk package reference)." + exit 0 +fi + +if [ -n "$APPHOST_PATH" ]; then + echo "- .NET Aspire app detected. AppHost project: $APPHOST_PATH" + echo "- AppHost source directory: $APPHOST_DIR" +else + echo "- Aspire.Hosting / Aspire.AppHost.Sdk package reference found, but no *.AppHost.csproj was located." +fi + +if [ "$HAS_EXCLUDE_FROM_MANIFEST" = "true" ]; then + echo "- ExcludeFromManifest found in AppHost source (informational): the app may contain local-only resources." +else + echo "- No ExcludeFromManifest usage found in AppHost source." +fi + +if [ "$HAS_FUNCTIONS" = "true" ]; then + if [ "$SECRET_STORAGE_CONFIGURED" = "true" ]; then + echo "- AddAzureFunctionsProject found; AzureWebJobsSecretStorageType is configured in the AppHost source." + else + echo "- AddAzureFunctionsProject found; AzureWebJobsSecretStorageType is not configured in the AppHost source." + fi +else + echo "- AddAzureFunctionsProject not found in AppHost source." +fi diff --git a/plugin/skills/azure-prepare/references/services/functions/aspire-containerapps.md b/plugin/skills/azure-prepare/references/services/functions/aspire-containerapps.md index 3eef3daa1..2005f7153 100644 --- a/plugin/skills/azure-prepare/references/services/functions/aspire-containerapps.md +++ b/plugin/skills/azure-prepare/references/services/functions/aspire-containerapps.md @@ -4,6 +4,8 @@ When .NET Aspire deploys Azure Functions via `azd`, Functions run as containeriz > ⚠️ **Critical:** When Azure Functions use identity-based storage (e.g., `AzureWebJobsStorage__blobServiceUri`), you **must** set `AzureWebJobsSecretStorageType=Files`. +> 📋 **Confirm with `gather-aspire-info`:** Run [gather-aspire-info.sh](../../scripts/gather-aspire-info.sh) / [gather-aspire-info.ps1](../../scripts/gather-aspire-info.ps1). If it reports `hasFunctions=true` and `secretStorageConfigured=false`, apply the configuration below before `azd up`. See [aspire.md](../../aspire.md) Step 4b for the complete procedure. + ## Proactive Configuration in AppHost **Best Practice:** Add this setting in your AppHost BEFORE running `azd up`: diff --git a/tests/azure-prepare/integration.test.ts b/tests/azure-prepare/integration.test.ts index 3aa21c8b8..23b69bba6 100644 --- a/tests/azure-prepare/integration.test.ts +++ b/tests/azure-prepare/integration.test.ts @@ -17,7 +17,7 @@ import { import { hasValidationCommand } from "../azure-validate/utils"; import { hasPlanReadyForValidation, hasServicesSection, getServiceProject } from "./utils"; import { cloneRepo } from "../utils/git-clone"; -import { doesWorkspaceFileIncludePattern, expectFiles, softCheckSkill, isSkillInvoked, shouldEarlyTerminateForSkillInvocation, withTestResult } from "../utils/evaluate"; +import { doesWorkspaceFileIncludePattern, expectFiles, softCheckSkill, isSkillInvoked, matchesCommand, shouldEarlyTerminateForSkillInvocation, withTestResult } from "../utils/evaluate"; const SKILL_NAME = "azure-prepare"; const RUNS_PER_PROMPT = 1; @@ -855,6 +855,10 @@ describeIntegration(`${SKILL_NAME}_ - Integration Tests`, () => { expect(workspacePath).toBeDefined(); expect(isSkillInvoked(agentMetadata, SKILL_NAME)).toBe(true); + // Verify the agent ran the detect-aspire / gather-aspire-info script + // rather than re-deriving the find/grep detection sequence inline. + expect(matchesCommand(agentMetadata, /(detect-aspire|gather-aspire-info)\.(sh|ps1)/)).toBe(true); + // Verify azure.yaml exists expectFiles(workspacePath!, [/azure\.yaml$/], []); @@ -900,6 +904,11 @@ describeIntegration(`${SKILL_NAME}_ - Integration Tests`, () => { expect(workspacePath).toBeDefined(); expect(isSkillInvoked(agentMetadata, SKILL_NAME)).toBe(true); + + // Verify the agent ran the detect-aspire / gather-aspire-info script + // rather than re-deriving the find/grep detection sequence inline. + expect(matchesCommand(agentMetadata, /(detect-aspire|gather-aspire-info)\.(sh|ps1)/)).toBe(true); + expectFiles(workspacePath!, [/azure\.yaml$/], []); // For Aspire projects, azd init --from-code generates a single "app" service