diff --git a/Directory.Packages.props b/Directory.Packages.props index 8ee92baad8a..2f1eca40444 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -137,14 +137,15 @@ - - + + - + + diff --git a/eng/Signing.props b/eng/Signing.props index 4de73bac6a3..54e9749407c 100644 --- a/eng/Signing.props +++ b/eng/Signing.props @@ -57,8 +57,11 @@ + + + @@ -73,6 +76,11 @@ + + + + + 13 2 - 2 + 4 $(MajorVersion).$(MinorVersion).$(PatchVersion) preview.1 net8.0 @@ -50,11 +50,11 @@ 10.2.0 10.2.0 - 1.15.0 - 1.15.0 - 1.15.0 - 1.15.0 - 1.15.0 + 1.15.2 + 1.15.1 + 1.15.3 + 1.15.1 + 1.15.3 1.5.0 2.23.32-alpha diff --git a/eng/aspire-managed-entitlements.plist b/eng/aspire-managed-entitlements.plist new file mode 100644 index 00000000000..fde7a18e473 --- /dev/null +++ b/eng/aspire-managed-entitlements.plist @@ -0,0 +1,18 @@ + + + + + + com.apple.security.cs.allow-jit + + + com.apple.security.cs.allow-unsigned-executable-memory + + + com.apple.security.cs.disable-library-validation + + + com.apple.security.cs.allow-dyld-environment-variables + + + diff --git a/eng/pipelines/templates/BuildAndTest.yml b/eng/pipelines/templates/BuildAndTest.yml index 5f3bf630398..2e3609079fa 100644 --- a/eng/pipelines/templates/BuildAndTest.yml +++ b/eng/pipelines/templates/BuildAndTest.yml @@ -41,19 +41,42 @@ parameters: steps: # Internal pipeline: Build with pack+sign - ${{ if ne(parameters.runAsPublic, 'true') }}: - # Build bundle payload (aspire-managed) for each target RID before the main build + # Build, publish, and sign aspire-managed for each target RID before the main build. + # This ensures aspire-managed is signed before CreateLayout packs it into the bundle archive. + # Step 1: dotnet publish produces the self-contained single-file binary. + # Step 2: build.cmd -sign runs Arcade's signing, which picks up the binary via ItemsToSign in Signing.props. + # Step 3: Bundle.proj creates the bundle layout and archive using the signed binary. - ${{ each targetRid in parameters.targetRids }}: + - script: ${{ parameters.dotnetScript }} + publish + $(Build.SourcesDirectory)/src/Aspire.Managed/Aspire.Managed.csproj + -c ${{ parameters.buildConfig }} + -r ${{ targetRid }} + --self-contained + /p:GenerateDocumentationFile=false + /p:EnforceCodeStyleInBuild=false + $(_OfficialBuildIdArgs) + /bl:${{ parameters.repoLogPath }}/PublishManaged-${{ targetRid }}.binlog + displayName: 🟣Publish aspire-managed (${{ targetRid }}) + + - script: ${{ parameters.buildScript }} + -restore -sign $(_SignArgs) + -configuration ${{ parameters.buildConfig }} + $(_OfficialBuildIdArgs) + -projects $(Build.SourcesDirectory)/src/Aspire.Managed/Aspire.Managed.csproj + /bl:${{ parameters.repoLogPath }}/SignManaged-${{ targetRid }}.binlog + displayName: 🟣Sign aspire-managed (${{ targetRid }}) + - script: ${{ parameters.dotnetScript }} msbuild $(Build.SourcesDirectory)/eng/Bundle.proj - /restore + "/t:_RestoreDcpPackage;_RunCreateLayout" /p:Configuration=${{ parameters.buildConfig }} /p:TargetRid=${{ targetRid }} /p:BundleVersion=ci-bundlepayload - /p:SkipNativeBuild=true /p:ContinuousIntegrationBuild=true - /bl:${{ parameters.repoLogPath }}/BundlePayload-${{ targetRid }}.binlog - displayName: 🟣Build bundle payload (${{ targetRid }}) + /bl:${{ parameters.repoLogPath }}/BundleLayout-${{ targetRid }}.binlog + displayName: 🟣Create bundle layout (${{ targetRid }}) - script: ${{ parameters.buildScript }} -restore -build @@ -69,6 +92,11 @@ steps: /p:BuildExtension=true displayName: 🟣Build + # NOTE: The 🟣Verify CLI archive (win-x64) step has been temporarily disabled on release/13.2. + # It is hanging/timing out on Windows agents similarly to the osx-arm64 verification step that + # was previously disabled. Tracking issue: re-enable once the hang on hosted agents is resolved. + + # Log MicroBuild environment for debugging # MicroBuildOutputFolderOverride is set by the MicroBuildSigningPlugin task in eng/common/templates-official/job/onelocbuild.yml # which is installed via the Arcade SDK's install-microbuild.yml template that runs before our build steps. diff --git a/eng/pipelines/templates/build_sign_native.yml b/eng/pipelines/templates/build_sign_native.yml index 7ee70558dae..8523f512248 100644 --- a/eng/pipelines/templates/build_sign_native.yml +++ b/eng/pipelines/templates/build_sign_native.yml @@ -22,7 +22,7 @@ jobs: jobs: - job: BuildNative_${{ replace(targetRid, '-', '_') }} displayName: ${{ replace(targetRid, '-', '_') }} - timeoutInMinutes: 40 + timeoutInMinutes: 60 variables: - TeamName: ${{ parameters.teamName }} @@ -63,18 +63,67 @@ jobs: displayName: 🟣Restore steps: + # Build, publish, and sign aspire-managed before creating the bundle layout. + # This ensures aspire-managed is signed before CreateLayout packs it into the bundle archive. + # Step 1: dotnet publish produces the self-contained single-file binary. + # Step 2: build.sh --sign runs Arcade's signing (only when codeSign is enabled). + # Step 3: Bundle.proj creates the bundle layout and archive using the signed binary. + - script: >- + $(Build.SourcesDirectory)/$(dotnetScript) + publish + $(Build.SourcesDirectory)/src/Aspire.Managed/Aspire.Managed.csproj + -c $(_BuildConfig) + -r ${{ targetRid }} + --self-contained + /p:GenerateDocumentationFile=false + /p:EnforceCodeStyleInBuild=false + ${{ parameters.extraBuildArgs }} + /bl:$(Build.Arcade.LogsPath)PublishManaged.binlog + displayName: 🟣Publish aspire-managed + + # On macOS, ad-hoc codesign aspire-managed with JIT entitlements BEFORE Arcade signing. + # MicroBuild (MacDeveloperHardenWithNotarization) preserves entitlements from the prior + # ad-hoc signature when re-signing with the real certificate. Without this step, + # hardened runtime blocks CoreCLR JIT (W^X memory mapping) causing HRESULT: 0x80070008. + # This follows the same pattern used by dotnet/sdk for managed binaries (roslyn-entitlements.plist). + - ${{ if eq(parameters.agentOs, 'macos') }}: + - script: >- + codesign --sign - --force + --entitlements $(Build.SourcesDirectory)/eng/aspire-managed-entitlements.plist + $(Build.SourcesDirectory)/artifacts/bin/Aspire.Managed/Release/net10.0/${{ targetRid }}/publish/aspire-managed + displayName: 🟣Ad-hoc codesign aspire-managed with JIT entitlements + + - ${{ if eq(parameters.codeSign, true) }}: + - script: >- + $(Build.SourcesDirectory)/$(scriptName) + --restore --sign + ${{ parameters.extraBuildArgs }} + -projects $(Build.SourcesDirectory)/src/Aspire.Managed/Aspire.Managed.csproj + /bl:$(Build.Arcade.LogsPath)SignManaged.binlog + displayName: 🟣Sign aspire-managed + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + + # On macOS/Linux, restore the execute bit after Arcade signing. + # MicroBuild rewrites the binary file during signing, which resets permissions + # to the default umask (typically 644). The execute bit must be restored before + # CreateLayout packs the binary into the bundle archive. + - ${{ if and(eq(parameters.codeSign, true), ne(parameters.agentOs, 'windows')) }}: + - script: >- + chmod +x $(Build.SourcesDirectory)/artifacts/bin/Aspire.Managed/Release/net10.0/${{ targetRid }}/publish/aspire-managed + displayName: 🟣Restore execute permission on aspire-managed + - script: >- $(Build.SourcesDirectory)/$(dotnetScript) msbuild $(Build.SourcesDirectory)/eng/Bundle.proj - /restore + "/t:_RestoreDcpPackage;_RunCreateLayout" /p:Configuration=$(_BuildConfig) /p:TargetRid=${{ targetRid }} /p:BundleVersion=ci-bundlepayload - /p:SkipNativeBuild=true /p:ContinuousIntegrationBuild=true - /bl:$(Build.Arcade.LogsPath)BundlePayload.binlog - displayName: 🟣Build bundle payload + /bl:$(Build.Arcade.LogsPath)BundleLayout.binlog + displayName: 🟣Create bundle layout - script: >- $(Build.SourcesDirectory)/$(scriptName) @@ -91,6 +140,11 @@ jobs: env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) # Needed for the signing task + # NOTE: The 🟣Verify CLI archive step has been temporarily disabled on release/13.2. + # On osx-arm64 it hangs at "🔐 Trusting certificates..." during `aspire new`, + # consuming the entire 60 minute job timeout. Tracking issue: re-enable once the + # dev-cert trust hang on macOS hosted agents is resolved. + - task: 1ES.PublishBuildArtifacts@1 displayName: 🟣Publish Artifacts condition: always() diff --git a/eng/scripts/verify-cli-archive.ps1 b/eng/scripts/verify-cli-archive.ps1 new file mode 100644 index 00000000000..69699c45f10 --- /dev/null +++ b/eng/scripts/verify-cli-archive.ps1 @@ -0,0 +1,171 @@ +<# +.SYNOPSIS + Verify that a signed Aspire CLI archive produces a working binary. + +.DESCRIPTION + This script: + 1. Cleans ~/.aspire to ensure no stale state + 2. Extracts the CLI archive to a temp location + 3. Runs 'aspire --version' to validate the binary executes + 4. Runs 'aspire new aspire-starter' to test bundle self-extraction + project creation + 5. Cleans up temp directories + +.PARAMETER ArchivePath + Path to the CLI archive (.zip or .tar.gz) + +.EXAMPLE + .\verify-cli-archive.ps1 -ArchivePath "artifacts\packages\Release\Shipping\aspire-cli-win-x64-10.0.0.zip" +#> + +param( + [Parameter(Mandatory = $true, Position = 0)] + [string]$ArchivePath +) + +$ErrorActionPreference = 'Stop' + +function Write-Step { param([string]$msg) Write-Host "▶ $msg" -ForegroundColor Cyan } +function Write-Ok { param([string]$msg) Write-Host "✅ $msg" -ForegroundColor Green } +function Write-Err { param([string]$msg) Write-Host "❌ $msg" -ForegroundColor Red } + +$verifyTmpDir = $null +$aspireBackup = $null + +function Invoke-Cleanup { + if ($verifyTmpDir -and (Test-Path $verifyTmpDir)) { + Write-Step "Cleaning up temp directory: $verifyTmpDir" + Remove-Item -Recurse -Force $verifyTmpDir -ErrorAction SilentlyContinue + } + # Restore ~/.aspire if we backed it up + $aspireDir = Join-Path $env:USERPROFILE ".aspire" + if ($aspireBackup -and (Test-Path $aspireBackup)) { + if (Test-Path $aspireDir) { + Remove-Item -Recurse -Force $aspireDir -ErrorAction SilentlyContinue + } + Move-Item $aspireBackup $aspireDir + Write-Step "Restored original ~/.aspire" + } +} + +try { + # Validate archive exists + if (-not (Test-Path $ArchivePath)) { + Write-Err "Archive not found: $ArchivePath" + exit 1 + } + + $ArchivePath = (Resolve-Path $ArchivePath).Path + + # Suppress interactive prompts and telemetry + $env:ASPIRE_CLI_TELEMETRY_OPTOUT = "true" + $env:DOTNET_CLI_TELEMETRY_OPTOUT = "true" + $env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = "true" + $env:DOTNET_GENERATE_ASPNET_CERTIFICATE = "false" + + Write-Host "" + Write-Host "==========================================" + Write-Host " Aspire CLI Archive Verification" + Write-Host "==========================================" + Write-Host " Archive: $ArchivePath" + Write-Host "==========================================" + Write-Host "" + + # Step 1: Back up and clean ~/.aspire + Write-Step "Cleaning ~/.aspire state..." + $aspireDir = Join-Path $env:USERPROFILE ".aspire" + if (Test-Path $aspireDir) { + $aspireBackup = Join-Path ([System.IO.Path]::GetTempPath()) "aspire-backup-$([System.IO.Path]::GetRandomFileName())" + Move-Item $aspireDir $aspireBackup + Write-Step "Backed up existing ~/.aspire to $aspireBackup" + } + Write-Ok "Clean ~/.aspire state" + + # Step 2: Extract the archive + $verifyTmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "aspire-verify-$([System.IO.Path]::GetRandomFileName())" + $extractDir = Join-Path $verifyTmpDir "cli" + New-Item -ItemType Directory -Path $extractDir -Force | Out-Null + + Write-Step "Extracting archive to $extractDir..." + if ($ArchivePath.EndsWith(".zip")) { + Expand-Archive -Path $ArchivePath -DestinationPath $extractDir + } + elseif ($ArchivePath.EndsWith(".tar.gz")) { + tar -xzf $ArchivePath -C $extractDir + if ($LASTEXITCODE -ne 0) { + Write-Err "Failed to extract tar.gz archive" + exit 1 + } + } + else { + Write-Err "Unsupported archive format: $ArchivePath (expected .zip or .tar.gz)" + exit 1 + } + + # Find the aspire binary + $aspireBin = Join-Path $extractDir "aspire.exe" + if (-not (Test-Path $aspireBin)) { + $aspireBin = Join-Path $extractDir "aspire" + if (-not (Test-Path $aspireBin)) { + Write-Err "Could not find 'aspire' binary in extracted archive." + Get-ChildItem $extractDir | Format-Table + exit 1 + } + } + Write-Ok "Extracted CLI binary: $aspireBin" + + # Install to ~/.aspire/bin so self-extraction works correctly + Write-Step "Installing CLI to ~/.aspire/bin..." + $aspireDir = Join-Path $env:USERPROFILE ".aspire" + $aspireBinDir = Join-Path $aspireDir "bin" + New-Item -ItemType Directory -Path $aspireBinDir -Force | Out-Null + Copy-Item $aspireBin (Join-Path $aspireBinDir (Split-Path $aspireBin -Leaf)) + $aspireBin = Join-Path $aspireBinDir (Split-Path $aspireBin -Leaf) + $env:PATH = "$aspireBinDir;$env:PATH" + Write-Ok "CLI installed to ~/.aspire/bin" + + # Step 3: Verify aspire --version + Write-Step "Running 'aspire --version'..." + $versionOutput = & $aspireBin --version 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Err "'aspire --version' failed with exit code $LASTEXITCODE" + Write-Host "Output: $versionOutput" + exit 1 + } + Write-Host " Version: $versionOutput" + Write-Ok "'aspire --version' succeeded" + + # Step 4: Create a new project with aspire new + # This exercises bundle self-extraction and aspire-managed (template search + download + scaffolding) + $projectDir = Join-Path $verifyTmpDir "VerifyApp" + New-Item -ItemType Directory -Path $projectDir -Force | Out-Null + + Write-Step "Running 'aspire new aspire-starter --name VerifyApp --output $projectDir --non-interactive --nologo'..." + & $aspireBin new aspire-starter --name VerifyApp --output $projectDir --non-interactive --nologo 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { + Write-Err "'aspire new' failed with exit code $LASTEXITCODE" + exit 1 + } + + # Verify the project was created + $appHostDir = Join-Path $projectDir "VerifyApp.AppHost" + if (-not (Test-Path $appHostDir)) { + Write-Err "Expected project directory 'VerifyApp.AppHost' not found after 'aspire new'" + Get-ChildItem $projectDir | Format-Table + exit 1 + } + Write-Ok "'aspire new' created project successfully" + + Write-Host "" + Write-Host "==========================================" + Write-Host " All verification checks passed!" -ForegroundColor Green + Write-Host "==========================================" + Write-Host "" +} +catch { + Write-Err "Verification failed: $_" + Write-Host $_.ScriptStackTrace + exit 1 +} +finally { + Invoke-Cleanup +} diff --git a/eng/scripts/verify-cli-archive.sh b/eng/scripts/verify-cli-archive.sh new file mode 100755 index 00000000000..40676d1ba10 --- /dev/null +++ b/eng/scripts/verify-cli-archive.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash + +# verify-cli-archive.sh - Verify that a signed Aspire CLI archive produces a working binary. +# +# Usage: ./verify-cli-archive.sh +# +# This script: +# 1. Cleans ~/.aspire to ensure no stale state +# 2. Extracts the CLI archive to a temp location +# 3. Runs 'aspire --version' to validate the binary executes +# 4. Runs 'aspire new aspire-starter' to test bundle self-extraction + project creation +# 5. Cleans up temp directories + +set -euo pipefail + +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly CYAN='\033[0;36m' +readonly RESET='\033[0m' + +ARCHIVE_PATH="" + +log_step() { echo -e "${CYAN}▶ $1${RESET}"; } +log_ok() { echo -e "${GREEN}✅ $1${RESET}"; } +log_err() { echo -e "${RED}❌ $1${RESET}"; } + +show_help() { + cat << 'EOF' +Aspire CLI Archive Verification Script + +USAGE: + verify-cli-archive.sh + +ARGUMENTS: + Path to the CLI archive (.tar.gz or .zip) + +OPTIONS: + -h, --help Show this help message + +DESCRIPTION: + Verifies that a signed Aspire CLI archive produces a working binary by: + 1. Extracting the archive + 2. Running 'aspire --version' + 3. Creating a new project with 'aspire new' +EOF + exit 0 +} + +cleanup() { + local exit_code=$? + if [[ -n "${VERIFY_TMPDIR:-}" ]] && [[ -d "${VERIFY_TMPDIR}" ]]; then + log_step "Cleaning up temp directory: ${VERIFY_TMPDIR}" + rm -rf "${VERIFY_TMPDIR}" + fi + # Restore ~/.aspire if we backed it up + if [[ -n "${ASPIRE_BACKUP:-}" ]] && [[ -d "${ASPIRE_BACKUP}" ]]; then + if [[ -d "$HOME/.aspire" ]]; then + rm -rf "$HOME/.aspire" + fi + mv "${ASPIRE_BACKUP}" "$HOME/.aspire" + log_step "Restored original ~/.aspire" + fi + if [[ $exit_code -ne 0 ]]; then + log_err "Verification FAILED (exit code: $exit_code)" + fi + exit $exit_code +} + +trap cleanup EXIT + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + show_help + ;; + -*) + echo "Unknown option: $1" >&2 + exit 1 + ;; + *) + if [[ -z "$ARCHIVE_PATH" ]]; then + ARCHIVE_PATH="$1" + else + echo "Unexpected argument: $1" >&2 + exit 1 + fi + shift + ;; + esac +done + +if [[ -z "$ARCHIVE_PATH" ]]; then + echo "Error: archive path is required." >&2 + echo "Usage: verify-cli-archive.sh " >&2 + exit 1 +fi + +if [[ ! -f "$ARCHIVE_PATH" ]]; then + log_err "Archive not found: $ARCHIVE_PATH" + exit 1 +fi + +echo "" +echo "==========================================" +echo " Aspire CLI Archive Verification" +echo "==========================================" +echo " Archive: $ARCHIVE_PATH" +echo "==========================================" +echo "" + +# Suppress interactive prompts and telemetry +export ASPIRE_CLI_TELEMETRY_OPTOUT=true +export DOTNET_CLI_TELEMETRY_OPTOUT=true +export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true +export DOTNET_GENERATE_ASPNET_CERTIFICATE=false + +VERIFY_TMPDIR="$(mktemp -d)" + +# Step 1: Back up and clean ~/.aspire +log_step "Cleaning ~/.aspire state..." +ASPIRE_BACKUP="" +if [[ -d "$HOME/.aspire" ]]; then + ASPIRE_BACKUP="${VERIFY_TMPDIR}/aspire-backup/.aspire" + mkdir -p "${VERIFY_TMPDIR}/aspire-backup" + mv "$HOME/.aspire" "${ASPIRE_BACKUP}" + log_step "Backed up existing ~/.aspire to ${ASPIRE_BACKUP}" +fi +log_ok "Clean ~/.aspire state" + +# Step 2: Extract the archive +EXTRACT_DIR="${VERIFY_TMPDIR}/cli" +mkdir -p "$EXTRACT_DIR" + +log_step "Extracting archive to ${EXTRACT_DIR}..." +if [[ "$ARCHIVE_PATH" == *.tar.gz ]]; then + tar -xzf "$ARCHIVE_PATH" -C "$EXTRACT_DIR" +elif [[ "$ARCHIVE_PATH" == *.zip ]]; then + unzip -q "$ARCHIVE_PATH" -d "$EXTRACT_DIR" +else + log_err "Unsupported archive format: $ARCHIVE_PATH (expected .tar.gz or .zip)" + exit 1 +fi + +# Find the aspire binary +ASPIRE_BIN="" +if [[ -f "$EXTRACT_DIR/aspire" ]]; then + ASPIRE_BIN="$EXTRACT_DIR/aspire" +elif [[ -f "$EXTRACT_DIR/aspire.exe" ]]; then + ASPIRE_BIN="$EXTRACT_DIR/aspire.exe" +else + log_err "Could not find 'aspire' binary in extracted archive. Contents:" + ls -la "$EXTRACT_DIR" + exit 1 +fi + +chmod +x "$ASPIRE_BIN" +log_ok "Extracted CLI binary: $ASPIRE_BIN" + +# Install the CLI to ~/.aspire/bin so self-extraction works correctly +log_step "Installing CLI to ~/.aspire/bin..." +mkdir -p "$HOME/.aspire/bin" +cp "$ASPIRE_BIN" "$HOME/.aspire/bin/" +ASPIRE_BIN="$HOME/.aspire/bin/$(basename "$ASPIRE_BIN")" +chmod +x "$ASPIRE_BIN" +export PATH="$HOME/.aspire/bin:$PATH" +log_ok "CLI installed to ~/.aspire/bin" + +# Step 3: Verify aspire --version +log_step "Running 'aspire --version'..." +VERSION_OUTPUT=$("$ASPIRE_BIN" --version 2>&1) || { + log_err "'aspire --version' failed with exit code $?" + echo "Output: $VERSION_OUTPUT" + exit 1 +} +echo " Version: $VERSION_OUTPUT" +log_ok "'aspire --version' succeeded" + +# Step 4: Create a new project with aspire new +# This exercises bundle self-extraction and aspire-managed (template search + download + scaffolding) +PROJECT_DIR="${VERIFY_TMPDIR}/VerifyApp" +mkdir -p "$PROJECT_DIR" + +log_step "Running 'aspire new aspire-starter --name VerifyApp --output $PROJECT_DIR'..." +"$ASPIRE_BIN" new aspire-starter --name VerifyApp --output "$PROJECT_DIR" --non-interactive --nologo 2>&1 || { + log_err "'aspire new' failed" + echo "Contents of project directory:" + find "$PROJECT_DIR" -maxdepth 3 -type f 2>/dev/null || true + exit 1 +} + +# Verify the project was actually created +if [[ ! -d "$PROJECT_DIR/VerifyApp.AppHost" ]]; then + log_err "Expected project directory 'VerifyApp.AppHost' not found after 'aspire new'" + echo "Contents of project directory:" + ls -la "$PROJECT_DIR" + exit 1 +fi +log_ok "'aspire new' created project successfully" + +echo "" +echo "==========================================" +echo -e " ${GREEN}All verification checks passed!${RESET}" +echo "==========================================" +echo "" diff --git a/playground/yarp/Yarp.AppHost/Properties/launchSettings.json b/playground/yarp/Yarp.AppHost/Properties/launchSettings.json index 7a511945b21..cbdb6039472 100644 --- a/playground/yarp/Yarp.AppHost/Properties/launchSettings.json +++ b/playground/yarp/Yarp.AppHost/Properties/launchSettings.json @@ -10,7 +10,6 @@ "DOTNET_ENVIRONMENT": "Development", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:17299", "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:18100", - //"ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL": "https://localhost:17299", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17299", "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true", "ASPIRE_ENABLE_CONTAINER_TUNNEL": "true" @@ -26,7 +25,6 @@ "DOTNET_ENVIRONMENT": "Development", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:17300", "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18101", - //"ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL": "http://localhost:17300", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17300", "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true", "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true", diff --git a/src/Aspire.Cli/Agents/Playwright/PlaywrightCliInstaller.cs b/src/Aspire.Cli/Agents/Playwright/PlaywrightCliInstaller.cs index 32279e74161..ab263941504 100644 --- a/src/Aspire.Cli/Agents/Playwright/PlaywrightCliInstaller.cs +++ b/src/Aspire.Cli/Agents/Playwright/PlaywrightCliInstaller.cs @@ -155,7 +155,8 @@ private async Task InstallCoreAsync(AgentEnvironmentScanContext context, C ExpectedWorkflowPath, ExpectedBuildType, refInfo => string.Equals(refInfo.Kind, "tags", StringComparison.Ordinal) && - string.Equals(refInfo.Name, $"v{packageInfo.Version}", StringComparison.Ordinal), + (string.Equals(refInfo.Name, $"{packageInfo.Version}", StringComparison.Ordinal) || + string.Equals(refInfo.Name, $"v{packageInfo.Version}", StringComparison.Ordinal)), cancellationToken, sriIntegrity: packageInfo.Integrity); diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 2efc1e9bf9f..e3c863981d9 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -125,6 +125,7 @@ + diff --git a/src/Aspire.Cli/Commands/ExportCommand.cs b/src/Aspire.Cli/Commands/ExportCommand.cs index 44dc69d4c22..1ac612654a3 100644 --- a/src/Aspire.Cli/Commands/ExportCommand.cs +++ b/src/Aspire.Cli/Commands/ExportCommand.cs @@ -259,7 +259,7 @@ private static async Task AddStructuredLogsAsync( IReadOnlyList allOtlpResources, CancellationToken cancellationToken) { - var url = DashboardUrls.TelemetryLogsApiUrl(baseUrl, resolvedResources); + var url = DashboardUrls.TelemetryLogsApiUrl(baseUrl, resolvedResources, limit: TelemetryCommandHelpers.MaxTelemetryLimit); var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); @@ -290,7 +290,7 @@ private static async Task AddTracesAsync( IReadOnlyList allOtlpResources, CancellationToken cancellationToken) { - var url = DashboardUrls.TelemetryTracesApiUrl(baseUrl, resolvedResources); + var url = DashboardUrls.TelemetryTracesApiUrl(baseUrl, resolvedResources, limit: TelemetryCommandHelpers.MaxTelemetryLimit); var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); diff --git a/src/Aspire.Cli/Commands/StopCommand.cs b/src/Aspire.Cli/Commands/StopCommand.cs index 9969d0c22a2..271208d3f17 100644 --- a/src/Aspire.Cli/Commands/StopCommand.cs +++ b/src/Aspire.Cli/Commands/StopCommand.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; -using System.Diagnostics; using System.Globalization; using Aspire.Cli.Backchannel; using Aspire.Cli.Configuration; @@ -193,16 +192,12 @@ private async Task StopAppHostAsync(IAppHostAuxiliaryBackchannel connection _interactionService.DisplayMessage(KnownEmojis.StopSign, "Sending stop signal..."); - // Get the CLI process ID - this is the process we need to kill - // Killing the CLI process will tear down everything including the AppHost - var cliProcessId = appHostInfo?.CliProcessId; - - if (cliProcessId is int cliPid) + if (appHostInfo?.CliProcessId is int cliPid) { _logger.LogDebug("Sending stop signal to CLI process (PID {Pid})", cliPid); try { - SendStopSignal(cliPid); + SendStopSignal(cliPid, appHostInfo?.CliStartedAt); } catch (Exception ex) { @@ -213,7 +208,7 @@ private async Task StopAppHostAsync(IAppHostAuxiliaryBackchannel connection } else { - // Fallback: Try the RPC method if we don't have CLI process ID + // Fallback: Try the RPC method if we don't have CLI process ID. _logger.LogDebug("No CLI process ID available, trying RPC stop"); var rpcSucceeded = false; try @@ -228,10 +223,10 @@ private async Task StopAppHostAsync(IAppHostAuxiliaryBackchannel connection // If RPC didn't work, try sending SIGINT to AppHost process directly if (!rpcSucceeded && appHostInfo?.ProcessId is int appHostPid) { - _logger.LogDebug("RPC stop not available, sending SIGINT to AppHost PID {Pid}", appHostPid); + _logger.LogDebug("RPC stop not available, sending SIGTERM to AppHost PID {Pid}", appHostPid); try { - SendStopSignal(appHostPid); + SendStopSignal(appHostPid, appHostInfo?.StartedAt); } catch (Exception ex) { @@ -247,21 +242,37 @@ private async Task StopAppHostAsync(IAppHostAuxiliaryBackchannel connection } } + var manager = new RunningInstanceManager(_logger, _interactionService, _timeProvider); var stopped = await _interactionService.ShowStatusAsync( StopCommandStrings.StoppingAppHost, async () => { try { - // Wait for processes to terminate - var manager = new RunningInstanceManager(_logger, _interactionService, _timeProvider); + if (appHostInfo is null) + { + return true; + } + + if (await manager.MonitorProcessesForTerminationAsync(appHostInfo, cancellationToken).ConfigureAwait(false)) + { + return true; + } - if (appHostInfo is not null) + var procsToKill = new HashSet<(int, DateTimeOffset?)> { (appHostInfo.ProcessId, appHostInfo.StartedAt) }; + + if (appHostInfo.CliProcessId is int cliPid) + { + procsToKill.Add((cliPid, appHostInfo.CliStartedAt)); + } + + foreach (var (pid, startTime) in procsToKill) { - return await manager.MonitorProcessesForTerminationAsync(appHostInfo, cancellationToken).ConfigureAwait(false); + _logger.LogWarning("AppHost did not stop gracefully within timeout. Forcing process {Pid} to terminate.", pid); + ForceKillProcess(pid, startTime); } - return true; + return await manager.MonitorProcessesForTerminationAsync(appHostInfo, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -286,28 +297,21 @@ private async Task StopAppHostAsync(IAppHostAuxiliaryBackchannel connection } /// - /// Sends a stop signal to a process to terminate it and its process tree. - /// Uses Process.Kill(entireProcessTree: true) to ensure all child processes are terminated. + /// Sends a best-effort graceful shutdown signal to the target process. + /// Uses Ctrl-Break on Windows and SIGTERM on non-Windows. /// - private static void SendStopSignal(int pid) + private void SendStopSignal(int pid, DateTimeOffset? startTime) { - try - { - using var process = Process.GetProcessById(pid); - process.Kill(entireProcessTree: true); - } - catch (ArgumentException) - { - // Process doesn't exist - already terminated - } - catch (InvalidOperationException) - { - // Process has already exited - } - catch (Exception) - { - // Some other error (e.g., permission denied) - ignore - } + ProcessSignaler.RequestGracefulShutdown(pid, startTime, _logger); + } + + /// + /// Forcefully kills the target process after the graceful shutdown timeout elapses. + /// This does not terminate the entire process tree. + /// + private void ForceKillProcess(int pid, DateTimeOffset? startTime) + { + ProcessSignaler.ForceKill(pid, startTime, _logger); } } diff --git a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs index b327cd2476a..a6e41c0b12c 100644 --- a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs +++ b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs @@ -26,6 +26,12 @@ internal static class TelemetryCommandHelpers /// internal const string ApiKeyHeaderName = "X-API-Key"; + /// + /// Limit passed to dashboard telemetry APIs. All data is fetched in one API call + /// so there shouldn't be a limit on data returned. + /// + internal const int MaxTelemetryLimit = int.MaxValue; + #region Shared Command Options /// diff --git a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs index d07af446a8e..70d768ae7da 100644 --- a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs @@ -129,22 +129,10 @@ private async Task FetchLogsAsync( return ExitCodeConstants.InvalidCommand; } - // Build query string with multiple resource parameters - var additionalParams = new List<(string key, string? value)> - { - ("traceId", traceId), - ("severity", severity) - }; - if (limit.HasValue && !follow) - { - additionalParams.Add(("limit", limit.Value.ToString(CultureInfo.InvariantCulture))); - } - if (follow) - { - additionalParams.Add(("follow", "true")); - } + // Build URL with query parameters + int? effectiveLimit = (limit.HasValue && !follow) ? limit.Value : null; - var url = DashboardUrls.TelemetryLogsApiUrl(baseUrl, resolvedResources, [.. additionalParams]); + var url = DashboardUrls.TelemetryLogsApiUrl(baseUrl, resolvedResources, traceId: traceId, severity: severity, limit: effectiveLimit, follow: follow ? true : null); try { diff --git a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs index 7c06a63ede3..84ff7bdb122 100644 --- a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs @@ -125,25 +125,10 @@ private async Task FetchSpansAsync( return ExitCodeConstants.InvalidCommand; } - // Build query string with multiple resource parameters - var additionalParams = new List<(string key, string? value)> - { - ("traceId", traceId) - }; - if (hasError.HasValue) - { - additionalParams.Add(("hasError", hasError.Value.ToString().ToLowerInvariant())); - } - if (limit.HasValue && !follow) - { - additionalParams.Add(("limit", limit.Value.ToString(CultureInfo.InvariantCulture))); - } - if (follow) - { - additionalParams.Add(("follow", "true")); - } + // Build URL with query parameters + int? effectiveLimit = (limit.HasValue && !follow) ? limit.Value : null; - var url = DashboardUrls.TelemetrySpansApiUrl(baseUrl, resolvedResources, [.. additionalParams]); + var url = DashboardUrls.TelemetrySpansApiUrl(baseUrl, resolvedResources, traceId: traceId, hasError: hasError, limit: effectiveLimit, follow: follow ? true : null); _logger.LogDebug("Fetching spans from {Url}", url); diff --git a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs index b97018fd7e5..3159c54be22 100644 --- a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs @@ -188,18 +188,7 @@ private async Task FetchTracesAsync( // Pre-resolve colors so assignment is deterministic regardless of data order TelemetryCommandHelpers.ResolveResourceColors(_resourceColorMap, allOtlpResources); - // Build query string with multiple resource parameters - var additionalParams = new List<(string key, string? value)>(); - if (hasError.HasValue) - { - additionalParams.Add(("hasError", hasError.Value.ToString().ToLowerInvariant())); - } - if (limit.HasValue) - { - additionalParams.Add(("limit", limit.Value.ToString(CultureInfo.InvariantCulture))); - } - - var url = DashboardUrls.TelemetryTracesApiUrl(baseUrl, resolvedResources, [.. additionalParams]); + var url = DashboardUrls.TelemetryTracesApiUrl(baseUrl, resolvedResources, hasError: hasError, limit: limit); _logger.LogDebug("Fetching traces from {Url}", url); diff --git a/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs index 4df3c286b4d..fb6bb671980 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs @@ -70,7 +70,8 @@ public override async ValueTask CallToolAsync(CallToolContext co }; } - var url = DashboardUrls.TelemetryLogsApiUrl(apiBaseUrl, resolvedResources); + // Fetch all logs from the API. Limiting of returned telemetry to the MCP caller happens later. + var url = DashboardUrls.TelemetryLogsApiUrl(apiBaseUrl, resolvedResources, limit: TelemetryCommandHelpers.MaxTelemetryLimit); logger.LogDebug("Fetching structured logs from {Url}", url); diff --git a/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs index 87320996e7f..f7543ec2adb 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs @@ -70,7 +70,8 @@ public override async ValueTask CallToolAsync(CallToolContext co var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, apiBaseUrl, cancellationToken).ConfigureAwait(false); // Build the logs API URL with traceId filter - var url = DashboardUrls.TelemetryLogsApiUrl(apiBaseUrl, resources: null, ("traceId", traceId)); + // Fetch all logs for the trace from the API. Limiting of returned telemetry to the MCP caller happens later. + var url = DashboardUrls.TelemetryLogsApiUrl(apiBaseUrl, traceId: traceId, limit: TelemetryCommandHelpers.MaxTelemetryLimit); logger.LogDebug("Fetching structured logs from {Url}", url); diff --git a/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs b/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs index 187b8479a7f..bdb96f204bf 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs @@ -70,7 +70,8 @@ public override async ValueTask CallToolAsync(CallToolContext co }; } - var url = DashboardUrls.TelemetryTracesApiUrl(apiBaseUrl, resolvedResources); + // Fetch all traces from the API. Limiting of returned telemetry to the MCP caller happens later. + var url = DashboardUrls.TelemetryTracesApiUrl(apiBaseUrl, resolvedResources, limit: TelemetryCommandHelpers.MaxTelemetryLimit); logger.LogDebug("Fetching traces from {Url}", url); diff --git a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs index da509a0be25..a4c86192d28 100644 --- a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs +++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs @@ -77,16 +77,17 @@ private static Process StartWindows(string fileName, IReadOnlyList argum var si = new STARTUPINFOEX(); si.cb = Marshal.SizeOf(); - si.dwFlags = StartfUseStdHandles; + si.dwFlags = StartfUseStdHandles | StartfUseShowWindow; si.hStdInput = nint.Zero; si.hStdOutput = nulRawHandle; si.hStdError = nulRawHandle; si.lpAttributeList = attrList; + si.wShowWindow = ShowWindowHide; // Build the command line string: "fileName" arg1 arg2 ... var commandLine = BuildCommandLine(fileName, arguments); - var flags = CreateUnicodeEnvironment | ExtendedStartupInfoPresent | CreateNoWindow; + var flags = CreateUnicodeEnvironment | ExtendedStartupInfoPresent | CreateNewProcessGroup; if (!CreateProcessW( null, @@ -223,9 +224,11 @@ private static void AppendArgument(StringBuilder sb, string argument) private const uint OpenExisting = 3; private const uint HandleFlagInherit = 0x00000001; private const uint StartfUseStdHandles = 0x00000100; + private const uint StartfUseShowWindow = 0x00000001; private const uint CreateUnicodeEnvironment = 0x00000400; private const uint ExtendedStartupInfoPresent = 0x00080000; - private const uint CreateNoWindow = 0x08000000; + private const uint CreateNewProcessGroup = 0x00000200; + private const ushort ShowWindowHide = 0x0000; private static readonly nint s_procThreadAttributeHandleList = (nint)0x00020002; // --- Structs --- diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index dc5fa35a5c9..d8f11d9e37a 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -5,6 +5,7 @@ using System.CommandLine.Parsing; using System.Diagnostics; using System.Globalization; +using System.Runtime.InteropServices; using System.Text; using System.Text.Json; using Aspire.Cli.Agents; @@ -662,6 +663,13 @@ public static async Task Main(string[] args) cts.Cancel(); eventArgs.Cancel = true; }; + using var sigTermRegistration = OperatingSystem.IsWindows() + ? null + : PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => + { + cts.Cancel(); + context.Cancel = true; + }); Console.OutputEncoding = Encoding.UTF8; @@ -674,6 +682,10 @@ public static async Task Main(string[] args) logger.LogInformation("Version: {Version}", AspireCliTelemetry.GetCliVersion()); logger.LogInformation("Build ID: {BuildId}", AspireCliTelemetry.GetCliBuildId()); logger.LogInformation("Working directory: {WorkingDirectory}", Environment.CurrentDirectory); + // Logging the log file path is useful so that when console logging is enabled (for example with --log-level debug), + // the path is written to the console logger (stderr) for easier discovery. + logger.LogInformation("Log file: {LogFilePath}", loggingOptions.LogFilePath); + logger.LogInformation("CLI process ID: {ProcessId}", Environment.ProcessId); IHost? app = null; try diff --git a/src/Aspire.Dashboard/Api/TelemetryApiService.cs b/src/Aspire.Dashboard/Api/TelemetryApiService.cs index 80617479ca7..76df9a7317a 100644 --- a/src/Aspire.Dashboard/Api/TelemetryApiService.cs +++ b/src/Aspire.Dashboard/Api/TelemetryApiService.cs @@ -21,7 +21,6 @@ internal sealed class TelemetryApiService( { private const int DefaultLimit = 200; private const int DefaultTraceLimit = 100; - private const int MaxQueryCount = 10000; private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers = outgoingPeerResolvers.ToArray(); @@ -50,7 +49,7 @@ internal sealed class TelemetryApiService( { ResourceKey = resourceKey, StartIndex = 0, - Count = MaxQueryCount, + Count = int.MaxValue, Filters = [], FilterText = string.Empty }); @@ -121,7 +120,7 @@ internal sealed class TelemetryApiService( { ResourceKey = resourceKey, StartIndex = 0, - Count = MaxQueryCount, + Count = int.MaxValue, Filters = [], FilterText = string.Empty }); @@ -237,7 +236,7 @@ internal sealed class TelemetryApiService( { ResourceKey = resourceKey, StartIndex = 0, - Count = MaxQueryCount, + Count = int.MaxValue, Filters = filters }); allLogs.AddRange(result.Items); diff --git a/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs b/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs index 17956d932f5..4a43552c4f6 100644 --- a/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs @@ -495,24 +495,31 @@ public static IResourceBuilder WithReference(this IResourc if (flags.HasFlag(ReferenceEnvironmentInjectionFlags.ServiceDiscovery)) { - // Use the endpoint's scheme (not name) in the service discovery key so that - // .NET service discovery can correctly match the scheme segment to the URI scheme. - var scheme = port.TargetEndpoint.Scheme; - if (!schemeIndexTracker.TryGetValue(scheme, out var index)) + // Use the endpoint's scheme for "http" and "https" endpoint names to handle + // TLS upgrades correctly. For all other endpoint names, use the endpoint name + // so that .NET service discovery's named endpoint resolution can match them. + var schemeKey = port.TargetEndpoint.EndpointName; + if (string.Equals(port.TargetEndpoint.EndpointName, "http", StringComparison.OrdinalIgnoreCase) || + string.Equals(port.TargetEndpoint.EndpointName, "https", StringComparison.OrdinalIgnoreCase)) + { + schemeKey = port.TargetEndpoint.Scheme; + } + + if (!schemeIndexTracker.TryGetValue(schemeKey, out var index)) { index = 0; } // Find the next unused index for this scheme in case of collisions with other callbacks. - var key = $"services__{serviceName}__{scheme}__{index}"; + var key = $"services__{serviceName}__{schemeKey}__{index}"; while (context.EnvironmentVariables.ContainsKey(key)) { index++; - key = $"services__{serviceName}__{scheme}__{index}"; + key = $"services__{serviceName}__{schemeKey}__{index}"; } context.EnvironmentVariables[key] = port.TunnelEndpoint; - schemeIndexTracker[scheme] = index + 1; + schemeIndexTracker[schemeKey] = index + 1; } if (flags.HasFlag(ReferenceEnvironmentInjectionFlags.Endpoints)) diff --git a/src/Aspire.Hosting.Yarp/ConfigurationBuilder/YarpCluster.cs b/src/Aspire.Hosting.Yarp/ConfigurationBuilder/YarpCluster.cs index ffb12583329..eae8cc3abc1 100644 --- a/src/Aspire.Hosting.Yarp/ConfigurationBuilder/YarpCluster.cs +++ b/src/Aspire.Hosting.Yarp/ConfigurationBuilder/YarpCluster.cs @@ -25,10 +25,25 @@ internal YarpCluster(ClusterConfig config, params object[] targets) /// /// The endpoint to target. internal YarpCluster(EndpointReference endpoint) - : this(endpoint.Resource.Name, $"{endpoint.Scheme}://_{endpoint.EndpointName}.{endpoint.Resource.Name}") + : this(endpoint.Resource.Name, BuildEndpointTarget(endpoint)) { } + private static string BuildEndpointTarget(EndpointReference endpoint) + { + // For endpoints named "http" or "https", use standard resolution without + // the named endpoint prefix since the scheme key already matches the scheme. + // For all other endpoint names, use the named endpoint DNS-SD convention + // which .NET service discovery resolves via the endpoint name in the config key. + if (string.Equals(endpoint.EndpointName, "http", StringComparison.OrdinalIgnoreCase) || + string.Equals(endpoint.EndpointName, "https", StringComparison.OrdinalIgnoreCase)) + { + return $"{endpoint.Scheme}://{endpoint.Resource.Name}"; + } + + return $"{endpoint.Scheme}://_{endpoint.EndpointName}.{endpoint.Resource.Name}"; + } + /// /// Construct a new YarpCluster targeting the resource in parameter. /// diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs index 9ced43243de..40d07437a02 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs @@ -69,6 +69,15 @@ public sealed class EndpointReference : IManifestExpressionProvider, IValueProvi /// public bool TlsEnabled => Exists && EndpointAnnotation.TlsEnabled; + /// + /// Gets a value indicating whether the endpoint name is "http" or "https", ignoring case. This is a convention used to identify + /// endpoints that will be resolved based on the scheme of the endpoint in service discovery rather than by the specific endpoint name. + /// This is done to allow http endpoints that are dynamically updated to https to be mapped correctly despite the endpoint name no longer + /// matching the scheme. + /// + internal bool IsHttpSchemeNamedEndpoint => string.Equals(EndpointName, "http", StringComparisons.EndpointAnnotationUriScheme) || + string.Equals(EndpointName, "https", StringComparisons.EndpointAnnotationUriScheme); + /// /// Gets a value indicating whether this endpoint is excluded from the default set when referencing the resource's endpoints. /// diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index 20d5c5be4c0..c16d13c6254 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -53,6 +53,7 @@ + diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index 95d5b856976..2f260d7d035 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -353,13 +353,20 @@ public Task GetAppHostInformationAsync(CancellationToken can { cliProcessId = parsedCliPid; } + DateTimeOffset? cliStartedAt = null; + var cliStartedAtString = configuration[KnownConfigNames.CliProcessStarted]; + if (!string.IsNullOrEmpty(cliStartedAtString) && long.TryParse(cliStartedAtString, out var parsedCliStartedAt)) + { + cliStartedAt = DateTimeOffset.FromUnixTimeSeconds(parsedCliStartedAt); + } return Task.FromResult(new AppHostInformation { AppHostPath = appHostPath, ProcessId = Environment.ProcessId, CliProcessId = cliProcessId, - StartedAt = new DateTimeOffset(Process.GetCurrentProcess().StartTime) + StartedAt = new DateTimeOffset(Process.GetCurrentProcess().StartTime), + CliStartedAt = cliStartedAt }); } diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index 1bf4303c6cc..06f2ef3aa5f 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -961,6 +961,12 @@ internal sealed class AppHostInformation /// Gets or sets when the AppHost process started. /// public DateTimeOffset? StartedAt { get; init; } + + /// + /// Gets or sets when the CLI process that launched the AppHost started. + /// This value is only set when the AppHost is launched via the Aspire CLI. + /// + public DateTimeOffset? CliStartedAt { get; init; } } /// diff --git a/src/Aspire.Hosting/Cli/CliOrphanDetector.cs b/src/Aspire.Hosting/Cli/CliOrphanDetector.cs index e7f65e88f39..d4b72afb02a 100644 --- a/src/Aspire.Hosting/Cli/CliOrphanDetector.cs +++ b/src/Aspire.Hosting/Cli/CliOrphanDetector.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -12,37 +11,14 @@ internal sealed class CliOrphanDetector(IConfiguration configuration, IHostAppli { internal Func IsProcessRunning { get; set; } = (int pid) => { - try - { - return !Process.GetProcessById(pid).HasExited; - } - catch (ArgumentException) - { - // If Process.GetProcessById throws it means the process in not running. - return false; - } + using var process = ProcessSignaler.TryGetRunningProcess(pid, null, logger); + return process is not null; }; internal Func IsProcessRunningWithStartTime { get; set; } = (int pid, long expectedStartTimeUnix) => { - try - { - var process = Process.GetProcessById(pid); - if (process.HasExited) - { - return false; - } - - // Check if the process start time matches the expected start time exactly. - var actualStartTimeUnix = ((DateTimeOffset)process.StartTime).ToUnixTimeSeconds(); - return actualStartTimeUnix == expectedStartTimeUnix; - } - catch - { - // If we can't get the process and/or can't get the start time, - // then we interpret both exceptions as the process not being there. - return false; - } + using var process = ProcessSignaler.TryGetRunningProcess(pid, DateTimeOffset.FromUnixTimeSeconds(expectedStartTimeUnix), logger); + return process is not null; }; protected override async Task ExecuteAsync(CancellationToken stoppingToken) diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 9c47ee8ea3f..e4ea9c57b69 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -596,24 +596,25 @@ private static Action CreateEndpointReferenceEnviron if (flags.HasFlag(ReferenceEnvironmentInjectionFlags.ServiceDiscovery)) { - // Use the endpoint's scheme (not name) in the service discovery key so that - // .NET service discovery can correctly match the scheme segment to the URI scheme. - var scheme = endpoint.Scheme; - if (!schemeIndexTracker.TryGetValue(scheme, out var index)) + // Use the endpoint's scheme for "http" and "https" endpoint names to handle + // TLS upgrades correctly. For all other endpoint names, use the endpoint name + // so that .NET service discovery's named endpoint resolution can match them. + var schemeKey = endpoint.IsHttpSchemeNamedEndpoint ? endpoint.Scheme : endpointName; + if (!schemeIndexTracker.TryGetValue(schemeKey, out var index)) { index = 0; } // Find the next unused index for this scheme in case of collisions with other callbacks. - var key = $"services__{serviceName}__{scheme}__{index}"; + var key = $"services__{serviceName}__{schemeKey}__{index}"; while (context.EnvironmentVariables.ContainsKey(key)) { index++; - key = $"services__{serviceName}__{scheme}__{index}"; + key = $"services__{serviceName}__{schemeKey}__{index}"; } context.EnvironmentVariables[key] = endpoint; - schemeIndexTracker[scheme] = index + 1; + schemeIndexTracker[schemeKey] = index + 1; } } }; diff --git a/src/Shared/DashboardUrls.cs b/src/Shared/DashboardUrls.cs index 597f9f25b3b..0437cfcdcc6 100644 --- a/src/Shared/DashboardUrls.cs +++ b/src/Shared/DashboardUrls.cs @@ -193,42 +193,92 @@ public static string CombineUrl(string baseUrl, string path) private const string TelemetryApiBasePath = "api/telemetry"; /// - /// Builds the URL for the telemetry logs API with resource filtering. + /// Builds the URL for the telemetry logs API. /// /// The dashboard base URL. /// Optional list of resource names to filter by. - /// Additional query parameters. + /// Optional trace ID to filter logs by. + /// Optional minimum severity level filter. + /// Optional maximum number of results to return. + /// Optional flag to enable streaming mode. /// The full API URL. - public static string TelemetryLogsApiUrl(string baseUrl, List? resources = null, params (string key, string? value)[] additionalParams) + public static string TelemetryLogsApiUrl(string baseUrl, List? resources = null, string? traceId = null, string? severity = null, int? limit = null, bool? follow = null) { - var queryString = BuildResourceQueryString(resources, additionalParams); - return CombineUrl(baseUrl, $"{TelemetryApiBasePath}/logs{queryString}"); + var url = $"/{TelemetryApiBasePath}/logs"; + url = AddResourceParams(url, resources); + if (traceId is not null) + { + url = AddQueryString(url, "traceId", traceId); + } + if (severity is not null) + { + url = AddQueryString(url, "severity", severity); + } + if (limit is not null) + { + url = AddQueryString(url, "limit", limit.Value.ToString(CultureInfo.InvariantCulture)); + } + if (follow == true) + { + url = AddQueryString(url, "follow", "true"); + } + return CombineUrl(baseUrl, url); } /// - /// Builds the URL for the telemetry spans API with resource filtering. + /// Builds the URL for the telemetry spans API. /// /// The dashboard base URL. /// Optional list of resource names to filter by. - /// Additional query parameters. + /// Optional trace ID to filter spans by. + /// Optional filter for error status. + /// Optional maximum number of results to return. + /// Optional flag to enable streaming mode. /// The full API URL. - public static string TelemetrySpansApiUrl(string baseUrl, List? resources = null, params (string key, string? value)[] additionalParams) + public static string TelemetrySpansApiUrl(string baseUrl, List? resources = null, string? traceId = null, bool? hasError = null, int? limit = null, bool? follow = null) { - var queryString = BuildResourceQueryString(resources, additionalParams); - return CombineUrl(baseUrl, $"{TelemetryApiBasePath}/spans{queryString}"); + var url = $"/{TelemetryApiBasePath}/spans"; + url = AddResourceParams(url, resources); + if (traceId is not null) + { + url = AddQueryString(url, "traceId", traceId); + } + if (hasError is not null) + { + url = AddQueryString(url, "hasError", hasError.Value.ToString().ToLowerInvariant()); + } + if (limit is not null) + { + url = AddQueryString(url, "limit", limit.Value.ToString(CultureInfo.InvariantCulture)); + } + if (follow == true) + { + url = AddQueryString(url, "follow", "true"); + } + return CombineUrl(baseUrl, url); } /// - /// Builds the URL for the telemetry traces API with resource filtering. + /// Builds the URL for the telemetry traces API. /// /// The dashboard base URL. /// Optional list of resource names to filter by. - /// Additional query parameters. + /// Optional filter for error status. + /// Optional maximum number of results to return. /// The full API URL. - public static string TelemetryTracesApiUrl(string baseUrl, List? resources = null, params (string key, string? value)[] additionalParams) + public static string TelemetryTracesApiUrl(string baseUrl, List? resources = null, bool? hasError = null, int? limit = null) { - var queryString = BuildResourceQueryString(resources, additionalParams); - return CombineUrl(baseUrl, $"{TelemetryApiBasePath}/traces{queryString}"); + var url = $"/{TelemetryApiBasePath}/traces"; + url = AddResourceParams(url, resources); + if (hasError is not null) + { + url = AddQueryString(url, "hasError", hasError.Value.ToString().ToLowerInvariant()); + } + if (limit is not null) + { + url = AddQueryString(url, "limit", limit.Value.ToString(CultureInfo.InvariantCulture)); + } + return CombineUrl(baseUrl, url); } /// @@ -255,33 +305,18 @@ public static string TelemetryResourcesApiUrl(string baseUrl) } /// - /// Builds a query string with multiple resource parameters and optional additional parameters. + /// Appends multiple resource query parameters to a URL. /// - internal static string BuildResourceQueryString( - List? resources, - params (string key, string? value)[] additionalParams) + private static string AddResourceParams(string url, List? resources) { - var parts = new List(); - - // Add each resource as a separate query parameter if (resources is not null) { foreach (var resource in resources) { - parts.Add($"resource={Uri.EscapeDataString(resource)}"); + url = AddQueryString(url, "resource", resource); } } - - // Add additional parameters - foreach (var (key, value) in additionalParams) - { - if (!string.IsNullOrEmpty(value)) - { - parts.Add($"{key}={Uri.EscapeDataString(value)}"); - } - } - - return parts.Count > 0 ? "?" + string.Join("&", parts) : ""; + return url; } #endregion diff --git a/src/Shared/ProcessSignaler.cs b/src/Shared/ProcessSignaler.cs new file mode 100644 index 00000000000..93cf21a85bd --- /dev/null +++ b/src/Shared/ProcessSignaler.cs @@ -0,0 +1,126 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; + +/// +/// Provides best-effort process signaling for graceful shutdown and forceful termination. +/// +internal static partial class ProcessSignaler +{ + public static void RequestGracefulShutdown(int pid, DateTimeOffset? expectedStartTime, ILogger logger) + { + using var process = TryGetRunningProcess(pid, expectedStartTime, logger); + if (process is null) + { + return; // Process is not running or does not match the expected start time + } + + logger.LogDebug("Requesting graceful shutdown of process {Pid}...", pid); + + if (OperatingSystem.IsWindows()) + { + RequestGracefulShutdownWindows(pid, logger); + } + else + { + RequestGracefulShutdownUnix(pid, logger); + } + } + + public static void ForceKill(int pid, DateTimeOffset? expectedStartTime, ILogger logger) + { + using var process = TryGetRunningProcess(pid, expectedStartTime, logger); + if (process is { }) + { + logger.LogDebug("Killing process {Pid}...", pid); + try + { + process.Kill(entireProcessTree: false); + } + catch (InvalidOperationException) + { + // Process already exited. + } + } + } + + public static Process? TryGetRunningProcess(int pid, DateTimeOffset? expectedStartTime, ILogger logger) + { + try + { + var process = Process.GetProcessById(pid); + if (expectedStartTime is not null && !AreClose(expectedStartTime, process.StartTime)) + { + logger.LogDebug("Process {Pid} start time {ProcessStartTime} does not match expected start time {ExpectedStartTime}", pid, process.StartTime, expectedStartTime); + process.Dispose(); + return null; // Do not return processes that do not match the expected start time + } + + if (process.HasExited) + { + process.Dispose(); + return null; + } + + return process; + } + catch (ArgumentException) + { + // Process doesn't exist - already terminated. + return null; + } + catch (InvalidOperationException) + { + // Process has already exited. + return null; + } + } + + private static bool AreClose(DateTimeOffset? expectedStartTime, DateTime processStartTime, TimeSpan? tolerance = default) + { + if (expectedStartTime is null) + { + return true; + } + + tolerance ??= TimeSpan.FromSeconds(1); + return ((DateTimeOffset)expectedStartTime - new DateTimeOffset(processStartTime)).Duration() <= tolerance; + } + + private const int SigTerm = 15; + + private static void RequestGracefulShutdownUnix(int pid, ILogger logger) + { + var result = kill(pid, SigTerm); + if (result != 0) + { + int errno = Marshal.GetLastSystemError(); + // Best effort. + logger.LogWarning("Could not gracefully stop Aspire application host process {Pid}; the error code from signal send operation was {ErrorCode}", pid, errno); + } + } + + private const uint CtrlBreakEvent = 1; + + private static void RequestGracefulShutdownWindows(int pid, ILogger logger) + { + var success = GenerateConsoleCtrlEvent(CtrlBreakEvent, (uint)pid); + if (!success) + { + // Best effort. + logger.LogWarning("Could not gracefully stop Aspire application host process {Pid}", pid); + } + } + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId); + + // "libc" here is a moniker for standard C library, which .NET maps to system C library on Unix-like systems. + // See https://developers.redhat.com/blog/2019/03/25/using-net-pinvoke-for-linux-system-functions + [LibraryImport("libc", SetLastError = true, EntryPoint = "kill")] + private static partial int kill(int pid, int sig); +} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/NewWithAgentInitTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/NewWithAgentInitTests.cs new file mode 100644 index 00000000000..396792aa789 --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/NewWithAgentInitTests.cs @@ -0,0 +1,166 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.EndToEnd.Tests.Helpers; +using Aspire.Cli.Tests.Utils; +using Hex1b.Automation; +using Aspire.TestUtilities; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// End-to-end test verifying that the aspire new flow with AI agent initialization +/// completes without provenance verification errors when installing @playwright/cli. +/// +[OuterloopTest("Requires npm and network access to install @playwright/cli from the npm registry")] +public sealed class NewWithAgentInitTests(ITestOutputHelper output) +{ + /// + /// Exercises the full aspire new → agent init → Playwright CLI install flow end-to-end. + /// This is the primary regression test for provenance verification failures (e.g., tag format changes + /// in upstream @playwright/cli releases). + /// + /// The test: + /// 1. Runs aspire new to create a Starter project + /// 2. Accepts the agent init prompt (instead of declining) + /// 3. Selects Playwright CLI during skill selection + /// 4. Verifies no errors appear (especially no "Provenance verification failed") + /// 5. Verifies playwright-cli is installed and skill files are generated + /// + [Fact] + public async Task AspireNew_WithAgentInit_InstallsPlaywrightWithoutErrors() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliInDockerAsync(installMode, counter); + + // Create .claude folder so agent init detects a Claude Code environment. + // This needs to exist in the workspace root before aspire new creates the project + // because agent init chains after project creation and looks for environment markers. + await auto.TypeAsync("mkdir -p .claude"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Run aspire new with the Starter template, going through all prompts manually + // so we can ACCEPT the agent init prompt instead of declining it. + await auto.TypeAsync("aspire new"); + await auto.EnterAsync(); + + // Template selection: accept default Starter App + await auto.WaitUntilAsync( + s => new CellPatternSearcher().Find("> Starter App").Search(s).Count > 0, + timeout: TimeSpan.FromSeconds(60), + description: "template selection list (> Starter App)"); + await auto.EnterAsync(); + + // Project name + await auto.WaitUntilAsync( + s => new CellPatternSearcher().Find("Enter the project name").Search(s).Count > 0, + timeout: TimeSpan.FromSeconds(10), + description: "project name prompt"); + await auto.TypeAsync("StarterApp"); + await auto.EnterAsync(); + + // Output path: accept default + await auto.WaitUntilAsync( + s => new CellPatternSearcher().Find("Enter the output path").Search(s).Count > 0, + timeout: TimeSpan.FromSeconds(10), + description: "output path prompt"); + await auto.EnterAsync(); + + // URLs prompt: accept default No + await auto.WaitUntilAsync( + s => new CellPatternSearcher().Find("Use *.dev.localhost URLs").Search(s).Count > 0, + timeout: TimeSpan.FromSeconds(10), + description: "URLs prompt"); + await auto.EnterAsync(); + + // Redis cache: accept default Yes + await auto.WaitUntilAsync( + s => new CellPatternSearcher().Find("Use Redis Cache").Search(s).Count > 0, + timeout: TimeSpan.FromSeconds(10), + description: "Redis cache prompt"); + await auto.EnterAsync(); + + // Test project: accept default No + await auto.WaitUntilAsync( + s => new CellPatternSearcher().Find("Do you want to create a test project?").Search(s).Count > 0, + timeout: TimeSpan.FromSeconds(10), + description: "test project prompt"); + await auto.EnterAsync(); + + // Agent init prompt: ACCEPT it (type 'y') + await auto.WaitUntilAsync( + s => s.ContainsText("configure AI agent environments"), + timeout: TimeSpan.FromSeconds(120), + description: "agent init prompt after aspire new"); + await auto.WaitAsync(500); + await auto.TypeAsync("y"); + await auto.EnterAsync(); + + // Agent init: workspace path - accept default + await auto.WaitUntilTextAsync("workspace:", timeout: TimeSpan.FromSeconds(30)); + await auto.WaitAsync(500); + await auto.EnterAsync(); + + // Agent init: skill location - select Claude Code + await auto.WaitUntilAsync( + s => s.ContainsText("skill files be installed"), + timeout: TimeSpan.FromSeconds(60), + description: "skill location prompt"); + await auto.TypeAsync(" "); // Toggle off default Standard location + await auto.DownAsync(); + await auto.TypeAsync(" "); // Toggle on Claude Code location + await auto.EnterAsync(); + + // Agent init: skill selection - toggle on Playwright CLI + await auto.WaitUntilAsync( + s => s.ContainsText("skills should be installed"), + timeout: TimeSpan.FromSeconds(30), + description: "skill selection prompt"); + await auto.DownAsync(); + await auto.TypeAsync(" "); // Toggle on Playwright CLI + await auto.EnterAsync(); + + // Wait for agent init to complete (downloads @playwright/cli from npm). + // Fail the test immediately if a provenance verification error appears. + await auto.WaitUntilAsync(s => + { + if (s.ContainsText("Provenance verification failed")) + { + throw new InvalidOperationException( + "Provenance verification failed for @playwright/cli! " + + "This likely means the upstream package changed its tag format."); + } + return s.ContainsText("configuration complete"); + }, timeout: TimeSpan.FromMinutes(5), description: "agent init configuration complete (no provenance errors)"); + await auto.WaitForSuccessPromptAsync(counter); + + // Verify playwright-cli is installed and functional. + await auto.TypeAsync("playwright-cli --version"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Verify skill file was generated in the Claude Code location. + await auto.TypeAsync("ls StarterApp/.claude/skills/playwright-cli/SKILL.md"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("SKILL.md", timeout: TimeSpan.FromSeconds(10)); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } +} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs index 5c896edf4d8..d35b375095e 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs @@ -20,6 +20,8 @@ public async Task CreateStartAndStopAspireProject() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + var projectSuffix = Guid.NewGuid().ToString("N")[..6]; + var projectName = $"StarterApp_{projectSuffix}"; var workspace = TemporaryWorkspace.Create(output); @@ -37,10 +39,10 @@ public async Task CreateStartAndStopAspireProject() await auto.InstallAspireCliInDockerAsync(installMode, counter); // Create a new project using aspire new - await auto.AspireNewAsync("AspireStarterApp", counter); + await auto.AspireNewAsync(projectName, counter); // Navigate to the AppHost directory - await auto.TypeAsync("cd AspireStarterApp/AspireStarterApp.AppHost"); + await auto.TypeAsync($"cd {projectName}/{projectName}.AppHost"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); @@ -56,6 +58,11 @@ public async Task CreateStartAndStopAspireProject() await auto.WaitUntilTextAsync(StopCommandStrings.AppHostStoppedSuccessfully, timeout: TimeSpan.FromMinutes(1)); await auto.WaitForSuccessPromptAsync(counter); + await auto.ClearScreenAsync(counter); + + // Ensure the test-specific Docker network is cleaned up (which signifies end of container cleanup) + await auto.ExecuteCommandUntilOutputAsync(counter, $"docker network ls --format json | grep -i -- '{projectName}' | wc -l", "0", timeout: TimeSpan.FromMinutes(2)); + // Exit the shell await auto.TypeAsync("exit"); await auto.EnterAsync(); diff --git a/tests/Aspire.Cli.Tests/Agents/PlaywrightCliInstallerTests.cs b/tests/Aspire.Cli.Tests/Agents/PlaywrightCliInstallerTests.cs index 12be67ebe90..49427e7d5f0 100644 --- a/tests/Aspire.Cli.Tests/Agents/PlaywrightCliInstallerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/PlaywrightCliInstallerTests.cs @@ -343,6 +343,40 @@ public void VerifyIntegrity_WithNonSha512Prefix_ReturnsFalse() } } + [Fact] + public async Task InstallAsync_WorkflowRefValidator_AcceptsBothTagFormats() + { + var version = SemVersion.Parse("0.1.7", SemVersionStyles.Strict); + var npmRunner = new TestNpmRunner + { + ResolveResult = new NpmPackageInfo { Version = version, Integrity = "sha512-abc123" } + }; + var provenanceChecker = new TestNpmProvenanceChecker(); + var playwrightRunner = new TestPlaywrightCliRunner(); + var installer = new PlaywrightCliInstaller(npmRunner, provenanceChecker, playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); + + await installer.InstallAsync(CreateTestContext(), CancellationToken.None); + + Assert.True(provenanceChecker.ProvenanceCalled); + Assert.NotNull(provenanceChecker.CapturedValidateWorkflowRef); + + // Accept tags without 'v' prefix (0.1.7+) + Assert.True(WorkflowRefInfo.TryParse($"refs/tags/{version}", out var refWithout)); + Assert.True(provenanceChecker.CapturedValidateWorkflowRef(refWithout!)); + + // Accept tags with 'v' prefix (pre-0.1.7) + Assert.True(WorkflowRefInfo.TryParse($"refs/tags/v{version}", out var refWith)); + Assert.True(provenanceChecker.CapturedValidateWorkflowRef(refWith!)); + + // Reject wrong version + Assert.True(WorkflowRefInfo.TryParse("refs/tags/0.2.0", out var wrongVersion)); + Assert.False(provenanceChecker.CapturedValidateWorkflowRef(wrongVersion!)); + + // Reject branch ref (not a tag) + Assert.True(WorkflowRefInfo.TryParse("refs/heads/main", out var branchRef)); + Assert.False(provenanceChecker.CapturedValidateWorkflowRef(branchRef!)); + } + [Fact] public async Task InstallAsync_WhenProvenanceCheckFails_ReturnsFalse() { @@ -577,10 +611,12 @@ private sealed class TestNpmProvenanceChecker : INpmProvenanceChecker { public ProvenanceVerificationOutcome ProvenanceOutcome { get; set; } = ProvenanceVerificationOutcome.Verified; public bool ProvenanceCalled { get; private set; } + public Func? CapturedValidateWorkflowRef { get; private set; } public Task VerifyProvenanceAsync(string packageName, string version, string expectedSourceRepository, string expectedWorkflowPath, string expectedBuildType, Func? validateWorkflowRef, CancellationToken cancellationToken, string? sriIntegrity = null) { ProvenanceCalled = true; + CapturedValidateWorkflowRef = validateWorkflowRef; return Task.FromResult(new ProvenanceVerificationResult { Outcome = ProvenanceOutcome, diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetryCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TelemetryCommandTests.cs index 205619fc810..5411b9ded3b 100644 --- a/tests/Aspire.Cli.Tests/Commands/TelemetryCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/TelemetryCommandTests.cs @@ -29,51 +29,45 @@ public async Task TelemetryCommand_WithoutSubcommand_ReturnsInvalidCommand() } [Fact] - public void BuildResourceQueryString_WithNoResources_ReturnsEmptyString() + public void TelemetryLogsApiUrl_WithNoParams_ReturnsBaseUrl() { - var result = DashboardUrls.BuildResourceQueryString(null); - Assert.Equal("", result); + var result = DashboardUrls.TelemetryLogsApiUrl("https://localhost:5000"); + Assert.Equal("https://localhost:5000/api/telemetry/logs", result); } [Fact] - public void BuildResourceQueryString_WithSingleResource_ReturnsCorrectQueryString() + public void TelemetryLogsApiUrl_WithSingleResource_ReturnsCorrectUrl() { - var result = DashboardUrls.BuildResourceQueryString(["frontend"]); - Assert.Equal("?resource=frontend", result); + var result = DashboardUrls.TelemetryLogsApiUrl("https://localhost:5000", ["frontend"]); + Assert.Equal("https://localhost:5000/api/telemetry/logs?resource=frontend", result); } [Fact] - public void BuildResourceQueryString_WithMultipleResources_ReturnsAllResourceParams() + public void TelemetryLogsApiUrl_WithMultipleResources_ReturnsAllResourceParams() { - var result = DashboardUrls.BuildResourceQueryString(["frontend-abc123", "frontend-xyz789"]); - Assert.Equal("?resource=frontend-abc123&resource=frontend-xyz789", result); + var result = DashboardUrls.TelemetryLogsApiUrl("https://localhost:5000", ["frontend-abc123", "frontend-xyz789"]); + Assert.Equal("https://localhost:5000/api/telemetry/logs?resource=frontend-abc123&resource=frontend-xyz789", result); } [Fact] - public void BuildResourceQueryString_WithResourcesAndAdditionalParams_CombinesCorrectly() + public void TelemetryLogsApiUrl_WithAllParams_CombinesCorrectly() { - var result = DashboardUrls.BuildResourceQueryString( - ["frontend"], - ("traceId", "abc123"), - ("limit", "10")); - Assert.Equal("?resource=frontend&traceId=abc123&limit=10", result); + var result = DashboardUrls.TelemetryLogsApiUrl("https://localhost:5000", ["frontend"], traceId: "abc123", severity: "Error", limit: 10, follow: true); + Assert.Equal("https://localhost:5000/api/telemetry/logs?resource=frontend&traceId=abc123&severity=Error&limit=10&follow=true", result); } [Fact] - public void BuildResourceQueryString_WithNullAdditionalParams_SkipsNullValues() + public void TelemetryLogsApiUrl_WithNullParams_SkipsNullValues() { - var result = DashboardUrls.BuildResourceQueryString( - ["frontend"], - ("traceId", null), - ("limit", "10")); - Assert.Equal("?resource=frontend&limit=10", result); + var result = DashboardUrls.TelemetryLogsApiUrl("https://localhost:5000", ["frontend"], traceId: null, limit: 10); + Assert.Equal("https://localhost:5000/api/telemetry/logs?resource=frontend&limit=10", result); } [Fact] - public void BuildResourceQueryString_WithSpecialCharacters_EncodesCorrectly() + public void TelemetryLogsApiUrl_WithSpecialCharacters_EncodesCorrectly() { - var result = DashboardUrls.BuildResourceQueryString(["service with spaces"]); - Assert.Equal("?resource=service%20with%20spaces", result); + var result = DashboardUrls.TelemetryLogsApiUrl("https://localhost:5000", ["service with spaces"]); + Assert.Equal("https://localhost:5000/api/telemetry/logs?resource=service%20with%20spaces", result); } [Fact] diff --git a/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs b/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs index 15d6995796c..adf88d43af7 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs @@ -238,7 +238,7 @@ public async Task GetSpans_WithQueryParameters_Returns200() using var httpClient = IntegrationTestHelpers.CreateHttpClient($"http://{app.FrontendSingleEndPointAccessor().EndPoint}"); // Act - test query parameters without resource filter (no resources exist in test) - var response = await httpClient.GetAsync("/api/telemetry/spans?hasError=true&limit=50").DefaultTimeout(); + var response = await httpClient.GetAsync($"/api/telemetry/spans?hasError=true&limit={int.MaxValue}").DefaultTimeout(); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -278,7 +278,7 @@ public async Task GetLogs_WithQueryParameters_Returns200() using var httpClient = IntegrationTestHelpers.CreateHttpClient($"http://{app.FrontendSingleEndPointAccessor().EndPoint}"); // Act - test query parameters without resource filter (no resources exist in test) - var response = await httpClient.GetAsync("/api/telemetry/logs?severity=Error&limit=50").DefaultTimeout(); + var response = await httpClient.GetAsync($"/api/telemetry/logs?severity=Error&limit={int.MaxValue}").DefaultTimeout(); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs index 8bd11576896..4e3138be183 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs @@ -453,6 +453,164 @@ public void GetTrace_VariousTraceIds_ReturnsExpectedResult(string lookupId, bool } } + [Fact] + public void GetSpans_WithLimit_ReturnsMostRecentSpans() + { + var repository = CreateRepository(); + + repository.AddTraces(new AddContext(), new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(name: "service1", instanceId: "inst1"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "trace1", spanId: "old-span", startTime: s_testTime, endTime: s_testTime.AddMinutes(1)), + CreateSpan(traceId: "trace2", spanId: "mid-span", startTime: s_testTime.AddMinutes(2), endTime: s_testTime.AddMinutes(3)), + CreateSpan(traceId: "trace3", spanId: "new-span", startTime: s_testTime.AddMinutes(4), endTime: s_testTime.AddMinutes(5)) + } + } + } + } + }); + + var service = CreateService(repository); + + var result = service.GetSpans(resourceNames: null, traceId: null, hasError: null, limit: 2); + + Assert.NotNull(result); + Assert.Equal(3, result.TotalCount); + Assert.Equal(2, result.ReturnedCount); + + var json = System.Text.Json.JsonSerializer.Serialize(result.Data); + Assert.DoesNotContain("old-span", json); + Assert.Contains("mid-span", json); + Assert.Contains("new-span", json); + } + + [Fact] + public void GetTraces_WithLimit_ReturnsMostRecentTraces() + { + var repository = CreateRepository(); + + for (var i = 1; i <= 3; i++) + { + repository.AddTraces(new AddContext(), new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(name: "service1", instanceId: "inst1"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: $"trace{i}", spanId: $"span{i}", startTime: s_testTime.AddMinutes(i * 10), endTime: s_testTime.AddMinutes(i * 10 + 1)) + } + } + } + } + }); + } + + var service = CreateService(repository); + + var result = service.GetTraces(resourceNames: null, hasError: null, limit: 2); + + Assert.NotNull(result); + Assert.Equal(3, result.TotalCount); + Assert.Equal(2, result.ReturnedCount); + + var json = System.Text.Json.JsonSerializer.Serialize(result.Data); + Assert.DoesNotContain("span1", json); + Assert.Contains("span2", json); + Assert.Contains("span3", json); + } + + [Fact] + public void GetLogs_WithLimit_ReturnsMostRecentLogs() + { + var repository = CreateRepository(); + + repository.AddLogs(new AddContext(), new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(name: "service1", instanceId: "inst1"), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope("TestLogger"), + LogRecords = + { + CreateLogRecord(time: s_testTime, message: "old-log", severity: SeverityNumber.Info), + CreateLogRecord(time: s_testTime.AddMinutes(1), message: "mid-log", severity: SeverityNumber.Info), + CreateLogRecord(time: s_testTime.AddMinutes(2), message: "new-log", severity: SeverityNumber.Info) + } + } + } + } + }); + + var service = CreateService(repository); + + var result = service.GetLogs(resourceNames: null, traceId: null, severity: null, limit: 2); + + Assert.NotNull(result); + Assert.Equal(3, result.TotalCount); + Assert.Equal(2, result.ReturnedCount); + + var json = System.Text.Json.JsonSerializer.Serialize(result.Data); + Assert.DoesNotContain("old-log", json); + Assert.Contains("mid-log", json); + Assert.Contains("new-log", json); + } + + [Fact] + public void GetLogs_LargeLimit_ReturnsAllLogs() + { + const int totalLogs = 20_000; + var repository = CreateRepository(maxLogCount: totalLogs); + + var logRecords = new RepeatedField(); + for (var i = 0; i < totalLogs; i++) + { + logRecords.Add(CreateLogRecord(time: s_testTime.AddMilliseconds(i), message: $"log{i}", severity: SeverityNumber.Info)); + } + + repository.AddLogs(new AddContext(), new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(name: "service1", instanceId: "inst1"), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope("TestLogger"), + LogRecords = { logRecords } + } + } + } + }); + + var service = CreateService(repository); + + var result = service.GetLogs(resourceNames: null, traceId: null, severity: null, limit: 100_000); + + Assert.NotNull(result); + Assert.Equal(totalLogs, result.TotalCount); + Assert.Equal(totalLogs, result.ReturnedCount); + } + /// /// Creates a TelemetryApiService instance for testing with optional custom dependencies. /// diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeWithProjectResources.verified.yaml b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeWithProjectResources.verified.yaml index 58de5541176..5fbf7692659 100644 --- a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeWithProjectResources.verified.yaml +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeWithProjectResources.verified.yaml @@ -33,13 +33,13 @@ services__project1__http__0: "http://project1:${PROJECT1_PORT}" PROJECT1_HTTPS: "https://project1:${PROJECT1_PORT}" PROJECT1_CUSTOM1: "http://project1:8000" - services__project1__http__1: "http://project1:8000" + services__project1__custom1__0: "http://project1:8000" PROJECT1_CUSTOM2: "http://project1:8001" - services__project1__http__2: "http://project1:8001" + services__project1__custom2__0: "http://project1:8001" PROJECT1_CUSTOM3: "http://project1:7002" - services__project1__http__3: "http://project1:7002" + services__project1__custom3__0: "http://project1:7002" PROJECT1_CUSTOM4: "http://project1:7004" - services__project1__http__4: "http://project1:7004" + services__project1__custom4__0: "http://project1:7004" networks: - "aspire" networks: diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#01.verified.yaml index 70534489e39..4de9a259cf3 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#01.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#01.verified.yaml @@ -9,10 +9,10 @@ config: ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true" api: PROJECT1_CUSTOM1: "http://project1-service:8000" - services__project1__http__1: "http://project1-service:8000" + services__project1__custom1__0: "http://project1-service:8000" PROJECT1_CUSTOM2: "http://project1-service:8001" - services__project1__http__2: "http://project1-service:8001" + services__project1__custom2__0: "http://project1-service:8001" PROJECT1_CUSTOM3: "http://project1-service:7002" - services__project1__http__3: "http://project1-service:7002" + services__project1__custom3__0: "http://project1-service:7002" PROJECT1_CUSTOM4: "http://project1-service:7004" - services__project1__http__4: "http://project1-service:7004" + services__project1__custom4__0: "http://project1-service:7004" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#06.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#06.verified.yaml index 1f6c4407daa..f691fa99a76 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#06.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#06.verified.yaml @@ -13,10 +13,10 @@ data: PROJECT1_HTTPS: "https://project1-service:{{ .Values.parameters.project1.port_http }}" services__project1__https__0: "https://project1-service:{{ .Values.parameters.project1.port_http }}" PROJECT1_CUSTOM1: "{{ .Values.config.api.PROJECT1_CUSTOM1 }}" - services__project1__http__1: "{{ .Values.config.api.services__project1__http__1 }}" + services__project1__custom1__0: "{{ .Values.config.api.services__project1__custom1__0 }}" PROJECT1_CUSTOM2: "{{ .Values.config.api.PROJECT1_CUSTOM2 }}" - services__project1__http__2: "{{ .Values.config.api.services__project1__http__2 }}" + services__project1__custom2__0: "{{ .Values.config.api.services__project1__custom2__0 }}" PROJECT1_CUSTOM3: "{{ .Values.config.api.PROJECT1_CUSTOM3 }}" - services__project1__http__3: "{{ .Values.config.api.services__project1__http__3 }}" + services__project1__custom3__0: "{{ .Values.config.api.services__project1__custom3__0 }}" PROJECT1_CUSTOM4: "{{ .Values.config.api.PROJECT1_CUSTOM4 }}" - services__project1__http__4: "{{ .Values.config.api.services__project1__http__4 }}" + services__project1__custom4__0: "{{ .Values.config.api.services__project1__custom4__0 }}" diff --git a/tests/Aspire.Hosting.Tests/WithReferenceTests.cs b/tests/Aspire.Hosting.Tests/WithReferenceTests.cs index bbfc60d94fb..7ff239e70c0 100644 --- a/tests/Aspire.Hosting.Tests/WithReferenceTests.cs +++ b/tests/Aspire.Hosting.Tests/WithReferenceTests.cs @@ -28,7 +28,7 @@ public async Task ResourceWithSingleEndpointProducesSimplifiedEnvironmentVariabl // Call environment variable callbacks. var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout(); - Assert.Equal("https://localhost:2000", config["services__projecta__https__0"]); + Assert.Equal("https://localhost:2000", config["services__projecta__mybinding__0"]); Assert.Equal("https://localhost:2000", config["PROJECTA_MYBINDING"]); Assert.True(projectB.Resource.TryGetAnnotationsOfType(out var relationships)); @@ -51,9 +51,9 @@ public async Task ResourceNamesWithDashesAreEncodedInEnvironmentVariables() var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout(); - Assert.Equal("https://localhost:2000", config["services__project-a__https__0"]); + Assert.Equal("https://localhost:2000", config["services__project-a__mybinding__0"]); Assert.Equal("https://localhost:2000", config["PROJECT_A_MYBINDING"]); - Assert.DoesNotContain("services__project_a__https__0", config.Keys); + Assert.DoesNotContain("services__project_a__mybinding__0", config.Keys); Assert.DoesNotContain("PROJECT-A_MYBINDING", config.Keys); } @@ -71,9 +71,9 @@ public async Task OverriddenServiceNamesAreEncodedInEnvironmentVariables() var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout(); - Assert.Equal("https://localhost:2000", config["services__custom-name__https__0"]); + Assert.Equal("https://localhost:2000", config["services__custom-name__mybinding__0"]); Assert.Equal("https://localhost:2000", config["custom_name_MYBINDING"]); - Assert.DoesNotContain("services__custom_name__https__0", config.Keys); + Assert.DoesNotContain("services__custom_name__mybinding__0", config.Keys); Assert.DoesNotContain("custom-name_MYBINDING", config.Keys); } @@ -104,28 +104,28 @@ public async Task ResourceWithEndpointRespectsCustomEnvironmentVariableNaming(Re switch (flags) { case ReferenceEnvironmentInjectionFlags.All: - Assert.Equal("https://localhost:2000", config["services__custom__https__0"]); + Assert.Equal("https://localhost:2000", config["services__custom__mybinding__0"]); Assert.Equal("https://localhost:2000", config["custom_MYBINDING"]); break; case ReferenceEnvironmentInjectionFlags.ConnectionProperties: Assert.False(config.ContainsKey("custom_MYBINDING")); - Assert.False(config.ContainsKey("services__custom__https__0")); + Assert.False(config.ContainsKey("services__custom__mybinding__0")); break; case ReferenceEnvironmentInjectionFlags.ConnectionString: Assert.False(config.ContainsKey("custom_MYBINDING")); - Assert.False(config.ContainsKey("services__custom__https__0")); + Assert.False(config.ContainsKey("services__custom__mybinding__0")); break; case ReferenceEnvironmentInjectionFlags.ServiceDiscovery: Assert.False(config.ContainsKey("custom_MYBINDING")); - Assert.True(config.ContainsKey("services__custom__https__0")); + Assert.True(config.ContainsKey("services__custom__mybinding__0")); break; case ReferenceEnvironmentInjectionFlags.Endpoints: Assert.True(config.ContainsKey("custom_MYBINDING")); - Assert.False(config.ContainsKey("services__custom__https__0")); + Assert.False(config.ContainsKey("services__custom__mybinding__0")); break; case ReferenceEnvironmentInjectionFlags.None: Assert.False(config.ContainsKey("custom_MYBINDING")); - Assert.False(config.ContainsKey("services__custom__https__0")); + Assert.False(config.ContainsKey("services__custom__mybinding__0")); break; } } @@ -150,8 +150,8 @@ public async Task ResourceWithConflictingEndpointsProducesFullyScopedEnvironment // Call environment variable callbacks. var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout(); - Assert.Equal("https://localhost:2000", config["services__projecta__https__0"]); - Assert.Equal("https://localhost:3000", config["services__projecta__https__1"]); + Assert.Equal("https://localhost:2000", config["services__projecta__mybinding__0"]); + Assert.Equal("https://localhost:3000", config["services__projecta__myconflictingbinding__0"]); Assert.Equal("https://localhost:2000", config["PROJECTA_MYBINDING"]); Assert.Equal("https://localhost:3000", config["PROJECTA_MYCONFLICTINGBINDING"]); @@ -178,8 +178,8 @@ public async Task ResourceWithNonConflictingEndpointsProducesAllVariantsOfEnviro // Call environment variable callbacks. var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout(); - Assert.Equal("https://localhost:2000", config["services__projecta__https__0"]); - Assert.Equal("http://localhost:3000", config["services__projecta__http__0"]); + Assert.Equal("https://localhost:2000", config["services__projecta__mybinding__0"]); + Assert.Equal("http://localhost:3000", config["services__projecta__mynonconflictingbinding__0"]); Assert.Equal("https://localhost:2000", config["PROJECTA_MYBINDING"]); Assert.Equal("http://localhost:3000", config["PROJECTA_MYNONCONFLICTINGBINDING"]); @@ -204,8 +204,8 @@ public async Task ResourceWithConflictingEndpointsProducesAllEnvironmentVariable // Call environment variable callbacks. var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout(); - Assert.Equal("https://localhost:2000", config["services__projecta__https__0"]); - Assert.Equal("https://localhost:3000", config["services__projecta__https__1"]); + Assert.Equal("https://localhost:2000", config["services__projecta__mybinding__0"]); + Assert.Equal("https://localhost:3000", config["services__projecta__mybinding2__0"]); Assert.Equal("https://localhost:2000", config["PROJECTA_MYBINDING"]); Assert.Equal("https://localhost:3000", config["PROJECTA_MYBINDING2"]); @@ -233,8 +233,8 @@ public async Task ResourceWithEndpointsProducesAllEnvironmentVariables() // Call environment variable callbacks. var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout(); - Assert.Equal("https://localhost:2000", config["services__projecta__https__0"]); - Assert.Equal("http://localhost:3000", config["services__projecta__http__0"]); + Assert.Equal("https://localhost:2000", config["services__projecta__mybinding__0"]); + Assert.Equal("http://localhost:3000", config["services__projecta__mybinding2__0"]); Assert.Equal("https://localhost:2000", config["PROJECTA_MYBINDING"]); Assert.Equal("http://localhost:3000", config["PROJECTA_MYBINDING2"]); @@ -822,11 +822,11 @@ public async Task ExcludedReferenceEndpointExcludedFromUseAllEndpoints() var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout(); // The "api" endpoint should be included (it's not excluded) - Assert.Equal("http://localhost:2000", config["services__projecta__http__0"]); + Assert.Equal("http://localhost:2000", config["services__projecta__api__0"]); Assert.Equal("http://localhost:2000", config["PROJECTA_API"]); // The "management" endpoint should NOT be included (ExcludeReferenceEndpoint = true) - Assert.False(config.ContainsKey("services__projecta__http__1")); + Assert.False(config.ContainsKey("services__projecta__management__0")); Assert.False(config.ContainsKey("PROJECTA_MANAGEMENT")); } @@ -852,7 +852,7 @@ public async Task ExcludedReferenceEndpointCanBeReferencedExplicitly() var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout(); // The "management" endpoint should be included because it was explicitly referenced - Assert.Equal("http://localhost:3000", config["services__projecta__http__0"]); + Assert.Equal("http://localhost:3000", config["services__projecta__management__0"]); Assert.Equal("http://localhost:3000", config["PROJECTA_MANAGEMENT"]); // The "api" endpoint should NOT be included (wasn't referenced) @@ -882,8 +882,8 @@ public async Task CombinedWithReferenceAndExplicitEndpointIncludesBoth() var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout(); // Both endpoints should be included - Assert.Equal("http://localhost:2000", config["services__projecta__http__0"]); - Assert.Equal("http://localhost:3000", config["services__projecta__http__1"]); + Assert.Equal("http://localhost:2000", config["services__projecta__api__0"]); + Assert.Equal("http://localhost:3000", config["services__projecta__management__0"]); Assert.Equal("http://localhost:2000", config["PROJECTA_API"]); Assert.Equal("http://localhost:3000", config["PROJECTA_MANAGEMENT"]); } @@ -930,6 +930,64 @@ public void EndpointReferenceReflectsExcludeReferenceEndpoint() Assert.True(managementRef.ExcludeReferenceEndpoint); } + [Fact] + public async Task HttpEndpointWithTlsUsesActualSchemeAsKey() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + // Endpoint named "http" but with https scheme (TLS upgrade) + var projectA = builder.AddProject("projecta") + .WithEndpoint(name: "http", scheme: "https", targetPort: 443) + .WithEndpoint("http", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 443)); + + var projectB = builder.AddProject("b").WithReference(projectA); + + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout(); + + // Endpoint named "http" but scheme is "https" — key should use actual scheme + Assert.Equal("https://localhost:443", config["services__projecta__https__0"]); + Assert.DoesNotContain("services__projecta__http__0", config.Keys); + } + + [Fact] + public async Task MultipleEndpointsWithDifferentNamesProduceDistinctKeys() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var projectA = builder.AddProject("projecta") + .WithHttpEndpoint(targetPort: 8080) + .WithEndpoint("http", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 8080)) + .WithEndpoint(name: "prometheus", scheme: "http", targetPort: 9090) + .WithEndpoint("prometheus", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 9090)); + + var projectB = builder.AddProject("b").WithReference(projectA); + + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout(); + + // "http" endpoint uses scheme key "http"; "prometheus" endpoint uses endpoint name + Assert.Equal("http://localhost:8080", config["services__projecta__http__0"]); + Assert.Equal("http://localhost:9090", config["services__projecta__prometheus__0"]); + } + + [Fact] + public async Task PerEndpointNameOverridesServiceName() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var projectA = builder.AddProject("projecta") + .WithEndpoint(name: "data", scheme: "http", targetPort: 8080) + .WithEndpoint("data", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 8080)); + + var projectB = builder.AddProject("b") + .WithReference(projectA, "projecta-data") + .WithReference(projectA.GetEndpoint("data")); + + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout(); + + // Service name overridden via WithReference(resource, name), endpoint name as scheme key + Assert.Equal("http://localhost:8080", config["services__projecta-data__data__0"]); + } + private sealed class TestResourceWithConnectionStringAndServiceDiscovery(string name) : ContainerResource(name), IResourceWithConnectionString, IResourceWithServiceDiscovery { public string? ConnectionString { get; set; } diff --git a/tests/Aspire.Hosting.Yarp.Tests/Snapshots/YarpConfigGeneratorTests.GenerateEnvVariablesConfigurationDockerCompose.verified.env b/tests/Aspire.Hosting.Yarp.Tests/Snapshots/YarpConfigGeneratorTests.GenerateEnvVariablesConfigurationDockerCompose.verified.env index 94a8bc0ab9a..f4aaf7198fb 100644 --- a/tests/Aspire.Hosting.Yarp.Tests/Snapshots/YarpConfigGeneratorTests.GenerateEnvVariablesConfigurationDockerCompose.verified.env +++ b/tests/Aspire.Hosting.Yarp.Tests/Snapshots/YarpConfigGeneratorTests.GenerateEnvVariablesConfigurationDockerCompose.verified.env @@ -38,8 +38,8 @@ services: REVERSEPROXY__ROUTES__route1__CLUSTERID: "cluster_backend" REVERSEPROXY__ROUTES__route1__TRANSFORMS__0__PathRemovePrefix: "/api" REVERSEPROXY__CLUSTERS__cluster_backend__METADATA__custom-metadata: "some-value" - REVERSEPROXY__CLUSTERS__cluster_backend__DESTINATIONS__destination1__ADDRESS: "http://_http.backend" - REVERSEPROXY__CLUSTERS__cluster_frontend__DESTINATIONS__destination1__ADDRESS: "http://_http.frontend" + REVERSEPROXY__CLUSTERS__cluster_backend__DESTINATIONS__destination1__ADDRESS: "http://backend" + REVERSEPROXY__CLUSTERS__cluster_frontend__DESTINATIONS__destination1__ADDRESS: "http://frontend" services__backend__http__0: "http://backend:8080" FRONTEND_HTTP: "http://frontend:8080" services__frontend__http__0: "http://frontend:8080" diff --git a/tests/Aspire.Hosting.Yarp.Tests/YarpClusterTests.cs b/tests/Aspire.Hosting.Yarp.Tests/YarpClusterTests.cs index 63bac388d10..36a4bf15db5 100644 --- a/tests/Aspire.Hosting.Yarp.Tests/YarpClusterTests.cs +++ b/tests/Aspire.Hosting.Yarp.Tests/YarpClusterTests.cs @@ -49,10 +49,25 @@ public void Create_YarpCluster_From_Endpoints_Without_Names() var httpsEndpoint = resource.GetEndpoint("https"); var httpCluster = new YarpCluster(httpEndpoint); - Assert.Equal("http://_http.ServiceC", httpCluster.Targets[0]); + Assert.Equal("http://ServiceC", httpCluster.Targets[0]); var httpsCluster = new YarpCluster(httpsEndpoint); - Assert.Equal("https://_https.ServiceC", httpsCluster.Targets[0]); + Assert.Equal("https://ServiceC", httpsCluster.Targets[0]); + } + + [Fact] + public void Create_YarpCluster_From_Named_Endpoint_Uses_DnsSd_Format() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + var resource = builder.AddResource(new TestResource("ServiceC")) + .WithEndpoint(name: "prometheus", scheme: "http") + .WithEndpoint(name: "management", scheme: "https"); + + var prometheusCluster = new YarpCluster(resource.GetEndpoint("prometheus")); + Assert.Equal("http://_prometheus.ServiceC", prometheusCluster.Targets[0]); + + var managementCluster = new YarpCluster(resource.GetEndpoint("management")); + Assert.Equal("https://_management.ServiceC", managementCluster.Targets[0]); } [Fact] diff --git a/tests/Shared/Hex1bAutomatorTestHelpers.cs b/tests/Shared/Hex1bAutomatorTestHelpers.cs index ffc9fa49d2a..f10eea3a160 100644 --- a/tests/Shared/Hex1bAutomatorTestHelpers.cs +++ b/tests/Shared/Hex1bAutomatorTestHelpers.cs @@ -1,7 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using Hex1b.Automation; +using Hex1b.Input; namespace Aspire.Tests.Shared; @@ -59,6 +61,186 @@ await auto.WaitUntilAsync(snapshot => counter.Increment(); } + /// + /// Repeatedly types a shell command until the first line of command output matches the expected text + /// or the timeout expires. Each attempt waits for either the first output line or the next prompt, + /// then waits for the prompt if output appeared first. + /// + internal static async Task ExecuteCommandUntilOutputAsync( + this Hex1bTerminalAutomator auto, + SequenceCounter counter, + string commandText, + string desiredOutput, + TimeSpan? timeout = null, + TimeSpan? retryInterval = null) + { + ArgumentNullException.ThrowIfNull(auto); + ArgumentNullException.ThrowIfNull(counter); + ArgumentException.ThrowIfNullOrWhiteSpace(commandText); + ArgumentException.ThrowIfNullOrWhiteSpace(desiredOutput); + + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(30); + var effectiveRetryInterval = retryInterval ?? TimeSpan.FromSeconds(5); + var stopwatch = Stopwatch.StartNew(); + var attempt = 0; + + while (stopwatch.Elapsed < effectiveTimeout) + { + attempt++; + var expectedPromptSequence = counter.Value; + var sawPrompt = false; + var firstOutputMatched = false; + + await auto.TypeAsync(commandText); + await auto.EnterAsync(); + + var remaining = effectiveTimeout - stopwatch.Elapsed; + if (remaining <= TimeSpan.Zero) + { + break; + } + + var waitThisAttempt = remaining < effectiveRetryInterval ? remaining : effectiveRetryInterval; + + try + { + await auto.WaitUntilAsync(snapshot => + { + var firstOutputLine = TryGetFirstOutputLine(snapshot, commandText); + if (firstOutputLine is not null) + { + if (IsPromptLine(firstOutputLine, expectedPromptSequence)) + { + sawPrompt = true; + return true; + } + + firstOutputMatched = firstOutputLine.Contains(desiredOutput, StringComparison.Ordinal); + return true; + } + + if (IsPromptVisible(snapshot, expectedPromptSequence)) + { + sawPrompt = true; + return true; + } + + return false; + }, timeout: waitThisAttempt, description: $"waiting for first output or prompt after '{commandText}' (attempt {attempt})"); + } + catch (TimeoutException) when (stopwatch.Elapsed < effectiveTimeout) + { + continue; + } + + if (!sawPrompt) + { + remaining = effectiveTimeout - stopwatch.Elapsed; + if (remaining <= TimeSpan.Zero) + { + break; + } + + try + { + await auto.WaitForAnyPromptAsync(counter, remaining); + sawPrompt = true; + } + catch (TimeoutException) when (stopwatch.Elapsed < effectiveTimeout) + { + continue; + } + } + else + { + counter.Increment(); + } + + if (firstOutputMatched) + { + return; // success + } + + remaining = effectiveTimeout - stopwatch.Elapsed; + if (remaining <= TimeSpan.Zero) + { + break; + } + + var delayBeforeRetry = remaining < effectiveRetryInterval ? remaining : effectiveRetryInterval; + if (delayBeforeRetry > TimeSpan.FromMilliseconds(1)) + { + await auto.WaitAsync(delayBeforeRetry); + } + } + + throw new TimeoutException( + $"Timed out after {effectiveTimeout} waiting for the first output line from '{commandText}' to contain '{desiredOutput}'."); + } + + private static bool IsPromptVisible(IHex1bTerminalRegion snapshot, int expectedPromptSequence) + { + var successSearcher = new CellPatternSearcher() + .FindPattern(expectedPromptSequence.ToString()) + .RightText(" OK] $ "); + var errorSearcher = new CellPatternSearcher() + .FindPattern(expectedPromptSequence.ToString()) + .RightText(" ERR:"); + + return successSearcher.Search(snapshot).Count > 0 || errorSearcher.Search(snapshot).Count > 0; + } + + private static bool IsPromptLine(string line, int expectedPromptSequence) + { + return line.Contains($"[{expectedPromptSequence} OK] $", StringComparison.Ordinal) || + line.Contains($"[{expectedPromptSequence} ERR:", StringComparison.Ordinal); + } + + private static string? TryGetFirstOutputLine(IHex1bTerminalRegion snapshot, string commandText) + { + var commandLineIndex = FindCommandLineIndex(snapshot, commandText); + if (commandLineIndex < 0) + { + return null; + } + + for (var lineIndex = commandLineIndex + 1; lineIndex < snapshot.Height; lineIndex++) + { + var line = snapshot.GetLineTrimmed(lineIndex); + if (!string.IsNullOrWhiteSpace(line)) + { + return line; + } + } + + return null; + } + + private static int FindCommandLineIndex(IHex1bTerminalRegion snapshot, string commandText) + { + for (var lineIndex = snapshot.Height - 1; lineIndex >= 0; lineIndex--) + { + var line = snapshot.GetLineTrimmed(lineIndex); + if (line.EndsWith(commandText, StringComparison.Ordinal) || + line.Contains($"$ {commandText}", StringComparison.Ordinal) || + line.Contains($"# {commandText}", StringComparison.Ordinal)) + { + return lineIndex; + } + } + + for (var lineIndex = snapshot.Height - 1; lineIndex >= 0; lineIndex--) + { + var line = snapshot.GetLineTrimmed(lineIndex); + if (line.Contains(commandText, StringComparison.Ordinal)) + { + return lineIndex; + } + } + + return -1; + } + /// /// Waits for a successful command prompt, but fails fast if an error prompt is detected. /// diff --git a/tests/Shared/RepoTesting/Aspire.RepoTesting.targets b/tests/Shared/RepoTesting/Aspire.RepoTesting.targets index 2f07ae0e92f..35adf7647d5 100644 --- a/tests/Shared/RepoTesting/Aspire.RepoTesting.targets +++ b/tests/Shared/RepoTesting/Aspire.RepoTesting.targets @@ -33,7 +33,7 @@ `AspireProjectOrPackageReference` - maps to projects in `src/` or `src/Components/` --> - + @@ -165,6 +165,6 @@ $(MajorVersion).$(MinorVersion).$(PatchVersion) - + diff --git a/tests/Shared/RepoTesting/Directory.Packages.Helix.props b/tests/Shared/RepoTesting/Directory.Packages.Helix.props index 26589f7f956..d2b22fcfccf 100644 --- a/tests/Shared/RepoTesting/Directory.Packages.Helix.props +++ b/tests/Shared/RepoTesting/Directory.Packages.Helix.props @@ -3,82 +3,82 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/tests/Shared/Telemetry/TelemetryTestHelpers.cs b/tests/Shared/Telemetry/TelemetryTestHelpers.cs index fd62bee78a7..16dcad85bd6 100644 --- a/tests/Shared/Telemetry/TelemetryTestHelpers.cs +++ b/tests/Shared/Telemetry/TelemetryTestHelpers.cs @@ -237,6 +237,7 @@ public static TelemetryRepository CreateRepository( int? maxAttributeLength = null, int? maxSpanEventCount = null, int? maxTraceCount = null, + int? maxLogCount = null, TimeSpan? subscriptionMinExecuteInterval = null, ILoggerFactory? loggerFactory = null, PauseManager? pauseManager = null, @@ -263,6 +264,10 @@ public static TelemetryRepository CreateRepository( { options.MaxTraceCount = maxTraceCount.Value; } + if (maxLogCount != null) + { + options.MaxLogCount = maxLogCount.Value; + } var repository = new TelemetryRepository( loggerFactory ?? NullLoggerFactory.Instance,