diff --git a/localhive.ps1 b/localhive.ps1 index b5f9693280a..9cbcf9adc4c 100644 --- a/localhive.ps1 +++ b/localhive.ps1 @@ -61,6 +61,14 @@ param( [Alias('v')] [string] $VersionSuffix, + [Alias('o')] + [string] $Output, + + [Alias('r')] + [string] $Rid, + + [switch] $Archive, + [switch] $Copy, [switch] $SkipCli, @@ -88,7 +96,10 @@ Positional parameters: Options: -Configuration (-c) Build configuration: Release or Debug -Name (-n) Hive name (default: local) + -Output (-o) Output directory for portable layout (instead of $HOME\.aspire) + -Rid (-r) Target RID for cross-platform builds (e.g. linux-x64) -VersionSuffix (-v) Prerelease version suffix (default: auto-generates local.YYYYMMDD.tHHmmss) + -Archive Create an archive (.tar.gz or .zip) of the output. Requires -Output. -Copy Copy .nupkg files instead of creating a symlink -SkipCli Skip installing the locally-built CLI to $HOME\.aspire\bin -SkipBundle Skip building and installing the bundle (aspire-managed + DCP) @@ -102,6 +113,7 @@ Examples: .\localhive.ps1 # Packs (tries Release then Debug) -> hive 'local' .\localhive.ps1 Debug # Packs Debug -> hive 'local' .\localhive.ps1 Release demo + .\localhive.ps1 -o ./aspire-linux -r linux-x64 -Archive # Portable archive for a Linux machine This will pack NuGet packages into artifacts\packages\\Shipping and create/update a hive at $HOME\.aspire\hives\ so the Aspire CLI can use it as a channel. @@ -115,6 +127,26 @@ function Write-Err { param([string]$m) Write-Error "[localhive] $m" } if ($Help) { Show-Usage; exit 0 } +# Validate flag combinations +if ($Archive -and -not $Output) { + Write-Err "-Archive requires -Output to be specified." + exit 1 +} + +if ($Rid -and $NativeAot) { + # Detect if this is a cross-OS build + $hostPrefix = if ($IsWindows) { 'win' } elseif ($IsMacOS) { 'osx' } else { 'linux' } + if (-not $Rid.StartsWith($hostPrefix)) { + Write-Err "Cross-OS native AOT builds are not supported (host=$hostPrefix, target=$Rid). Use -Rid without -NativeAot." + exit 1 + } +} + +# When -Output is specified, always copy (portable layout, no symlinks) +if ($Output) { + $Copy = $true +} + # Normalize configuration casing if provided (case-insensitive) and allow common abbreviations. if ($Configuration) { switch ($Configuration.ToLowerInvariant()) { @@ -206,7 +238,26 @@ if (-not $packages -or $packages.Count -eq 0) { } Write-Log ("Found {0} packages in {1}" -f $packages.Count, $pkgDir) -$hivesRoot = Join-Path (Join-Path $HOME '.aspire') 'hives' +# Determine the RID for the target platform (or auto-detect from host) +if ($Rid) { + $bundleRid = $Rid + Write-Log "Using target RID: $bundleRid" +} elseif ($IsWindows) { + $bundleRid = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { 'win-arm64' } else { 'win-x64' } +} elseif ($IsMacOS) { + $bundleRid = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { 'osx-arm64' } else { 'osx-x64' } +} else { + $bundleRid = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { 'linux-arm64' } else { 'linux-x64' } +} + +if ($Output) { + $aspireRoot = $Output +} else { + $aspireRoot = Join-Path $HOME '.aspire' +} +$cliBinDir = Join-Path $aspireRoot 'bin' + +$hivesRoot = Join-Path $aspireRoot 'hives' $hiveRoot = Join-Path $hivesRoot $Name $hivePath = Join-Path $hiveRoot 'packages' @@ -252,25 +303,13 @@ else { } } -# Determine the RID for the current platform -if ($IsWindows) { - $bundleRid = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { 'win-arm64' } else { 'win-x64' } -} elseif ($IsMacOS) { - $bundleRid = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { 'osx-arm64' } else { 'osx-x64' } -} else { - $bundleRid = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { 'linux-arm64' } else { 'linux-x64' } -} - -$aspireRoot = Join-Path $HOME '.aspire' -$cliBinDir = Join-Path $aspireRoot 'bin' - # Build the bundle (aspire-managed + DCP, and optionally native AOT CLI) if (-not $SkipBundle) { $bundleProjPath = Join-Path $RepoRoot "eng" "Bundle.proj" $skipNativeArg = if ($NativeAot) { '' } else { '/p:SkipNativeBuild=true' } Write-Log "Building bundle (aspire-managed + DCP$(if ($NativeAot) { ' + native AOT CLI' }))..." - $buildArgs = @($bundleProjPath, '-c', $effectiveConfig, "/p:VersionSuffix=$VersionSuffix") + $buildArgs = @($bundleProjPath, '-c', $effectiveConfig, "/p:VersionSuffix=$VersionSuffix", "/p:TargetRid=$bundleRid") if (-not $NativeAot) { $buildArgs += '/p:SkipNativeBuild=true' } @@ -305,9 +344,9 @@ if (-not $SkipBundle) { Write-Log "Bundle installed to $aspireRoot (managed/ + dcp/)" } -# Install the CLI to $HOME/.aspire/bin +# Install the CLI to $aspireRoot/bin if (-not $SkipCli) { - $cliExeName = if ($IsWindows) { 'aspire.exe' } else { 'aspire' } + $cliExeName = if ($bundleRid -like 'win-*') { 'aspire.exe' } else { 'aspire' } if ($NativeAot) { # Native AOT CLI is produced by Bundle.proj's _PublishNativeCli target @@ -315,6 +354,16 @@ if (-not $SkipCli) { if (-not (Test-Path -LiteralPath $cliPublishDir)) { $cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli" $effectiveConfig "net10.0" $bundleRid "publish" } + } elseif ($Rid) { + # Cross-RID: publish CLI for the target platform + Write-Log "Publishing Aspire CLI for target RID: $Rid" + $cliProj = Join-Path $RepoRoot "src" "Aspire.Cli" "Aspire.Cli.csproj" + $cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli" $effectiveConfig "net10.0" $Rid "publish" + & dotnet publish $cliProj -c $effectiveConfig -r $Rid --self-contained /p:PublishAot=false /p:PublishSingleFile=true "/p:VersionSuffix=$VersionSuffix" + if ($LASTEXITCODE -ne 0) { + Write-Err "CLI publish for RID $Rid failed." + exit 1 + } } else { # Framework-dependent CLI from dotnet tool build $cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli.Tool" $effectiveConfig "net10.0" "publish" @@ -377,16 +426,18 @@ if (-not $SkipCli) { $installedCliPath = Join-Path $cliBinDir $cliExeName Write-Log "Aspire CLI installed to: $installedCliPath" - # Set the channel to the local hive so templates and packages resolve from it - & $installedCliPath config set channel $Name -g 2>$null - Write-Log "Set global channel to '$Name'" - - # Check if the bin directory is in PATH - $pathSeparator = [System.IO.Path]::PathSeparator - $currentPathArray = $env:PATH.Split($pathSeparator, [StringSplitOptions]::RemoveEmptyEntries) - if ($currentPathArray -notcontains $cliBinDir) { - Write-Warn "The CLI bin directory is not in your PATH." - Write-Log "Add it to your PATH with: `$env:PATH = '$cliBinDir' + '$pathSeparator' + `$env:PATH" + if (-not $Output) { + # Set the channel to the local hive so templates and packages resolve from it + & $installedCliPath config set channel $Name -g 2>$null + Write-Log "Set global channel to '$Name'" + + # Check if the bin directory is in PATH + $pathSeparator = [System.IO.Path]::PathSeparator + $currentPathArray = $env:PATH.Split($pathSeparator, [StringSplitOptions]::RemoveEmptyEntries) + if ($currentPathArray -notcontains $cliBinDir) { + Write-Warn "The CLI bin directory is not in your PATH." + Write-Log "Add it to your PATH with: `$env:PATH = '$cliBinDir' + '$pathSeparator' + `$env:PATH" + } } } else { @@ -395,21 +446,50 @@ if (-not $SkipCli) { } } +# Create archive if requested +if ($Archive) { + if ($bundleRid -like 'win-*') { + $archivePath = "$Output.zip" + Write-Log "Creating archive: $archivePath" + Compress-Archive -Path (Join-Path $Output '*') -DestinationPath $archivePath -Force + } else { + $archivePath = "$Output.tar.gz" + Write-Log "Creating archive: $archivePath" + tar -czf $archivePath -C $Output . + } + Write-Log "Archive created: $archivePath" +} + Write-Host Write-Log 'Done.' Write-Host -Write-Log "Aspire CLI will discover a channel named '$Name' from:" -Write-Log " $hivePath" -Write-Host -Write-Log "Channel behavior: Aspire* comes from the hive; others from nuget.org." -Write-Host -if (-not $SkipCli) { - Write-Log "The locally-built CLI was installed to: $(Join-Path (Join-Path $HOME '.aspire') 'bin')" +if ($Output) { + Write-Log "Portable layout created at: $Output" + if ($Archive) { + Write-Log "Archive: $archivePath" + Write-Log "" + Write-Log "To install on the target machine:" + if ($bundleRid -like 'win-*') { + Write-Log " Expand-Archive -Path $(Split-Path $archivePath -Leaf) -DestinationPath `$HOME\.aspire" + } else { + Write-Log " mkdir -p ~/.aspire && tar -xzf $(Split-Path $archivePath -Leaf) -C ~/.aspire" + } + Write-Log " ~/.aspire/bin/aspire config set channel '$Name' -g" + } +} else { + Write-Log "Aspire CLI will discover a channel named '$Name' from:" + Write-Log " $hivePath" Write-Host -} -if (-not $SkipBundle) { - Write-Log "Bundle (aspire-managed + DCP) installed to: $(Join-Path $HOME '.aspire')" - Write-Log " The CLI at ~/.aspire/bin/ will auto-discover managed/ and dcp/ in the parent directory." + Write-Log "Channel behavior: Aspire* comes from the hive; others from nuget.org." Write-Host + if (-not $SkipCli) { + Write-Log "The locally-built CLI was installed to: $cliBinDir" + Write-Host + } + if (-not $SkipBundle) { + Write-Log "Bundle (aspire-managed + DCP) installed to: $aspireRoot" + Write-Log " The CLI at ~/.aspire/bin/ will auto-discover managed/ and dcp/ in the parent directory." + Write-Host + } + Write-Log 'The Aspire CLI discovers channels automatically from the hives directory; no extra flags are required.' } -Write-Log 'The Aspire CLI discovers channels automatically from the hives directory; no extra flags are required.' diff --git a/localhive.sh b/localhive.sh index 859865339ac..d79ad54a261 100755 --- a/localhive.sh +++ b/localhive.sh @@ -9,7 +9,10 @@ # Options: # -c, --configuration Build configuration: Release or Debug # -n, --name Hive name (default: local) +# -o, --output Output directory for portable layout (instead of $HOME/.aspire) +# -r, --rid Target RID for cross-platform builds (e.g. linux-x64) # -v, --versionsuffix Prerelease version suffix (default: auto-generates local.YYYYMMDD.tHHmmss) +# --archive Create a .tar.gz (or .zip for win-* RIDs) archive of the output. Requires --output. # --copy Copy .nupkg files instead of creating a symlink # --skip-cli Skip installing the locally-built CLI to $HOME/.aspire/bin # --skip-bundle Skip building and installing the bundle (aspire-managed + DCP) @@ -32,7 +35,10 @@ Usage: Options: -c, --configuration Build configuration: Release or Debug -n, --name Hive name (default: local) + -o, --output Output directory for portable layout (instead of \$HOME/.aspire) + -r, --rid Target RID for cross-platform builds (e.g. linux-x64) -v, --versionsuffix Prerelease version suffix (default: auto-generates local.YYYYMMDD.tHHmmss) + --archive Create a .tar.gz (or .zip for win-* RIDs) archive of the output. Requires --output. --copy Copy .nupkg files instead of creating a symlink --skip-cli Skip installing the locally-built CLI to \$HOME/.aspire/bin --skip-bundle Skip building and installing the bundle (aspire-managed + DCP) @@ -44,6 +50,7 @@ Examples: ./localhive.sh Debug my-feature ./localhive.sh -c Release -n demo -v local.20250811.t033324 ./localhive.sh --skip-cli + ./localhive.sh -o /tmp/aspire-linux -r linux-x64 --archive # Portable archive for a Linux machine This will pack NuGet packages into artifacts/packages//Shipping and create/update a hive at \$HOME/.aspire/hives/ so the Aspire CLI can use it as a channel. @@ -78,6 +85,9 @@ SKIP_CLI=0 SKIP_BUNDLE=0 NATIVE_AOT=0 VERSION_SUFFIX="" +OUTPUT_DIR="" +TARGET_RID="" +ARCHIVE=0 is_valid_versionsuffix() { local s="$1" # Must be dot-separated identifiers containing only 0-9A-Za-z- per SemVer2. @@ -111,6 +121,14 @@ while [[ $# -gt 0 ]]; do -v|--versionsuffix) if [[ $# -lt 2 ]]; then error "Missing value for $1"; exit 1; fi VERSION_SUFFIX="$2"; shift 2 ;; + -o|--output) + if [[ $# -lt 2 ]]; then error "Missing value for $1"; exit 1; fi + OUTPUT_DIR="$2"; shift 2 ;; + -r|--rid) + if [[ $# -lt 2 ]]; then error "Missing value for $1"; exit 1; fi + TARGET_RID="$2"; shift 2 ;; + --archive) + ARCHIVE=1; shift ;; --copy) USE_COPY=1; shift ;; --skip-cli) @@ -131,6 +149,31 @@ while [[ $# -gt 0 ]]; do esac done +# Validate flag combinations +if [[ $ARCHIVE -eq 1 ]] && [[ -z "$OUTPUT_DIR" ]]; then + error "--archive requires --output to be specified." + exit 1 +fi + +if [[ -n "$TARGET_RID" ]] && [[ $NATIVE_AOT -eq 1 ]]; then + # Detect if this is a cross-OS build (e.g. building linux-x64 on macOS) + HOST_OS="$(uname -s)" + case "$HOST_OS" in + Darwin) HOST_PREFIX="osx" ;; + Linux) HOST_PREFIX="linux" ;; + *) HOST_PREFIX="win" ;; + esac + if [[ "$TARGET_RID" != "$HOST_PREFIX"* ]]; then + error "Cross-OS native AOT builds are not supported (host=$HOST_PREFIX, target=$TARGET_RID). Use --rid without --native-aot." + exit 1 + fi +fi + +# When --output is specified, always copy (portable layout, no symlinks) +if [[ -n "$OUTPUT_DIR" ]]; then + USE_COPY=1 +fi + # Normalize config value if set if [[ -n "$CONFIG" ]]; then case "${CONFIG,,}" in @@ -192,7 +235,33 @@ if [[ $pkg_count -eq 0 ]]; then fi log "Found $pkg_count packages in $PKG_DIR" -HIVES_ROOT="$HOME/.aspire/hives" +# Determine the RID for the current platform (or use --rid override) +if [[ -n "$TARGET_RID" ]]; then + BUNDLE_RID="$TARGET_RID" + log "Using target RID: $BUNDLE_RID" +else + ARCH=$(uname -m) + case "$(uname -s)" in + Darwin) + if [[ "$ARCH" == "arm64" ]]; then BUNDLE_RID="osx-arm64"; else BUNDLE_RID="osx-x64"; fi + ;; + Linux) + if [[ "$ARCH" == "aarch64" ]]; then BUNDLE_RID="linux-arm64"; else BUNDLE_RID="linux-x64"; fi + ;; + *) + BUNDLE_RID="linux-x64" + ;; + esac +fi + +if [[ -n "$OUTPUT_DIR" ]]; then + ASPIRE_ROOT="$OUTPUT_DIR" +else + ASPIRE_ROOT="$HOME/.aspire" +fi +CLI_BIN_DIR="$ASPIRE_ROOT/bin" + +HIVES_ROOT="$ASPIRE_ROOT/hives" HIVE_ROOT="$HIVES_ROOT/$HIVE_NAME" HIVE_PATH="$HIVE_ROOT/packages" @@ -206,9 +275,12 @@ if [ -e "$HIVE_ROOT" ] || [ -L "$HIVE_ROOT" ]; then fi if [[ $USE_COPY -eq 1 ]]; then - log "Populating hive '$HIVE_NAME' by copying .nupkg files" + log "Populating hive '$HIVE_NAME' by copying .nupkg files (version suffix: $VERSION_SUFFIX)" mkdir -p "$HIVE_PATH" - cp -f "$PKG_DIR"/*.nupkg "$HIVE_PATH"/ 2>/dev/null || true + # Only copy packages matching the current version suffix to avoid accumulating stale packages + for pkg in "$PKG_DIR"/*"$VERSION_SUFFIX"*.nupkg; do + [ -f "$pkg" ] && cp -f "$pkg" "$HIVE_PATH"/ + done log "Created/updated hive '$HIVE_NAME' at $HIVE_PATH (copied packages)." else log "Linking hive '$HIVE_NAME/packages' to $PKG_DIR" @@ -223,33 +295,16 @@ else fi fi -# Determine the RID for the current platform -ARCH=$(uname -m) -case "$(uname -s)" in - Darwin) - if [[ "$ARCH" == "arm64" ]]; then BUNDLE_RID="osx-arm64"; else BUNDLE_RID="osx-x64"; fi - ;; - Linux) - if [[ "$ARCH" == "aarch64" ]]; then BUNDLE_RID="linux-arm64"; else BUNDLE_RID="linux-x64"; fi - ;; - *) - BUNDLE_RID="linux-x64" - ;; -esac - -ASPIRE_ROOT="$HOME/.aspire" -CLI_BIN_DIR="$ASPIRE_ROOT/bin" - # Build the bundle (aspire-managed + DCP, and optionally native AOT CLI) if [[ $SKIP_BUNDLE -eq 0 ]]; then BUNDLE_PROJ="$REPO_ROOT/eng/Bundle.proj" if [[ $NATIVE_AOT -eq 1 ]]; then log "Building bundle (aspire-managed + DCP + native AOT CLI)..." - dotnet build "$BUNDLE_PROJ" -c "$EFFECTIVE_CONFIG" "/p:VersionSuffix=$VERSION_SUFFIX" + dotnet build "$BUNDLE_PROJ" -c "$EFFECTIVE_CONFIG" "/p:VersionSuffix=$VERSION_SUFFIX" "/p:TargetRid=$BUNDLE_RID" else log "Building bundle (aspire-managed + DCP)..." - dotnet build "$BUNDLE_PROJ" -c "$EFFECTIVE_CONFIG" /p:SkipNativeBuild=true "/p:VersionSuffix=$VERSION_SUFFIX" + dotnet build "$BUNDLE_PROJ" -c "$EFFECTIVE_CONFIG" /p:SkipNativeBuild=true "/p:VersionSuffix=$VERSION_SUFFIX" "/p:TargetRid=$BUNDLE_RID" fi if [[ $? -ne 0 ]]; then error "Bundle build failed." @@ -285,7 +340,7 @@ if [[ $SKIP_BUNDLE -eq 0 ]]; then log "Bundle installed to $ASPIRE_ROOT (managed/ + dcp/)" fi -# Install the CLI to $HOME/.aspire/bin +# Install the CLI to $ASPIRE_ROOT/bin if [[ $SKIP_CLI -eq 0 ]]; then if [[ $NATIVE_AOT -eq 1 ]]; then # Native AOT CLI from Bundle.proj publish @@ -293,6 +348,13 @@ if [[ $SKIP_CLI -eq 0 ]]; then if [[ ! -d "$CLI_PUBLISH_DIR" ]]; then CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli/$EFFECTIVE_CONFIG/net10.0/$BUNDLE_RID/publish" fi + elif [[ -n "$TARGET_RID" ]]; then + # Cross-RID: publish CLI for the target platform + log "Publishing Aspire CLI for target RID: $TARGET_RID" + CLI_PROJ="$REPO_ROOT/src/Aspire.Cli/Aspire.Cli.csproj" + CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli/$EFFECTIVE_CONFIG/net10.0/$TARGET_RID/publish" + dotnet publish "$CLI_PROJ" -c "$EFFECTIVE_CONFIG" -r "$TARGET_RID" --self-contained \ + /p:PublishAot=false /p:PublishSingleFile=true "/p:VersionSuffix=$VERSION_SUFFIX" else # Framework-dependent CLI from dotnet tool build CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli.Tool/$EFFECTIVE_CONFIG/net10.0/publish" @@ -319,16 +381,18 @@ if [[ $SKIP_CLI -eq 0 ]]; then log "Aspire CLI installed to: $CLI_BIN_DIR/aspire" - if "$CLI_BIN_DIR/aspire" config set channel "$HIVE_NAME" -g >/dev/null 2>&1; then - log "Set global channel to '$HIVE_NAME'" - else - warn "Failed to set global channel to '$HIVE_NAME'. Run: aspire config set channel '$HIVE_NAME' -g" - fi + if [[ -z "$OUTPUT_DIR" ]]; then + if "$CLI_BIN_DIR/aspire" config set channel "$HIVE_NAME" -g >/dev/null 2>&1; then + log "Set global channel to '$HIVE_NAME'" + else + warn "Failed to set global channel to '$HIVE_NAME'. Run: aspire config set channel '$HIVE_NAME' -g" + fi - # Check if the bin directory is in PATH - if [[ ":$PATH:" != *":$CLI_BIN_DIR:"* ]]; then - warn "The CLI bin directory is not in your PATH." - log "Add it to your PATH with: export PATH=\"$CLI_BIN_DIR:\$PATH\"" + # Check if the bin directory is in PATH + if [[ ":$PATH:" != *":$CLI_BIN_DIR:"* ]]; then + warn "The CLI bin directory is not in your PATH." + log "Add it to your PATH with: export PATH=\"$CLI_BIN_DIR:\$PATH\"" + fi fi else warn "Could not find CLI at $CLI_SOURCE_PATH. Skipping CLI installation." @@ -336,21 +400,48 @@ if [[ $SKIP_CLI -eq 0 ]]; then fi fi +# Create archive if requested +if [[ $ARCHIVE -eq 1 ]]; then + # Resolve to absolute path before cd to avoid relative path issues + ARCHIVE_BASE="$(cd "$(dirname "$OUTPUT_DIR")" && pwd)/$(basename "$OUTPUT_DIR")" + if [[ "$BUNDLE_RID" == win-* ]]; then + ARCHIVE_PATH="${ARCHIVE_BASE}.zip" + log "Creating archive: $ARCHIVE_PATH" + (cd "$OUTPUT_DIR" && zip -r "$ARCHIVE_PATH" .) + else + ARCHIVE_PATH="${ARCHIVE_BASE}.tar.gz" + log "Creating archive: $ARCHIVE_PATH" + tar -czf "$ARCHIVE_PATH" -C "$OUTPUT_DIR" . + fi + log "Archive created: $ARCHIVE_PATH" +fi + echo log "Done." echo -log "Aspire CLI will discover a channel named '$HIVE_NAME' from:" -log " $HIVE_PATH" -echo -log "Channel behavior: Aspire* comes from the hive; others from nuget.org." -echo -if [[ $SKIP_CLI -eq 0 ]]; then - log "The locally-built CLI was installed to: $HOME/.aspire/bin" +if [[ -n "$OUTPUT_DIR" ]]; then + log "Portable layout created at: $OUTPUT_DIR" + if [[ $ARCHIVE -eq 1 ]]; then + log "Archive: $ARCHIVE_PATH" + log "" + log "To install on the target machine:" + log " mkdir -p ~/.aspire && tar -xzf $(basename "$ARCHIVE_PATH") -C ~/.aspire" + log " ~/.aspire/bin/aspire config set channel '$HIVE_NAME' -g" + fi +else + log "Aspire CLI will discover a channel named '$HIVE_NAME' from:" + log " $HIVE_PATH" echo -fi -if [[ $SKIP_BUNDLE -eq 0 ]]; then - log "Bundle (aspire-managed + DCP) installed to: $HOME/.aspire" - log " The CLI at ~/.aspire/bin/ will auto-discover managed/ and dcp/ in the parent directory." + log "Channel behavior: Aspire* comes from the hive; others from nuget.org." echo + if [[ $SKIP_CLI -eq 0 ]]; then + log "The locally-built CLI was installed to: $ASPIRE_ROOT/bin" + echo + fi + if [[ $SKIP_BUNDLE -eq 0 ]]; then + log "Bundle (aspire-managed + DCP) installed to: $ASPIRE_ROOT" + log " The CLI at ~/.aspire/bin/ will auto-discover managed/ and dcp/ in the parent directory." + echo + fi + log "The Aspire CLI discovers channels automatically from the hives directory; no extra flags are required." fi -log "The Aspire CLI discovers channels automatically from the hives directory; no extra flags are required." diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index e1ae8a44e13..fb338ae871e 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -78,6 +78,7 @@ + diff --git a/src/Aspire.Cli/JsonSourceGenerationContext.cs b/src/Aspire.Cli/JsonSourceGenerationContext.cs index b0fadbcf717..814b794e0e8 100644 --- a/src/Aspire.Cli/JsonSourceGenerationContext.cs +++ b/src/Aspire.Cli/JsonSourceGenerationContext.cs @@ -27,7 +27,6 @@ namespace Aspire.Cli; [JsonSerializable(typeof(DoctorCheckResponse))] [JsonSerializable(typeof(EnvironmentCheckResult))] [JsonSerializable(typeof(DoctorCheckSummary))] -[JsonSerializable(typeof(ContainerVersionJson))] [JsonSerializable(typeof(AspireJsonConfiguration))] [JsonSerializable(typeof(AspireConfigFile))] [JsonSerializable(typeof(List))] diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs index dfa04cf884d..c1ab8c0cb81 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs @@ -1,10 +1,7 @@ // 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.Text.Json; -using System.Text.Json.Serialization; -using System.Text.RegularExpressions; +using Aspire.Shared; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Utils.EnvironmentChecker; @@ -12,9 +9,8 @@ namespace Aspire.Cli.Utils.EnvironmentChecker; /// /// Checks if a container runtime (Docker or Podman) is available and running. /// -internal sealed partial class ContainerRuntimeCheck(ILogger logger) : IEnvironmentCheck +internal sealed class ContainerRuntimeCheck(ILogger logger) : IEnvironmentCheck { - private static readonly TimeSpan s_processTimeout = TimeSpan.FromSeconds(10); /// /// Minimum Docker version required for Aspire. @@ -32,41 +28,58 @@ public async Task> CheckAsync(Cancellation { try { - // Try Docker first, then Podman - var dockerCheck = await CheckSpecificContainerRuntimeAsync("Docker", cancellationToken); - if (dockerCheck.Status == EnvironmentCheckStatus.Pass) + // Probe all runtimes in parallel + var dockerTask = ContainerRuntimeDetector.CheckRuntimeAsync("docker", "Docker", isDefault: true, logger, cancellationToken); + var podmanTask = ContainerRuntimeDetector.CheckRuntimeAsync("podman", "Podman", isDefault: false, logger, cancellationToken); + var runtimes = await Task.WhenAll(dockerTask, podmanTask); + + var configuredRuntime = Environment.GetEnvironmentVariable("ASPIRE_CONTAINER_RUNTIME") + ?? Environment.GetEnvironmentVariable("DOTNET_ASPIRE_CONTAINER_RUNTIME"); + + // Select best from already-probed results (no re-probing) + ContainerRuntimeInfo? selected; + if (configuredRuntime is not null) { - return [dockerCheck]; + selected = runtimes.FirstOrDefault(r => + string.Equals(r.Executable, configuredRuntime, StringComparison.OrdinalIgnoreCase)); } - - var podmanCheck = await CheckSpecificContainerRuntimeAsync("Podman", cancellationToken); - if (podmanCheck.Status == EnvironmentCheckStatus.Pass) + else { - return [podmanCheck]; + selected = ContainerRuntimeDetector.FindBestRuntime(runtimes); } - // If Docker is installed but not running, prefer showing that error - if (dockerCheck.Status == EnvironmentCheckStatus.Warning) + var results = new List(); + + // Only report runtimes that are installed (or explicitly configured) + foreach (var info in runtimes) { - return [dockerCheck]; + if (!info.IsInstalled && (configuredRuntime is null || + !string.Equals(info.Executable, configuredRuntime, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + var isSelected = selected is not null && + string.Equals(info.Executable, selected.Executable, StringComparison.OrdinalIgnoreCase); + + results.Add(BuildRuntimeResult(info, isSelected, configuredRuntime, cancellationToken)); } - // If Podman is installed but not running, show that - if (podmanCheck.Status == EnvironmentCheckStatus.Warning) + // If nothing is available, show a single failure + if (results.Count == 0) { - return [podmanCheck]; + results.Add(new EnvironmentCheckResult + { + Category = "container", + Name = "container-runtime", + Status = EnvironmentCheckStatus.Fail, + Message = "No container runtime detected", + Fix = "Install Docker Desktop: https://www.docker.com/products/docker-desktop or Podman: https://podman.io/getting-started/installation", + Link = "https://aka.ms/dotnet/aspire/containers" + }); } - // Neither found - return [new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Fail, - Message = "No container runtime detected", - Fix = "Install Docker Desktop: https://www.docker.com/products/docker-desktop or Podman: https://podman.io/getting-started/installation", - Link = "https://aka.ms/dotnet/aspire/containers" - }]; + return results; } catch (Exception ex) { @@ -82,305 +95,154 @@ public async Task> CheckAsync(Cancellation } } - private async Task CheckSpecificContainerRuntimeAsync(string runtime, CancellationToken cancellationToken) + /// + /// Applies Aspire-specific policy checks (minimum version, Windows containers, tunnel) + /// using version info already gathered by the detector. No process spawning. + /// + private static EnvironmentCheckResult? CheckRuntimePolicy(ContainerRuntimeInfo info) { - try - { - // Check if runtime is installed and get version using JSON format (use lowercase for process name) - var runtimeLower = runtime.ToLowerInvariant(); - var versionProcessInfo = new ProcessStartInfo - { - FileName = runtimeLower, - Arguments = "version -f json", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var versionProcess = Process.Start(versionProcessInfo); - if (versionProcess is null) - { - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Fail, - Message = $"{runtime} not found" - }; - } - - using var versionTimeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - versionTimeoutCts.CancelAfter(s_processTimeout); - - string versionOutput; - try - { - versionOutput = await versionProcess.StandardOutput.ReadToEndAsync(versionTimeoutCts.Token); - await versionProcess.WaitForExitAsync(versionTimeoutCts.Token); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - versionProcess.Kill(); - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Warning, - Message = $"{runtime} check timed out", - Fix = GetContainerRuntimeStartupAdvice(runtime), - Link = "https://aka.ms/dotnet/aspire/containers" - }; - } - - // Parse the version from JSON output first, even if the command failed - // (docker version -f json outputs client info even when daemon is not running) - var versionInfo = ContainerVersionInfo.Parse(versionOutput); - var clientVersion = versionInfo.ClientVersion; - var serverVersion = versionInfo.ServerVersion; - var context = versionInfo.Context; - var serverOs = versionInfo.ServerOs; - - // Determine if this is Docker Desktop based on context - var isDockerDesktop = runtime == "Docker" && - context is not null && - context.Contains("desktop", StringComparison.OrdinalIgnoreCase); - - // Note: docker/podman version -f json returns exit code != 0 when daemon is not running, - // but still outputs client version info including the context - if (versionProcess.ExitCode != 0) - { - // If we got client info from JSON, CLI is installed but daemon isn't running - if (clientVersion is not null || isDockerDesktop) - { - var runtimeDescription = isDockerDesktop ? "Docker Desktop" : runtime; - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Warning, - Message = $"{runtimeDescription} is installed but not running", - Fix = GetContainerRuntimeStartupAdvice(runtime, isDockerDesktop), - Link = "https://aka.ms/dotnet/aspire/containers" - }; - } - - // Couldn't get client info, check if CLI is installed separately - var isCliInstalled = await IsCliInstalledAsync(runtimeLower, cancellationToken); - if (isCliInstalled) - { - // CLI is installed but daemon isn't running - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Warning, - Message = $"{runtime} is installed but the daemon is not running", - Fix = GetContainerRuntimeStartupAdvice(runtime), - Link = "https://aka.ms/dotnet/aspire/containers" - }; - } - - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Fail, - Message = $"{runtime} not found", - Fix = GetContainerRuntimeInstallationLink(runtime), - Link = "https://aka.ms/dotnet/aspire/containers" - }; - } + var minimumVersion = GetMinimumVersion(info.Name); - // Fall back to text parsing if JSON parsing failed - if (clientVersion is null) - { - clientVersion = ParseVersionFromOutput(versionOutput); - } - - var minimumVersion = GetMinimumVersion(runtime); - - // Check if client version meets minimum requirement - if (clientVersion is not null && minimumVersion is not null) - { - if (clientVersion < minimumVersion) - { - var minVersionString = GetMinimumVersionString(runtime); - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Warning, - Message = $"{runtime} client version {clientVersion} is below the minimum required version {minVersionString}", - Fix = GetContainerRuntimeUpgradeAdvice(runtime), - Link = "https://aka.ms/dotnet/aspire/containers" - }; - } - } + // Check minimum client version + if (info.ClientVersion is not null && minimumVersion is not null && info.ClientVersion < minimumVersion) + { + return WarningResult( + $"{info.Name} client version {info.ClientVersion} is below minimum required {GetMinimumVersionString(info.Name)}", + GetContainerRuntimeUpgradeAdvice(info.Name)); + } - // For Docker, also check server version if available - if (runtime == "Docker" && serverVersion is not null && minimumVersion is not null) - { - if (serverVersion < minimumVersion) - { - var minVersionString = GetMinimumVersionString(runtime); - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Warning, - Message = $"{runtime} server version {serverVersion} is below the minimum required version {minVersionString}", - Fix = GetContainerRuntimeUpgradeAdvice(runtime), - Link = "https://aka.ms/dotnet/aspire/containers" - }; - } - } + // Check minimum server version (Docker only) + if (info.Name == "Docker" && info.ServerVersion is not null && minimumVersion is not null && info.ServerVersion < minimumVersion) + { + return WarningResult( + $"{info.Name} server version {info.ServerVersion} is below minimum required {GetMinimumVersionString(info.Name)}", + GetContainerRuntimeUpgradeAdvice(info.Name)); + } - // Runtime is installed, check if it's running - var psProcessInfo = new ProcessStartInfo + // Docker-specific: check Windows container mode + if (info.Name == "Docker" && string.Equals(info.ServerOs, "windows", StringComparison.OrdinalIgnoreCase)) + { + var runtimeName = info.IsDockerDesktop ? "Docker Desktop" : "Docker"; + return new EnvironmentCheckResult { - FileName = runtimeLower, - Arguments = "ps", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true + Category = "container", + Name = "container-runtime", + Status = EnvironmentCheckStatus.Fail, + Message = $"{runtimeName} is running in Windows container mode", + Details = "Aspire requires Linux containers. Windows containers are not supported.", + Fix = "Switch Docker Desktop to Linux containers mode (right-click Docker tray icon → 'Switch to Linux containers...')", + Link = "https://aka.ms/dotnet/aspire/containers" }; + } - using var psProcess = Process.Start(psProcessInfo); - if (psProcess is null) - { - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Warning, - Message = $"{runtime} installed but daemon not reachable", - Fix = GetContainerRuntimeStartupAdvice(runtime), - Link = "https://aka.ms/dotnet/aspire/containers" - }; - } - - using var psTimeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - psTimeoutCts.CancelAfter(s_processTimeout); - - try - { - await psProcess.WaitForExitAsync(psTimeoutCts.Token); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - psProcess.Kill(); - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Warning, - Message = $"{runtime} daemon not responding", - Fix = GetContainerRuntimeStartupAdvice(runtime), - Link = "https://aka.ms/dotnet/aspire/containers" - }; - } - - if (psProcess.ExitCode != 0) + // Docker Engine (not Desktop): check tunnel + if (info.Name == "Docker" && !info.IsDockerDesktop) + { + var tunnelEnabled = Environment.GetEnvironmentVariable("ASPIRE_ENABLE_CONTAINER_TUNNEL"); + if (!string.Equals(tunnelEnabled, "true", StringComparison.OrdinalIgnoreCase)) { - var runtimeDescription = isDockerDesktop ? "Docker Desktop" : runtime; + var versionSuffix = info.ClientVersion is not null ? $" (version {info.ClientVersion})" : ""; return new EnvironmentCheckResult { Category = "container", Name = "container-runtime", Status = EnvironmentCheckStatus.Warning, - Message = $"{runtimeDescription} is installed but not running", - Fix = GetContainerRuntimeStartupAdvice(runtime, isDockerDesktop), - Link = "https://aka.ms/dotnet/aspire/containers" - }; - } - - // Return pass with version info if available - var versionSuffix = clientVersion is not null ? $" (version {clientVersion})" : string.Empty; - var runtimeName = isDockerDesktop ? "Docker Desktop" : runtime; - - // Check if Docker is running in Windows container mode (only Linux containers are supported) - if (runtime == "Docker" && string.Equals(serverOs, "windows", StringComparison.OrdinalIgnoreCase)) - { - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Fail, - Message = $"{runtimeName} is running in Windows container mode{versionSuffix}", - Details = "Aspire requires Linux containers. Windows containers are not supported.", - Fix = "Switch Docker Desktop to Linux containers mode (right-click Docker tray icon → 'Switch to Linux containers...')", - Link = "https://aka.ms/dotnet/aspire/containers" + Message = $"Docker Engine detected{versionSuffix}. Aspire's container tunnel is required to allow containers to reach applications running on the host", + Fix = "Set environment variable: ASPIRE_ENABLE_CONTAINER_TUNNEL=true", + Link = "https://aka.ms/aspire-prerequisites#docker-engine" }; } + } - // For Docker Engine (not Desktop), check tunnel configuration - if (runtime == "Docker" && !isDockerDesktop) - { - var tunnelEnabled = Environment.GetEnvironmentVariable("ASPIRE_ENABLE_CONTAINER_TUNNEL"); - if (!string.Equals(tunnelEnabled, "true", StringComparison.OrdinalIgnoreCase)) - { - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Warning, - Message = $"Docker Engine detected{versionSuffix}. Aspire's container tunnel is required to allow containers to reach applications running on the host", - Fix = "Set environment variable: ASPIRE_ENABLE_CONTAINER_TUNNEL=true", - Link = "https://aka.ms/aspire-prerequisites#docker-engine" - }; - } + return null; // No issues + } - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Pass, - Message = $"Docker Engine detected and running{versionSuffix} with container tunnel enabled" - }; - } + private static EnvironmentCheckResult WarningResult(string message, string fix) => new() + { + Category = "container", + Name = "container-runtime", + Status = EnvironmentCheckStatus.Warning, + Message = message, + Fix = fix, + Link = "https://aka.ms/dotnet/aspire/containers" + }; + + private static EnvironmentCheckResult BuildRuntimeResult( + ContainerRuntimeInfo info, + bool isSelected, + string? configuredRuntime, + CancellationToken _) + { + var selectedSuffix = isSelected ? " ← active" : ""; + if (!info.IsInstalled) + { + // Only reached for explicitly configured runtimes return new EnvironmentCheckResult { Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Pass, - Message = $"{runtimeName} detected and running{versionSuffix}" + Name = info.Executable, + Status = EnvironmentCheckStatus.Fail, + Message = $"{info.Name}: not found (configured via ASPIRE_CONTAINER_RUNTIME={configuredRuntime})", + Fix = GetContainerRuntimeInstallationLink(info.Name) }; } - catch (Exception ex) + + if (!info.IsRunning) { - logger.LogDebug(ex, "Error checking {Runtime}", runtime); return new EnvironmentCheckResult { Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Fail, - Message = $"Failed to check {runtime}" + Name = info.Executable, + Status = EnvironmentCheckStatus.Warning, + Message = $"{info.Name}: installed but not running{selectedSuffix}", + Fix = GetContainerRuntimeStartupAdvice(info.Name, info.IsDockerDesktop) }; } - } - /// - /// Parses a version number from container runtime output as a fallback when JSON parsing fails. - /// - internal static Version? ParseVersionFromOutput(string output) - { - if (string.IsNullOrWhiteSpace(output)) + // Runtime is healthy — apply Aspire-specific policy checks (no process spawning) + var policyResult = CheckRuntimePolicy(info); + if (policyResult is not null) { - return null; + // Append selection info to the policy result message + return new EnvironmentCheckResult + { + Category = policyResult.Category, + Name = policyResult.Name, + Status = policyResult.Status, + Message = policyResult.Message + selectedSuffix, + Fix = policyResult.Fix, + Details = policyResult.Details, + Link = policyResult.Link + }; } - // Match version patterns like "20.10.17", "4.3.1", "27.5.1" etc. - // The pattern looks for "version" followed by a version number - var match = VersionRegex().Match(output); - if (match.Success && Version.TryParse(match.Groups[1].Value, out var version)) + // Explain why this runtime was chosen + var reason = configuredRuntime is not null && isSelected + ? $"configured via ASPIRE_CONTAINER_RUNTIME={configuredRuntime}" + : isSelected && info.IsDefault ? "auto-detected (default)" + : isSelected ? "auto-detected (only runtime running)" + : "available"; + + var versionSuffix = info.ClientVersion is not null ? $" v{info.ClientVersion}" : ""; + + return new EnvironmentCheckResult { - return version; - } + Category = "container", + Name = info.Executable, + Status = EnvironmentCheckStatus.Pass, + Message = $"{info.Name}{versionSuffix}: running ({reason}){selectedSuffix}" + }; + } - return null; + private static string GetContainerRuntimeInstallationLink(string runtime) + { + return runtime switch + { + "Docker" => "Install Docker Desktop: https://www.docker.com/products/docker-desktop", + "Podman" => "Install Podman: https://podman.io/getting-started/installation", + _ => $"Install {runtime}" + }; } /// @@ -421,19 +283,6 @@ private static string GetContainerRuntimeUpgradeAdvice(string runtime) }; } - [GeneratedRegex(@"version\s+(\d+\.\d+(?:\.\d+)?)", RegexOptions.IgnoreCase)] - private static partial Regex VersionRegex(); - - private static string GetContainerRuntimeInstallationLink(string runtime) - { - return runtime switch - { - "Docker" => "Install Docker Desktop from: https://www.docker.com/products/docker-desktop", - "Podman" => "Install Podman from: https://podman.io/getting-started/installation", - _ => $"Install {runtime}" - }; - } - private static string GetContainerRuntimeStartupAdvice(string runtime, bool isDockerDesktop = false) { return runtime switch @@ -444,127 +293,4 @@ private static string GetContainerRuntimeStartupAdvice(string runtime, bool isDo _ => $"Start {runtime} daemon" }; } - - /// - /// Checks if the container runtime CLI is installed by running --version (which doesn't require daemon). - /// - private async Task IsCliInstalledAsync(string runtimeLower, CancellationToken cancellationToken) - { - try - { - var processInfo = new ProcessStartInfo - { - FileName = runtimeLower, - Arguments = "--version", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var process = Process.Start(processInfo); - if (process is null) - { - return false; - } - - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(s_processTimeout); - - try - { - await process.WaitForExitAsync(timeoutCts.Token); - return process.ExitCode == 0; - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - process.Kill(); - return false; - } - } - catch (Exception ex) - { - logger.LogDebug(ex, "Error checking if {Runtime} CLI is installed", runtimeLower); - return false; - } - } -} - -/// -/// Parsed container runtime version information. -/// -internal sealed record ContainerVersionInfo( - Version? ClientVersion, - Version? ServerVersion, - string? Context, - string? ServerOs) -{ - /// - /// Parses container version info from 'docker/podman version -f json' output. - /// - public static ContainerVersionInfo Parse(string? output) - { - if (string.IsNullOrWhiteSpace(output)) - { - return new ContainerVersionInfo(null, null, null, null); - } - - try - { - var json = JsonSerializer.Deserialize(output, JsonSourceGenerationContext.Default.ContainerVersionJson); - if (json is null) - { - return new ContainerVersionInfo(null, null, null, null); - } - - Version.TryParse(json.Client?.Version, out var clientVersion); - Version.TryParse(json.Server?.Version, out var serverVersion); - - return new ContainerVersionInfo( - clientVersion, - serverVersion, - json.Client?.Context, - json.Server?.Os); - } - catch (JsonException) - { - return new ContainerVersionInfo(null, null, null, null); - } - } -} - -/// -/// JSON structure for container runtime version output. -/// -internal sealed class ContainerVersionJson -{ - [JsonPropertyName("Client")] - public ContainerClientJson? Client { get; set; } - - [JsonPropertyName("Server")] - public ContainerServerJson? Server { get; set; } -} - -/// -/// JSON structure for the Client section of container runtime version output. -/// -internal sealed class ContainerClientJson -{ - [JsonPropertyName("Version")] - public string? Version { get; set; } - - [JsonPropertyName("Context")] - public string? Context { get; set; } -} - -/// -/// JSON structure for the Server section of container runtime version output. -/// -internal sealed class ContainerServerJson -{ - [JsonPropertyName("Version")] - public string? Version { get; set; } - - [JsonPropertyName("Os")] - public string? Os { get; set; } } diff --git a/src/Aspire.Hosting.Azure/AcrLoginService.cs b/src/Aspire.Hosting.Azure/AcrLoginService.cs index ad4a4fe3881..bf943179647 100644 --- a/src/Aspire.Hosting.Azure/AcrLoginService.cs +++ b/src/Aspire.Hosting.Azure/AcrLoginService.cs @@ -26,7 +26,7 @@ internal sealed class AcrLoginService : IAcrLoginService }; private readonly IHttpClientFactory _httpClientFactory; - private readonly IContainerRuntime _containerRuntime; + private readonly IContainerRuntimeResolver _containerRuntimeResolver; private readonly ILogger _logger; private sealed class AcrRefreshTokenResponse @@ -42,12 +42,12 @@ private sealed class AcrRefreshTokenResponse /// Initializes a new instance of the class. /// /// The HTTP client factory for making OAuth2 exchange requests. - /// The container runtime for performing registry login. + /// The container runtime resolver for performing registry login. /// The logger for diagnostic output. - public AcrLoginService(IHttpClientFactory httpClientFactory, IContainerRuntime containerRuntime, ILogger logger) + public AcrLoginService(IHttpClientFactory httpClientFactory, IContainerRuntimeResolver containerRuntimeResolver, ILogger logger) { _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); - _containerRuntime = containerRuntime ?? throw new ArgumentNullException(nameof(containerRuntime)); + _containerRuntimeResolver = containerRuntimeResolver ?? throw new ArgumentNullException(nameof(containerRuntimeResolver)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -76,7 +76,8 @@ public async Task LoginAsync( _logger.LogDebug("ACR refresh token acquired, length: {TokenLength}", refreshToken.Length); // Step 3: Login to the registry using container runtime - await _containerRuntime.LoginToRegistryAsync(registryEndpoint, AcrUsername, refreshToken, cancellationToken).ConfigureAwait(false); + var containerRuntime = await _containerRuntimeResolver.ResolveAsync(cancellationToken).ConfigureAwait(false); + await containerRuntime.LoginToRegistryAsync(registryEndpoint, AcrUsername, refreshToken, cancellationToken).ConfigureAwait(false); } private async Task ExchangeAadTokenForAcrRefreshTokenAsync( diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs index 6fc8bccd7cf..44047f40ef2 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs @@ -3,17 +3,16 @@ #pragma warning disable ASPIREPIPELINES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable ASPIREPIPELINES003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +#pragma warning disable ASPIRECONTAINERRUNTIME001 using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Dcp.Process; using Aspire.Hosting.Docker.Resources; using Aspire.Hosting.Pipelines; using Aspire.Hosting.Publishing; using Aspire.Hosting.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Aspire.Hosting.Docker; @@ -224,58 +223,27 @@ private async Task DockerComposeUpAsync(PipelineStepContext context) throw new InvalidOperationException($"Docker Compose file not found at {dockerComposeFilePath}"); } + var runtime = await context.Services.GetRequiredService().ResolveAsync(context.CancellationToken).ConfigureAwait(false); + var deployTask = await context.ReportingStep.CreateTaskAsync( - new MarkdownString($"Running docker compose up for **{Name}**"), + new MarkdownString($"Running compose up for **{Name}** using **{runtime.Name}**"), context.CancellationToken).ConfigureAwait(false); await using (deployTask.ConfigureAwait(false)) { try { - var arguments = GetDockerComposeArguments(context, this); - arguments += " up -d --remove-orphans"; - - context.Logger.LogDebug("Running docker compose up with arguments: {Arguments}", arguments); - - var spec = new ProcessSpec("docker") - { - Arguments = arguments, - WorkingDirectory = outputPath, - ThrowOnNonZeroReturnCode = false, - InheritEnv = true, - OnOutputData = output => - { - context.Logger.LogDebug("docker compose up (stdout): {Output}", output); - }, - OnErrorData = error => - { - context.Logger.LogDebug("docker compose up (stderr): {Error}", error); - }, - }; + var composeContext = CreateComposeOperationContext(context); - var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec); - - await using (processDisposable) - { - var processResult = await pendingProcessResult - .WaitAsync(context.CancellationToken) - .ConfigureAwait(false); + await runtime.ComposeUpAsync(composeContext, context.CancellationToken).ConfigureAwait(false); - if (processResult.ExitCode != 0) - { - await deployTask.FailAsync($"docker compose up failed with exit code {processResult.ExitCode}", cancellationToken: context.CancellationToken).ConfigureAwait(false); - } - else - { - await deployTask.CompleteAsync( - new MarkdownString($"Service **{Name}** is now running with Docker Compose locally"), - CompletionState.Completed, - context.CancellationToken).ConfigureAwait(false); - } - } + await deployTask.CompleteAsync( + new MarkdownString($"Service **{Name}** is now running with Docker Compose locally (runtime: {runtime.Name})"), + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); } catch (Exception ex) { - await deployTask.CompleteAsync($"Docker Compose deployment failed: {ex.Message}", CompletionState.CompletedWithError, context.CancellationToken).ConfigureAwait(false); + await deployTask.CompleteAsync($"Compose deployment failed ({runtime.Name}): {ex.Message}", CompletionState.CompletedWithError, context.CancellationToken).ConfigureAwait(false); throw; } } @@ -291,50 +259,27 @@ private async Task DockerComposeDownAsync(PipelineStepContext context) throw new InvalidOperationException($"Docker Compose file not found at {dockerComposeFilePath}"); } + var runtime = await context.Services.GetRequiredService().ResolveAsync(context.CancellationToken).ConfigureAwait(false); + var deployTask = await context.ReportingStep.CreateTaskAsync( - new MarkdownString($"Running docker compose down for **{Name}**"), + new MarkdownString($"Running compose down for **{Name}** using **{runtime.Name}**"), context.CancellationToken).ConfigureAwait(false); await using (deployTask.ConfigureAwait(false)) { try { - var arguments = GetDockerComposeArguments(context, this); - arguments += " down"; - - context.Logger.LogDebug("Running docker compose down with arguments: {Arguments}", arguments); - - var spec = new ProcessSpec("docker") - { - Arguments = arguments, - WorkingDirectory = outputPath, - ThrowOnNonZeroReturnCode = false, - InheritEnv = true - }; - - var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec); + var composeContext = CreateComposeOperationContext(context); - await using (processDisposable) - { - var processResult = await pendingProcessResult - .WaitAsync(context.CancellationToken) - .ConfigureAwait(false); + await runtime.ComposeDownAsync(composeContext, context.CancellationToken).ConfigureAwait(false); - if (processResult.ExitCode != 0) - { - await deployTask.FailAsync($"docker compose down failed with exit code {processResult.ExitCode}", cancellationToken: context.CancellationToken).ConfigureAwait(false); - } - else - { - await deployTask.CompleteAsync( - new MarkdownString($"Docker Compose shutdown complete for **{Name}**"), - CompletionState.Completed, - context.CancellationToken).ConfigureAwait(false); - } - } + await deployTask.CompleteAsync( + new MarkdownString($"Compose shutdown complete for **{Name}** ({runtime.Name})"), + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); } catch (Exception ex) { - await deployTask.CompleteAsync($"Docker Compose shutdown failed: {ex.Message}", CompletionState.CompletedWithError, context.CancellationToken).ConfigureAwait(false); + await deployTask.CompleteAsync($"Compose shutdown failed ({runtime.Name}): {ex.Message}", CompletionState.CompletedWithError, context.CancellationToken).ConfigureAwait(false); throw; } } @@ -396,21 +341,16 @@ internal static string GetEnvFilePath(PipelineStepContext context, DockerCompose return envFilePath; } - internal static string GetDockerComposeArguments(PipelineStepContext context, DockerComposeEnvironmentResource environment) + internal ComposeOperationContext CreateComposeOperationContext(PipelineStepContext context) { - var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, environment); - var dockerComposeFilePath = Path.Combine(outputPath, "docker-compose.yaml"); - var envFilePath = GetEnvFilePath(context, environment); - var projectName = GetDockerComposeProjectName(context, environment); - - var arguments = $"compose -f \"{dockerComposeFilePath}\" --project-name \"{projectName}\""; - - if (File.Exists(envFilePath)) + var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, this); + return new ComposeOperationContext { - arguments += $" --env-file \"{envFilePath}\""; - } - - return arguments; + ComposeFilePath = Path.Combine(outputPath, "docker-compose.yaml"), + ProjectName = GetDockerComposeProjectName(context, this), + EnvFilePath = GetEnvFilePath(context, this), + WorkingDirectory = outputPath + }; } internal static string GetDockerComposeProjectName(PipelineStepContext context, DockerComposeEnvironmentResource environment) diff --git a/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs b/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs index c8dd226dbee..9cee6f081b5 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs @@ -2,17 +2,16 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIRECONTAINERRUNTIME001 using System.Globalization; using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Dcp.Process; using Aspire.Hosting.Docker.Resources.ComposeNodes; using Aspire.Hosting.Docker.Resources.ServiceNodes; using Aspire.Hosting.Pipelines; -using Aspire.Hosting.Utils; +using Aspire.Hosting.Publishing; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Aspire.Hosting.Docker; @@ -325,8 +324,6 @@ private void AddVolumes(Service composeService) private async Task PrintEndpointsAsync(PipelineStepContext context, DockerComposeEnvironmentResource environment) { - var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, environment); - // No external endpoints configured - this is valid for internal-only services var externalEndpointMappings = EndpointMappings.Values.Where(m => m.IsExternal).ToList(); if (externalEndpointMappings.Count == 0) @@ -337,10 +334,13 @@ private async Task PrintEndpointsAsync(PipelineStepContext context, DockerCompos return; } - // Query the running container for its published ports - var outputLines = await RunDockerComposePsAsync(context, environment, outputPath).ConfigureAwait(false); - var endpoints = outputLines is not null - ? ParseServiceEndpoints(outputLines, externalEndpointMappings, context.Logger) + // Query the running containers for published ports + var runtime = await context.Services.GetRequiredService().ResolveAsync(context.CancellationToken).ConfigureAwait(false); + var composeContext = environment.CreateComposeOperationContext(context); + var services = await runtime.ComposeListServicesAsync(composeContext, context.CancellationToken).ConfigureAwait(false); + + var endpoints = services is not null + ? ParseServiceEndpoints(services, externalEndpointMappings, context.Logger) : []; if (endpoints.Count > 0) @@ -351,7 +351,7 @@ private async Task PrintEndpointsAsync(PipelineStepContext context, DockerCompos } else { - // No published ports found in docker compose ps output. + // No published ports found in compose output. context.ReportingStep.Log(LogLevel.Information, new MarkdownString($"Successfully deployed **{TargetResource.Name}** to Docker Compose environment **{environment.Name}**.")); context.Summary.Add(TargetResource.Name, "No public endpoints"); @@ -359,152 +359,55 @@ private async Task PrintEndpointsAsync(PipelineStepContext context, DockerCompos } /// - /// Runs 'docker compose ps --format json' to get container status and port mappings. + /// Extracts endpoint URLs from compose service info, matching against configured external endpoint mappings. /// - /// List of JSON output lines, or null if the command failed. - private static async Task?> RunDockerComposePsAsync( - PipelineStepContext context, - DockerComposeEnvironmentResource environment, - string outputPath) + private HashSet ParseServiceEndpoints( + IReadOnlyList services, + List externalEndpointMappings, + ILogger _) { - var arguments = DockerComposeEnvironmentResource.GetDockerComposeArguments(context, environment); - arguments += " ps --format json"; - - var outputLines = new List(); + var endpoints = new HashSet(StringComparers.EndpointAnnotationName); + var serviceName = TargetResource.Name.ToLowerInvariant(); - var spec = new ProcessSpec("docker") + foreach (var serviceInfo in services) { - Arguments = arguments, - WorkingDirectory = outputPath, - ThrowOnNonZeroReturnCode = false, - InheritEnv = true, - OnOutputData = output => + // Skip if not our service + if (serviceInfo.Service is null || + !string.Equals(serviceInfo.Service, serviceName, StringComparisons.ResourceName)) { - if (!string.IsNullOrWhiteSpace(output)) - { - outputLines.Add(output); - } - }, - OnErrorData = error => - { - if (!string.IsNullOrWhiteSpace(error)) - { - context.Logger.LogDebug("docker compose ps (stderr): {Error}", error); - } + continue; } - }; - var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec); - - await using (processDisposable) - { - var processResult = await pendingProcessResult - .WaitAsync(context.CancellationToken) - .ConfigureAwait(false); - - if (processResult.ExitCode != 0) + // Skip if no published ports + if (serviceInfo.Publishers is not { Count: > 0 }) { - context.Logger.LogDebug("docker compose ps failed with exit code {ExitCode}", processResult.ExitCode); - return null; + continue; } - } - - return outputLines; - } - /// - /// Parses the JSON output from 'docker compose ps' to extract endpoint URLs for this service. - /// - /// - /// Example JSON line from 'docker compose ps --format json': - /// - /// {"Service":"myservice","State":"running","Publishers":[{"URL":"","TargetPort":80,"PublishedPort":8080,"Protocol":"tcp"}]} - /// - /// Note: PublishedPort is 0 when the port is exposed but not mapped to the host. - /// - private HashSet ParseServiceEndpoints( - List outputLines, - List externalEndpointMappings, - ILogger logger) - { - var endpoints = new HashSet(StringComparers.EndpointAnnotationName); - var serviceName = TargetResource.Name.ToLowerInvariant(); - - foreach (var line in outputLines) - { - try + foreach (var publisher in serviceInfo.Publishers) { - var serviceInfo = JsonSerializer.Deserialize(line, DockerComposeJsonContext.Default.DockerComposeServiceInfo); - - // Skip if not our service - if (serviceInfo is null || - !string.Equals(serviceInfo.Service, serviceName, StringComparisons.ResourceName)) + // Skip ports that aren't actually published (port 0 or null means not exposed) + if (publisher.PublishedPort is not > 0) { continue; } - // Skip if no published ports - if (serviceInfo.Publishers is not { Count: > 0 }) - { - continue; - } + // Try to find a matching external endpoint to get the scheme + var targetPortStr = publisher.TargetPort?.ToString(CultureInfo.InvariantCulture); + var endpointMapping = externalEndpointMappings + .FirstOrDefault(m => m.InternalPort == targetPortStr || m.ExposedPort == publisher.TargetPort); + + var scheme = endpointMapping.Scheme ?? "http"; - foreach (var publisher in serviceInfo.Publishers) + if (endpointMapping.IsExternal || scheme is "http" or "https") { - // Skip ports that aren't actually published (port 0 or null means not exposed) - if (publisher.PublishedPort is not > 0) - { - continue; - } - - // Try to find a matching external endpoint to get the scheme - // Match by internal port (numeric) or by exposed port - // InternalPort may be a placeholder like ${API_PORT} for projects, so also check ExposedPort - var targetPortStr = publisher.TargetPort?.ToString(CultureInfo.InvariantCulture); - var endpointMapping = externalEndpointMappings - .FirstOrDefault(m => m.InternalPort == targetPortStr || m.ExposedPort == publisher.TargetPort); - - // If we found a matching endpoint, use its scheme; otherwise default to http for external ports - var scheme = endpointMapping.Scheme ?? "http"; - - // Only add if we found a matching external endpoint OR if scheme is http/https - // (published ports are external by definition in docker compose) - if (endpointMapping.IsExternal || scheme is "http" or "https") - { - var endpoint = $"{scheme}://localhost:{publisher.PublishedPort}"; - endpoints.Add(endpoint); - } + var endpoint = $"{scheme}://localhost:{publisher.PublishedPort}"; + endpoints.Add(endpoint); } } - catch (JsonException ex) - { - logger.LogDebug(ex, "Failed to parse docker compose ps output line: {Line}", line); - } } return endpoints; } - /// - /// Represents the JSON output from docker compose ps --format json. - /// - internal sealed class DockerComposeServiceInfo - { - public string? Service { get; set; } - public List? Publishers { get; set; } - } - - /// - /// Represents a port publisher in docker compose ps output. - /// - internal sealed class DockerComposePublisher - { - public int? PublishedPort { get; set; } - public int? TargetPort { get; set; } - } -} - -[JsonSerializable(typeof(DockerComposeServiceResource.DockerComposeServiceInfo))] -internal sealed partial class DockerComposeJsonContext : JsonSerializerContext -{ } diff --git a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs index 1e960005d69..51f484731d4 100644 --- a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs +++ b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs @@ -144,7 +144,7 @@ private async Task BuildProjectImage(PipelineStepContext ctx) var tempTag = $"temp-{Guid.NewGuid():N}"; var tempImageName = $"{originalImageName}:{tempTag}"; - var containerRuntime = ctx.Services.GetRequiredService(); + var containerRuntime = await ctx.Services.GetRequiredService().ResolveAsync(ctx.CancellationToken).ConfigureAwait(false); logger.LogDebug("Tagging image {OriginalImageName} as {TempImageName}", originalImageName, tempImageName); await containerRuntime.TagImageAsync(originalImageName, tempImageName, ctx.CancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index 7a1342cd690..5d6eb7c6108 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -29,6 +29,7 @@ + diff --git a/src/Aspire.Hosting/CompatibilitySuppressions.xml b/src/Aspire.Hosting/CompatibilitySuppressions.xml new file mode 100644 index 00000000000..5820a6da429 --- /dev/null +++ b/src/Aspire.Hosting/CompatibilitySuppressions.xml @@ -0,0 +1,25 @@ + + + + + CP0006 + M:Aspire.Hosting.Publishing.IContainerRuntime.ComposeDownAsync(Aspire.Hosting.Publishing.ComposeOperationContext,System.Threading.CancellationToken) + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + + + CP0006 + M:Aspire.Hosting.Publishing.IContainerRuntime.ComposeListServicesAsync(Aspire.Hosting.Publishing.ComposeOperationContext,System.Threading.CancellationToken) + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + + + CP0006 + M:Aspire.Hosting.Publishing.IContainerRuntime.ComposeUpAsync(Aspire.Hosting.Publishing.ComposeOperationContext,System.Threading.CancellationToken) + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + + \ No newline at end of file diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index e1f004486ff..bceeacd410e 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -511,15 +511,7 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) Eventing.Subscribe(BuiltInDistributedApplicationEventSubscriptionHandlers.MutateHttp2TransportAsync); _innerBuilder.Services.AddKeyedSingleton("docker"); _innerBuilder.Services.AddKeyedSingleton("podman"); - _innerBuilder.Services.AddSingleton(sp => - { - var dcpOptions = sp.GetRequiredService>(); - return dcpOptions.Value.ContainerRuntime switch - { - string rt => sp.GetRequiredKeyedService(rt), - null => sp.GetRequiredKeyedService("docker") - }; - }); + _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(sp => sp.GetRequiredService()); diff --git a/src/Aspire.Hosting/Pipelines/PipelineStepHelpers.cs b/src/Aspire.Hosting/Pipelines/PipelineStepHelpers.cs index 6e438c09df1..1e686028533 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineStepHelpers.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineStepHelpers.cs @@ -84,7 +84,7 @@ private static async Task TagImageForLocalRegistryAsync(IResource resource, Pipe : resource.Name.ToLowerInvariant(); // Only tag the image, don't push to a remote registry - var containerRuntime = context.Services.GetRequiredService(); + var containerRuntime = await context.Services.GetRequiredService().ResolveAsync(context.CancellationToken).ConfigureAwait(false); await containerRuntime.TagImageAsync(localImageName, targetTag, context.CancellationToken).ConfigureAwait(false); await tagTask.CompleteAsync( diff --git a/src/Aspire.Hosting/Publishing/ComposeOperationContext.cs b/src/Aspire.Hosting/Publishing/ComposeOperationContext.cs new file mode 100644 index 00000000000..b888e16d3d4 --- /dev/null +++ b/src/Aspire.Hosting/Publishing/ComposeOperationContext.cs @@ -0,0 +1,33 @@ +// 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.CodeAnalysis; + +namespace Aspire.Hosting.Publishing; + +/// +/// Provides the parameters needed to execute a Docker Compose operation against a container runtime. +/// +[Experimental("ASPIRECONTAINERRUNTIME001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class ComposeOperationContext +{ + /// + /// Gets the path to the Docker Compose YAML file. + /// + public required string ComposeFilePath { get; init; } + + /// + /// Gets the compose project name used for resource isolation. + /// + public required string ProjectName { get; init; } + + /// + /// Gets the optional path to an environment file to pass to the compose operation. + /// + public string? EnvFilePath { get; init; } + + /// + /// Gets the working directory for the compose process. + /// + public required string WorkingDirectory { get; init; } +} diff --git a/src/Aspire.Hosting/Publishing/ComposeServiceInfo.cs b/src/Aspire.Hosting/Publishing/ComposeServiceInfo.cs new file mode 100644 index 00000000000..d4aa67b2e21 --- /dev/null +++ b/src/Aspire.Hosting/Publishing/ComposeServiceInfo.cs @@ -0,0 +1,40 @@ +// 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.CodeAnalysis; + +namespace Aspire.Hosting.Publishing; + +/// +/// Represents a running service discovered from a compose environment. +/// +[Experimental("ASPIRECONTAINERRUNTIME001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class ComposeServiceInfo +{ + /// + /// Gets the name of the compose service. + /// + public string? Service { get; init; } + + /// + /// Gets the published port mappings for the service. + /// + public IReadOnlyList? Publishers { get; init; } +} + +/// +/// Represents a port mapping for a compose service. +/// +[Experimental("ASPIRECONTAINERRUNTIME001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class ComposeServicePort +{ + /// + /// Gets the port published on the host. + /// + public int? PublishedPort { get; init; } + + /// + /// Gets the target port inside the container. + /// + public int? TargetPort { get; init; } +} diff --git a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs index 6b0bf535b60..ac465ed9057 100644 --- a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs +++ b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs @@ -4,6 +4,8 @@ #pragma warning disable ASPIREPIPELINES003 #pragma warning disable ASPIRECONTAINERRUNTIME001 +using System.Text.Json; +using System.Text.Json.Serialization; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dcp.Process; using Microsoft.Extensions.Logging; @@ -294,4 +296,301 @@ private ProcessSpec CreateProcessSpec(string arguments) InheritEnv = true }; } + + public virtual async Task ComposeUpAsync(ComposeOperationContext context, CancellationToken cancellationToken) + { + await EnsureRuntimeAvailableAsync().ConfigureAwait(false); + + var arguments = BuildComposeArguments(context); + arguments += " up -d --remove-orphans"; + + _logger.LogInformation("Using container runtime '{Runtime}' for compose operations.", RuntimeExecutable); + _logger.LogDebug("Running {Runtime} compose up with arguments: {Arguments}", RuntimeExecutable, arguments); + + var spec = new ProcessSpec(RuntimeExecutable) + { + Arguments = arguments, + WorkingDirectory = context.WorkingDirectory, + ThrowOnNonZeroReturnCode = false, + InheritEnv = true, + OnOutputData = output => + { + _logger.LogDebug("{Runtime} compose up (stdout): {Output}", RuntimeExecutable, output); + }, + OnErrorData = error => + { + _logger.LogDebug("{Runtime} compose up (stderr): {Error}", RuntimeExecutable, error); + }, + }; + + var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec); + + await using (processDisposable) + { + var processResult = await pendingProcessResult + .WaitAsync(cancellationToken) + .ConfigureAwait(false); + + if (processResult.ExitCode != 0) + { + var envHint = Environment.GetEnvironmentVariable("ASPIRE_CONTAINER_RUNTIME") is not null + ? $"The container runtime is configured via ASPIRE_CONTAINER_RUNTIME (current: '{RuntimeExecutable}')." + : $"The container runtime was auto-detected as '{RuntimeExecutable}'. Set ASPIRE_CONTAINER_RUNTIME to override (e.g., 'docker' or 'podman')."; + + throw new DistributedApplicationException( + $"'{RuntimeExecutable} compose up' failed with exit code {processResult.ExitCode}. " + + $"Ensure '{RuntimeExecutable}' is installed and available on PATH. " + + envHint); + } + } + } + + public virtual async Task ComposeDownAsync(ComposeOperationContext context, CancellationToken cancellationToken) + { + await EnsureRuntimeAvailableAsync().ConfigureAwait(false); + + var arguments = BuildComposeArguments(context); + arguments += " down"; + + _logger.LogDebug("Running {Runtime} compose down with arguments: {Arguments}", RuntimeExecutable, arguments); + + var spec = new ProcessSpec(RuntimeExecutable) + { + Arguments = arguments, + WorkingDirectory = context.WorkingDirectory, + ThrowOnNonZeroReturnCode = false, + InheritEnv = true, + OnOutputData = output => + { + _logger.LogDebug("{Runtime} compose down (stdout): {Output}", RuntimeExecutable, output); + }, + OnErrorData = error => + { + _logger.LogDebug("{Runtime} compose down (stderr): {Error}", RuntimeExecutable, error); + }, + }; + + var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec); + + await using (processDisposable) + { + var processResult = await pendingProcessResult + .WaitAsync(cancellationToken) + .ConfigureAwait(false); + + if (processResult.ExitCode != 0) + { + throw new DistributedApplicationException( + $"'{RuntimeExecutable} compose down' failed with exit code {processResult.ExitCode}. " + + $"Ensure '{RuntimeExecutable}' is installed and available on PATH."); + } + } + } + + public virtual async Task?> ComposeListServicesAsync(ComposeOperationContext context, CancellationToken cancellationToken) + { + await EnsureRuntimeAvailableAsync().ConfigureAwait(false); + + var arguments = BuildComposeArguments(context); + arguments += " ps --format json"; + + var outputLines = new List(); + + var spec = new ProcessSpec(RuntimeExecutable) + { + Arguments = arguments, + WorkingDirectory = context.WorkingDirectory, + ThrowOnNonZeroReturnCode = false, + InheritEnv = true, + OnOutputData = output => + { + if (!string.IsNullOrWhiteSpace(output)) + { + outputLines.Add(output); + } + }, + OnErrorData = error => + { + if (!string.IsNullOrWhiteSpace(error)) + { + _logger.LogDebug("{Runtime} compose ps (stderr): {Error}", RuntimeExecutable, error); + } + } + }; + + var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec); + + await using (processDisposable) + { + var processResult = await pendingProcessResult + .WaitAsync(cancellationToken) + .ConfigureAwait(false); + + if (processResult.ExitCode != 0) + { + _logger.LogDebug("{Runtime} compose ps failed with exit code {ExitCode}", RuntimeExecutable, processResult.ExitCode); + return null; + } + } + + return ParseComposeServiceEntries(outputLines); + } + + /// + /// Parses Docker Compose ps JSON output, handling both NDJSON (one object per line) and JSON array formats. + /// + /// + /// NDJSON (Docker Compose v2+): + /// + /// {"Service":"web","Publishers":[{"URL":"","TargetPort":80,"PublishedPort":8080,"Protocol":"tcp"}]} + /// {"Service":"cache","Publishers":[{"TargetPort":6379,"PublishedPort":6379}]} + /// + /// JSON array (older versions): + /// + /// [{"Service":"web","Publishers":[{"TargetPort":80,"PublishedPort":8080}]}] + /// + /// + internal static List ParseComposeServiceEntries(List outputLines) + { + var results = new List(); + + foreach (var line in outputLines) + { + var trimmed = line.Trim(); + if (trimmed.Length == 0) + { + continue; + } + + // Try parsing as JSON array first (older Docker Compose versions) + if (trimmed.StartsWith('[')) + { + try + { + var entries = JsonSerializer.Deserialize(trimmed, ComposeJsonContext.Default.ListDockerComposePsEntry); + if (entries is not null) + { + foreach (var entry in entries) + { + results.Add(MapDockerComposeEntry(entry)); + } + } + } + catch (JsonException) + { + // Skip unparseable lines + } + continue; + } + + // Parse as single JSON object (NDJSON format) + if (trimmed.StartsWith('{')) + { + try + { + var entry = JsonSerializer.Deserialize(trimmed, ComposeJsonContext.Default.DockerComposePsEntry); + if (entry is not null) + { + results.Add(MapDockerComposeEntry(entry)); + } + } + catch (JsonException) + { + // Skip unparseable lines + } + } + } + + return results; + } + + private static ComposeServiceInfo MapDockerComposeEntry(DockerComposePsEntry entry) + { + return new ComposeServiceInfo + { + Service = entry.Service, + Publishers = entry.Publishers?.Select(p => new ComposeServicePort + { + PublishedPort = p.PublishedPort, + TargetPort = p.TargetPort + }).ToList() + }; + } + + /// + /// Builds the compose CLI arguments from a . + /// + private static string BuildComposeArguments(ComposeOperationContext context) + { + var arguments = $"compose -f \"{context.ComposeFilePath}\" --project-name \"{context.ProjectName}\""; + + if (context.EnvFilePath is not null && File.Exists(context.EnvFilePath)) + { + arguments += $" --env-file \"{context.EnvFilePath}\""; + } + + return arguments; + } + + /// + /// Validates that the container runtime binary is available on the system PATH. + /// Fails fast with an actionable error message instead of a cryptic exit code. + /// + protected async Task EnsureRuntimeAvailableAsync() + { + try + { + var whichCommand = OperatingSystem.IsWindows() ? "where" : "which"; + var spec = new ProcessSpec(whichCommand) + { + Arguments = RuntimeExecutable, + ThrowOnNonZeroReturnCode = false, + InheritEnv = true + }; + + var (pendingResult, processDisposable) = ProcessUtil.Run(spec); + await using (processDisposable) + { + var result = await pendingResult.ConfigureAwait(false); + if (result.ExitCode != 0) + { + throw new DistributedApplicationException( + $"Container runtime '{RuntimeExecutable}' was not found on PATH. " + + $"Install {Name} or set ASPIRE_CONTAINER_RUNTIME to a different runtime (e.g., 'docker' or 'podman')."); + } + } + } + catch (DistributedApplicationException) + { + throw; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to check if {Runtime} is available on PATH", RuntimeExecutable); + } + } +} + +/// +/// Internal DTO for deserializing Docker Compose ps JSON output. +/// +internal sealed class DockerComposePsEntry +{ + public string? Service { get; set; } + public List? Publishers { get; set; } +} + +/// +/// Internal DTO for deserializing Docker Compose ps publisher entries. +/// +internal sealed class DockerComposePsPublisher +{ + public int? PublishedPort { get; set; } + public int? TargetPort { get; set; } +} + +[JsonSerializable(typeof(DockerComposePsEntry))] +[JsonSerializable(typeof(List))] +internal sealed partial class ComposeJsonContext : JsonSerializerContext +{ } diff --git a/src/Aspire.Hosting/Publishing/ContainerRuntimeResolver.cs b/src/Aspire.Hosting/Publishing/ContainerRuntimeResolver.cs new file mode 100644 index 00000000000..b0774136221 --- /dev/null +++ b/src/Aspire.Hosting/Publishing/ContainerRuntimeResolver.cs @@ -0,0 +1,96 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIRECONTAINERRUNTIME001 + +using Aspire.Hosting.Dcp; +using Aspire.Shared; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Aspire.Hosting.Publishing; + +/// +/// Resolves the container runtime asynchronously using explicit configuration or auto-detection. +/// Caches the result after first resolution. +/// +internal sealed class ContainerRuntimeResolver : IContainerRuntimeResolver +{ + private readonly IServiceProvider _serviceProvider; + private readonly IOptions _dcpOptions; + private readonly ILogger _logger; + private readonly object _lock = new(); + private Task? _cachedTask; + + public ContainerRuntimeResolver( + IServiceProvider serviceProvider, + IOptions dcpOptions, + ILoggerFactory loggerFactory) + { + _serviceProvider = serviceProvider; + _dcpOptions = dcpOptions; + _logger = loggerFactory.CreateLogger("Aspire.Hosting.ContainerRuntime"); + } + + public Task ResolveAsync(CancellationToken cancellationToken = default) + { + // Caching behavior: + // - Completed successfully: return cached result. Caller's token is irrelevant. + // - In-progress: return the in-flight task (started with a previous caller's token). + // If this caller is cancelled, their await throws but the detection continues. + // - Faulted (e.g. bad ASPIRE_CONTAINER_RUNTIME value): return faulted task. + // Config won't change mid-process, so retry won't help. + // - Cancelled: discard and retry with the new caller's token, since a different + // caller may have a valid token. + // - Null: first call, start detection with this caller's token. + var task = _cachedTask; + if (task is not null && !task.IsCanceled) + { + return task; + } + + lock (_lock) + { + task = _cachedTask; + if (task is not null && !task.IsCanceled) + { + return task; + } + + _cachedTask = ResolveInternalAsync(cancellationToken); + return _cachedTask; + } + } + + private async Task ResolveInternalAsync(CancellationToken cancellationToken) + { + var configuredRuntime = _dcpOptions.Value.ContainerRuntime; + + if (configuredRuntime is not null) + { + _logger.LogInformation("Container runtime '{RuntimeKey}' configured via ASPIRE_CONTAINER_RUNTIME.", configuredRuntime); + return _serviceProvider.GetRequiredKeyedService(configuredRuntime); + } + + // Auto-detect: probe available runtimes asynchronously. + // See https://github.com/microsoft/dcp/blob/main/internal/containers/runtimes/runtime.go + var detected = await ContainerRuntimeDetector.FindAvailableRuntimeAsync(logger: _logger, cancellationToken: cancellationToken).ConfigureAwait(false); + var runtimeKey = detected?.Executable ?? "docker"; + + if (detected is { IsHealthy: true }) + { + _logger.LogInformation("Container runtime auto-detected: {RuntimeName} ({Executable}).", detected.Name, detected.Executable); + } + else if (detected is { IsInstalled: true }) + { + _logger.LogWarning("Container runtime '{RuntimeName}' is installed but not running. {Error}", detected.Name, detected.Error); + } + else + { + _logger.LogWarning("No container runtime detected, defaulting to 'docker'. Install Docker or Podman to use container features."); + } + + return _serviceProvider.GetRequiredKeyedService(runtimeKey); + } +} diff --git a/src/Aspire.Hosting/Publishing/IContainerRuntime.cs b/src/Aspire.Hosting/Publishing/IContainerRuntime.cs index 88a4a58a772..73c6d574c88 100644 --- a/src/Aspire.Hosting/Publishing/IContainerRuntime.cs +++ b/src/Aspire.Hosting/Publishing/IContainerRuntime.cs @@ -66,4 +66,28 @@ public interface IContainerRuntime /// The password for authentication. /// A token to cancel the operation. Task LoginToRegistryAsync(string registryServer, string username, string password, CancellationToken cancellationToken); + + /// + /// Starts compose services in detached mode. + /// + /// The compose operation parameters. + /// A token to cancel the operation. + /// Thrown when the compose up command fails. + Task ComposeUpAsync(ComposeOperationContext context, CancellationToken cancellationToken); + + /// + /// Stops and removes compose services. + /// + /// The compose operation parameters. + /// A token to cancel the operation. + /// Thrown when the compose down command fails. + Task ComposeDownAsync(ComposeOperationContext context, CancellationToken cancellationToken); + + /// + /// Lists the running services in a compose environment with their port mappings. + /// + /// The compose operation parameters. + /// A token to cancel the operation. + /// A list of running services, or null if the query could not be completed. + Task?> ComposeListServicesAsync(ComposeOperationContext context, CancellationToken cancellationToken); } diff --git a/src/Aspire.Hosting/Publishing/IContainerRuntimeResolver.cs b/src/Aspire.Hosting/Publishing/IContainerRuntimeResolver.cs new file mode 100644 index 00000000000..bc3f2e57b56 --- /dev/null +++ b/src/Aspire.Hosting/Publishing/IContainerRuntimeResolver.cs @@ -0,0 +1,22 @@ +// 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.CodeAnalysis; + +namespace Aspire.Hosting.Publishing; + +/// +/// Resolves the configured or auto-detected container runtime asynchronously. +/// The result is cached after the first resolution. +/// +[Experimental("ASPIRECONTAINERRUNTIME001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public interface IContainerRuntimeResolver +{ + /// + /// Resolves the container runtime, detecting it from the environment if not explicitly configured. + /// The result is cached after the first call. + /// + /// A token to cancel the operation. + /// The resolved container runtime. + Task ResolveAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs b/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs index d93eefd21ae..c007ddd316f 100644 --- a/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs +++ b/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs @@ -4,6 +4,9 @@ #pragma warning disable ASPIREPIPELINES003 #pragma warning disable ASPIRECONTAINERRUNTIME001 +using System.Text.Json; +using System.Text.Json.Serialization; +using Aspire.Hosting.Dcp.Process; using Microsoft.Extensions.Logging; namespace Aspire.Hosting.Publishing; @@ -16,6 +19,127 @@ public PodmanContainerRuntime(ILogger logger) : base(log protected override string RuntimeExecutable => "podman"; public override string Name => "Podman"; + + /// + /// Lists compose services using native podman ps with label filters, + /// which works with both Docker Compose v2 and podman-compose providers. + /// + public override async Task?> ComposeListServicesAsync(ComposeOperationContext context, CancellationToken cancellationToken) + { + await EnsureRuntimeAvailableAsync().ConfigureAwait(false); + + var arguments = $"ps --filter label=com.docker.compose.project={context.ProjectName} --format json"; + + var outputLines = new List(); + + var spec = new ProcessSpec(RuntimeExecutable) + { + Arguments = arguments, + WorkingDirectory = context.WorkingDirectory, + ThrowOnNonZeroReturnCode = false, + InheritEnv = true, + OnOutputData = output => + { + if (!string.IsNullOrWhiteSpace(output)) + { + outputLines.Add(output); + } + }, + OnErrorData = error => + { + if (!string.IsNullOrWhiteSpace(error)) + { + Logger.LogDebug("podman ps (stderr): {Error}", error); + } + } + }; + + var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec); + + await using (processDisposable) + { + var processResult = await pendingProcessResult + .WaitAsync(cancellationToken) + .ConfigureAwait(false); + + if (processResult.ExitCode != 0) + { + Logger.LogDebug("podman ps failed with exit code {ExitCode}", processResult.ExitCode); + return null; + } + } + + return ParsePodmanPsOutput(outputLines); + } + + /// + /// Parses native podman ps --format json output into normalized entries. + /// Podman returns a JSON array. Containers are aggregated by compose service name. + /// + /// + /// + /// [{"Labels":{"com.docker.compose.service":"web"},"Ports":[{"host_ip":"","container_port":80,"host_port":8080,"range":1,"protocol":"tcp"}]}] + /// + /// + internal static List ParsePodmanPsOutput(List outputLines) + { + var allText = string.Join("", outputLines); + if (string.IsNullOrWhiteSpace(allText)) + { + return []; + } + + List? entries; + try + { + entries = JsonSerializer.Deserialize(allText, PodmanPsJsonContext.Default.ListPodmanPsEntry); + } + catch (JsonException) + { + return []; + } + + if (entries is null) + { + return []; + } + + // Group by compose service name since Podman may return multiple containers per service + var grouped = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var entry in entries) + { + var serviceName = entry.Labels?.GetValueOrDefault("com.docker.compose.service"); + if (serviceName is null) + { + continue; + } + + if (!grouped.TryGetValue(serviceName, out var ports)) + { + ports = []; + grouped[serviceName] = ports; + } + + if (entry.Ports is not null) + { + foreach (var port in entry.Ports) + { + ports.Add(new ComposeServicePort + { + PublishedPort = port.HostPort, + TargetPort = port.ContainerPort + }); + } + } + } + + return grouped.Select(g => new ComposeServiceInfo + { + Service = g.Key, + Publishers = g.Value + }).ToList(); + } private async Task RunPodmanBuildAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken) { var imageName = !string.IsNullOrEmpty(options?.Tag) @@ -116,3 +240,29 @@ public override async Task CheckIfRunningAsync(CancellationToken cancellat } } } + +/// +/// Internal DTO for deserializing podman ps --format json output. +/// +internal sealed class PodmanPsEntry +{ + public Dictionary? Labels { get; set; } + public List? Ports { get; set; } +} + +/// +/// Internal DTO for deserializing Podman port mappings. +/// +internal sealed class PodmanPsPort +{ + [JsonPropertyName("container_port")] + public int? ContainerPort { get; set; } + + [JsonPropertyName("host_port")] + public int? HostPort { get; set; } +} + +[JsonSerializable(typeof(List))] +internal sealed partial class PodmanPsJsonContext : JsonSerializerContext +{ +} diff --git a/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs b/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs index c2887046135..7db0407bc5b 100644 --- a/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs +++ b/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs @@ -158,14 +158,15 @@ public interface IResourceContainerImageManager internal sealed class ResourceContainerImageManager( ILogger logger, - IContainerRuntime containerRuntime, + IContainerRuntimeResolver containerRuntimeResolver, IServiceProvider serviceProvider, DistributedApplicationExecutionContext? executionContext = null) : IResourceContainerImageManager { // Disable concurrent builds for project resources to avoid issues with overlapping msbuild projects private readonly SemaphoreSlim _throttle = new(1); - private IContainerRuntime ContainerRuntime { get; } = containerRuntime; + private async Task GetContainerRuntimeAsync(CancellationToken cancellationToken) + => await containerRuntimeResolver.ResolveAsync(cancellationToken).ConfigureAwait(false); private sealed class ResolvedContainerBuildOptions { @@ -205,22 +206,23 @@ private async Task ResolveContainerBuildOptionsAs public async Task BuildImagesAsync(IEnumerable resources, CancellationToken cancellationToken = default) { + var containerRuntime = await GetContainerRuntimeAsync(cancellationToken).ConfigureAwait(false); logger.LogInformation("Starting to build container images"); // Only check container runtime health if there are resources that need it if (await ResourcesRequireContainerRuntimeAsync(resources, cancellationToken).ConfigureAwait(false)) { - logger.LogDebug("Checking {ContainerRuntimeName} health", ContainerRuntime.Name); + logger.LogDebug("Checking {ContainerRuntimeName} health", containerRuntime.Name); - var containerRuntimeHealthy = await ContainerRuntime.CheckIfRunningAsync(cancellationToken).ConfigureAwait(false); + var containerRuntimeHealthy = await containerRuntime.CheckIfRunningAsync(cancellationToken).ConfigureAwait(false); if (!containerRuntimeHealthy) { - logger.LogError("Container runtime '{ContainerRuntimeName}' is not running or is unhealthy. Cannot build container images.", ContainerRuntime.Name); - throw new InvalidOperationException($"Container runtime '{ContainerRuntime.Name}' is not running or is unhealthy."); + logger.LogError("Container runtime '{ContainerRuntimeName}' is not running or is unhealthy. Cannot build container images.", containerRuntime.Name); + throw new InvalidOperationException($"Container runtime '{containerRuntime.Name}' is not running or is unhealthy."); } - logger.LogDebug("{ContainerRuntimeName} is healthy", ContainerRuntime.Name); + logger.LogDebug("{ContainerRuntimeName} is healthy", containerRuntime.Name); } foreach (var resource in resources) @@ -234,6 +236,7 @@ public async Task BuildImagesAsync(IEnumerable resources, Cancellatio public async Task BuildImageAsync(IResource resource, CancellationToken cancellationToken = default) { + var containerRuntime = await GetContainerRuntimeAsync(cancellationToken).ConfigureAwait(false); logger.LogInformation("Building container image for resource {ResourceName}", resource.Name); var options = await ResolveContainerBuildOptionsAsync(resource, cancellationToken).ConfigureAwait(false); @@ -241,17 +244,17 @@ public async Task BuildImageAsync(IResource resource, CancellationToken cancella // Check if this resource needs a container runtime if (await ResourcesRequireContainerRuntimeAsync([resource], cancellationToken).ConfigureAwait(false)) { - logger.LogDebug("Checking {ContainerRuntimeName} health", ContainerRuntime.Name); + logger.LogDebug("Checking {ContainerRuntimeName} health", containerRuntime.Name); - var containerRuntimeHealthy = await ContainerRuntime.CheckIfRunningAsync(cancellationToken).ConfigureAwait(false); + var containerRuntimeHealthy = await containerRuntime.CheckIfRunningAsync(cancellationToken).ConfigureAwait(false); if (!containerRuntimeHealthy) { - logger.LogError("Container runtime '{ContainerRuntimeName}' is not running or is unhealthy. Cannot build container image.", ContainerRuntime.Name); - throw new InvalidOperationException($"Container runtime '{ContainerRuntime.Name}' is not running or is unhealthy."); + logger.LogError("Container runtime '{ContainerRuntimeName}' is not running or is unhealthy. Cannot build container image.", containerRuntime.Name); + throw new InvalidOperationException($"Container runtime '{containerRuntime.Name}' is not running or is unhealthy."); } - logger.LogDebug("{ContainerRuntimeName} is healthy", ContainerRuntime.Name); + logger.LogDebug("{ContainerRuntimeName} is healthy", containerRuntime.Name); } if (resource is ProjectResource) @@ -418,6 +421,7 @@ private async Task ExecuteDotnetPublishAsync(IResource resource, ResolvedC private async Task BuildContainerImageFromDockerfileAsync(IResource resource, DockerfileBuildAnnotation dockerfileBuildAnnotation, string imageName, ResolvedContainerBuildOptions options, CancellationToken cancellationToken) { + var containerRuntime = await GetContainerRuntimeAsync(cancellationToken).ConfigureAwait(false); logger.LogInformation("Building image: {ResourceName}", resource.Name); // If there's a factory, generate the Dockerfile content and write it to the specified path @@ -471,7 +475,7 @@ private async Task BuildContainerImageFromDockerfileAsync(IResource resource, Do try { - await ContainerRuntime.BuildImageAsync( + await containerRuntime.BuildImageAsync( dockerfileBuildAnnotation.ContextPath, dockerfileBuildAnnotation.DockerfilePath, containerBuildOptions, @@ -514,7 +518,8 @@ await ContainerRuntime.BuildImageAsync( public async Task PushImageAsync(IResource resource, CancellationToken cancellationToken) { - await ContainerRuntime.PushImageAsync(resource, cancellationToken).ConfigureAwait(false); + var containerRuntime = await GetContainerRuntimeAsync(cancellationToken).ConfigureAwait(false); + await containerRuntime.PushImageAsync(resource, cancellationToken).ConfigureAwait(false); } // .NET Container builds that push OCI images to a local file path do not need a runtime diff --git a/src/Shared/ContainerRuntimeDetector.cs b/src/Shared/ContainerRuntimeDetector.cs new file mode 100644 index 00000000000..bedc765045a --- /dev/null +++ b/src/Shared/ContainerRuntimeDetector.cs @@ -0,0 +1,448 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Shared container runtime detection logic mirroring the approach used by DCP: +// https://github.com/microsoft/dcp/blob/main/internal/containers/runtimes/runtime.go +// https://github.com/microsoft/dcp/blob/main/internal/containers/flags/container_runtime.go +// +// Detection strategy (matches DCP's FindAvailableContainerRuntime): +// 1. If a runtime is explicitly configured, use it directly. +// 2. Otherwise, probe all known runtimes in parallel. +// 3. Prefer installed+running over installed-only over not-found. +// 4. When runtimes are equally available, prefer the default (Docker). + +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; + +namespace Aspire.Shared; + +/// +/// Describes the availability of a single container runtime (e.g., Docker or Podman). +/// +internal sealed class ContainerRuntimeInfo +{ + /// + /// The executable name (e.g., "docker", "podman"). + /// + public required string Executable { get; init; } + + /// + /// Display name (e.g., "Docker", "Podman"). + /// + public required string Name { get; init; } + + /// + /// Whether the runtime CLI was found on PATH. + /// + public bool IsInstalled { get; init; } + + /// + /// Whether the runtime daemon/service is responding. + /// + public bool IsRunning { get; init; } + + /// + /// Whether this is the default runtime when all else is equal. + /// + public bool IsDefault { get; init; } + + /// + /// Error message if detection failed. + /// + public string? Error { get; init; } + + /// + /// The client (CLI) version, if detected. + /// + public Version? ClientVersion { get; init; } + + /// + /// The server (daemon/engine) version, if detected. + /// + public Version? ServerVersion { get; init; } + + /// + /// Whether this is Docker Desktop (vs Docker Engine). + /// + public bool IsDockerDesktop { get; init; } + + /// + /// The server OS (e.g., "linux", "windows"). Relevant for Docker's Windows container mode. + /// + public string? ServerOs { get; init; } + + /// + /// Whether the runtime is fully operational. + /// + public bool IsHealthy => IsInstalled && IsRunning; +} + +/// +/// Detects available container runtimes by probing CLI executables on PATH. +/// Mirrors the detection logic used by DCP. +/// +internal static class ContainerRuntimeDetector +{ + private static readonly TimeSpan s_processTimeout = TimeSpan.FromSeconds(10); + + private static readonly (string Executable, string Name, bool IsDefault)[] s_knownRuntimes = + [ + ("docker", "Docker", true), + ("podman", "Podman", false) + ]; + + /// + /// Finds the best available container runtime, optionally using an explicit preference. + /// + /// + /// An explicitly configured runtime name (e.g., "docker" or "podman" from ASPIRE_CONTAINER_RUNTIME). + /// When set, only that runtime is checked. When null, all known runtimes are probed in parallel. + /// + /// Optional logger for diagnostic output during detection. + /// Cancellation token. + /// + /// The best available runtime, or null if no runtime was found. + /// When a runtime is configured but not available, returns its info with = false. + /// + public static async Task FindAvailableRuntimeAsync(string? configuredRuntime = null, ILogger? logger = null, CancellationToken cancellationToken = default) + { + if (configuredRuntime is not null) + { + // Explicit config: check only the requested runtime + var known = s_knownRuntimes.FirstOrDefault(r => string.Equals(r.Executable, configuredRuntime, StringComparison.OrdinalIgnoreCase)); + var name = known.Name ?? configuredRuntime; + var isDefault = known.IsDefault; + logger?.LogDebug("Checking explicitly configured runtime: {Runtime}", configuredRuntime); + return await CheckRuntimeAsync(configuredRuntime, name, isDefault, logger, cancellationToken).ConfigureAwait(false); + } + + // Auto-detect: probe all runtimes in parallel (matches DCP behavior) + logger?.LogDebug("Auto-detecting container runtime, probing {Count} known runtimes...", s_knownRuntimes.Length); + var tasks = s_knownRuntimes.Select(r => + CheckRuntimeAsync(r.Executable, r.Name, r.IsDefault, logger, cancellationToken)).ToArray(); + + var results = await Task.WhenAll(tasks).ConfigureAwait(false); + + return FindBestRuntime(results); + } + + /// + /// Checks the availability of a specific container runtime. + /// + public static async Task CheckRuntimeAsync(string executable, string name, bool isDefault, ILogger? logger = null, CancellationToken cancellationToken = default) + { + try + { + logger?.LogDebug("Probing container runtime '{Name}' ({Executable})...", name, executable); + // Check if the CLI is installed by running ` container ls -n 1` + // This matches DCP's check and also validates the daemon is running. + var startInfo = new ProcessStartInfo + { + FileName = executable, + Arguments = "container ls -n 1", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(startInfo); + if (process is null) + { + return new ContainerRuntimeInfo + { + Executable = executable, + Name = name, + IsInstalled = false, + IsRunning = false, + IsDefault = isDefault, + Error = $"{name} CLI not found on PATH." + }; + } + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(s_processTimeout); + + try + { + await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + try { process.Kill(); } catch { /* best effort */ } + return new ContainerRuntimeInfo + { + Executable = executable, + Name = name, + IsInstalled = true, + IsRunning = false, + IsDefault = isDefault, + Error = $"{name} CLI timed out while checking status." + }; + } + + if (process.ExitCode == 0) + { + // Runtime is running — gather version metadata + logger?.LogDebug("{Name} is running, gathering version info...", name); + var versionInfo = await GetVersionInfoAsync(executable, cancellationToken).ConfigureAwait(false); + logger?.LogDebug("{Name}: client={ClientVersion}, server={ServerVersion}, desktop={IsDesktop}", name, versionInfo.ClientVersion, versionInfo.ServerVersion, versionInfo.IsDockerDesktop); + + return new ContainerRuntimeInfo + { + Executable = executable, + Name = name, + IsInstalled = true, + IsRunning = true, + IsDefault = isDefault, + ClientVersion = versionInfo.ClientVersion, + ServerVersion = versionInfo.ServerVersion, + IsDockerDesktop = versionInfo.IsDockerDesktop, + ServerOs = versionInfo.ServerOs + }; + } + + // Non-zero exit code: CLI exists (we started it) but daemon may not be running. + var isInstalled = await IsCliInstalledAsync(executable, cancellationToken).ConfigureAwait(false); + logger?.LogDebug("{Name}: exit code {ExitCode}, installed={IsInstalled}", name, process.ExitCode, isInstalled); + + var partialVersionInfo = isInstalled + ? await GetVersionInfoAsync(executable, cancellationToken).ConfigureAwait(false) + : default; + + var error = isInstalled + ? $"{name} is installed but the daemon is not running." + : $"{name} CLI not found on PATH."; + logger?.LogDebug("{Name}: {Error}", name, error); + + return new ContainerRuntimeInfo + { + Executable = executable, + Name = name, + IsInstalled = isInstalled, + IsRunning = false, + IsDefault = isDefault, + ClientVersion = partialVersionInfo.ClientVersion, + IsDockerDesktop = partialVersionInfo.IsDockerDesktop, + Error = error + }; + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException) + { + logger?.LogDebug("{Name}: not found on PATH ({ExceptionMessage})", name, ex.Message); + return new ContainerRuntimeInfo + { + Executable = executable, + Name = name, + IsInstalled = false, + IsRunning = false, + IsDefault = isDefault, + Error = $"{name} CLI not found on PATH." + }; + } + } + + private static async Task IsCliInstalledAsync(string executable, CancellationToken cancellationToken) + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = executable, + Arguments = "--version", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(startInfo); + if (process is null) + { + return false; + } + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(s_processTimeout); + + try + { + await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false); + return process.ExitCode == 0; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + try { process.Kill(); } catch { /* best effort */ } + return false; + } + } + catch + { + return false; + } + } + + /// + /// Selects the best runtime from pre-probed results using DCP's priority logic. + /// Use this when you've already probed runtimes and want to determine which one to use. + /// + public static ContainerRuntimeInfo? FindBestRuntime(IEnumerable results) + { + ContainerRuntimeInfo? best = null; + foreach (var candidate in results) + { + if (best is null) + { + best = candidate; + continue; + } + + if (!best.IsInstalled && candidate.IsInstalled) + { + best = candidate; + } + else if (!best.IsRunning && candidate.IsRunning) + { + best = candidate; + } + else if (candidate.IsDefault + && candidate.IsInstalled == best.IsInstalled + && candidate.IsRunning == best.IsRunning) + { + best = candidate; + } + } + + return best; + } + + /// + /// Gathers version metadata from <runtime> version -f json. + /// + private static async Task GetVersionInfoAsync(string executable, CancellationToken cancellationToken) + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = executable, + Arguments = "version -f json", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(startInfo); + if (process is null) + { + return default; + } + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(s_processTimeout); + + string output; + try + { + output = await process.StandardOutput.ReadToEndAsync(timeoutCts.Token).ConfigureAwait(false); + await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + try { process.Kill(); } catch { /* best effort */ } + return default; + } + + return ParseVersionOutput(output); + } + catch + { + return default; + } + } + + /// + /// Parses the JSON output from docker/podman version -f json using source-generated JSON serialization. + /// + /// + /// Docker: + /// + /// {"Client":{"Version":"28.0.1","Context":"desktop-linux"},"Server":{"Version":"27.5.0","Os":"linux"}} + /// + /// Podman: + /// + /// {"Client":{"Version":"4.9.3"},"Server":null} + /// + /// + internal static RuntimeVersionInfo ParseVersionOutput(string? output) + { + if (string.IsNullOrWhiteSpace(output)) + { + return default; + } + + try + { + var json = JsonSerializer.Deserialize(output, ContainerRuntimeJsonContext.Default.ContainerRuntimeVersionJson); + if (json is null) + { + return default; + } + + Version.TryParse(json.Client?.Version, out var clientVersion); + Version.TryParse(json.Server?.Version, out var serverVersion); + var context = json.Client?.Context; + var isDockerDesktop = context is not null && + context.Contains("desktop", StringComparison.OrdinalIgnoreCase); + + return new RuntimeVersionInfo(clientVersion, serverVersion, isDockerDesktop, json.Server?.Os); + } + catch (JsonException) + { + // Fall back to regex parsing for non-JSON output + var match = Regex.Match(output, @"[Vv]ersion\s*:?\s*(\d+\.\d+(?:\.\d+)?)", RegexOptions.IgnoreCase); + if (match.Success && Version.TryParse(match.Groups[1].Value, out var version)) + { + return new RuntimeVersionInfo(version, null, false, null); + } + + return default; + } + } + + internal readonly record struct RuntimeVersionInfo( + Version? ClientVersion, + Version? ServerVersion, + bool IsDockerDesktop, + string? ServerOs); +} + +internal sealed class ContainerRuntimeVersionJson +{ + [JsonPropertyName("Client")] + public ContainerRuntimeComponentJson? Client { get; set; } + + [JsonPropertyName("Server")] + public ContainerRuntimeComponentJson? Server { get; set; } +} + +internal sealed class ContainerRuntimeComponentJson +{ + [JsonPropertyName("Version")] + public string? Version { get; set; } + + [JsonPropertyName("Context")] + public string? Context { get; set; } + + [JsonPropertyName("Os")] + public string? Os { get; set; } +} + +[JsonSerializable(typeof(ContainerRuntimeVersionJson))] +internal sealed partial class ContainerRuntimeJsonContext : JsonSerializerContext +{ +} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs new file mode 100644 index 00000000000..5d7a0a269a8 --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs @@ -0,0 +1,137 @@ +// 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 Aspire.TestUtilities; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// End-to-end tests for Aspire CLI deployment to Docker Compose using Podman as the container runtime. +/// Validates that setting ASPIRE_CONTAINER_RUNTIME=podman flows through to compose operations. +/// Requires Podman and docker-compose v2 installed on the host. +/// +public sealed class PodmanDeploymentTests(ITestOutputHelper output) +{ + private const string ProjectName = "AspirePodmanDeployTest"; + + [Fact] + [ActiveIssue("https://github.com/mitchdenny/hex1b/pull/270")] + [OuterloopTest("Requires Podman and docker-compose v2 installed on the host")] + public async Task CreateAndDeployToDockerComposeWithPodman() + { + using var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + // PrepareEnvironment + await auto.PrepareEnvironmentAsync(workspace, counter); + + if (isCI) + { + await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter); + await auto.SourceAspireCliEnvironmentAsync(counter); + await auto.VerifyAspireCliVersionAsync(commitSha, counter); + } + + // Step 0: Verify Podman is available, skip if not + await auto.TypeAsync("podman --version || echo 'PODMAN_NOT_FOUND'"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(10)); + + // Step 1: Set the container runtime to Podman + await auto.TypeAsync("export ASPIRE_CONTAINER_RUNTIME=podman"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Step 2: Create a new Aspire Starter App (no Redis cache) + await auto.AspireNewAsync(ProjectName, counter, useRedisCache: false); + + // Step 3: Navigate into the project directory + await auto.TypeAsync($"cd {ProjectName}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Step 4: Add Aspire.Hosting.Docker package using aspire add + await auto.TypeAsync("aspire add Aspire.Hosting.Docker"); + await auto.EnterAsync(); + + if (isCI) + { + await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60)); + await auto.EnterAsync(); + } + + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180)); + + // Step 5: Modify AppHost's main file to add Docker Compose environment + { + var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, ProjectName); + var appHostDir = Path.Combine(projectDir, $"{ProjectName}.AppHost"); + var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs"); + + output.WriteLine($"Looking for AppHost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Docker Compose environment for deployment +builder.AddDockerComposeEnvironment("compose"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified AppHost.cs at: {appHostFilePath}"); + } + + // Step 6: Create output directory for deployment artifacts + await auto.TypeAsync("mkdir -p deploy-output"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Step 7: Unset ASPIRE_PLAYGROUND before deploy + await auto.TypeAsync("unset ASPIRE_PLAYGROUND"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Step 8: Run aspire deploy with Podman as the container runtime + await auto.TypeAsync("aspire deploy -o deploy-output --non-interactive"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5)); + + // Step 9: Verify containers are running with podman ps + await auto.TypeAsync("podman ps"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Step 10: Verify the application is accessible + await auto.TypeAsync("curl -s -o /dev/null -w '%{http_code}' http://localhost:$(podman ps --format '{{.Ports}}' --filter 'name=webfrontend' | grep -oE '0\\.0\\.0\\.0:[0-9]+->8080' | head -1 | cut -d: -f2 | cut -d'-' -f1) 2>/dev/null || echo 'request-failed'"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); + + // Step 11: Clean up - stop and remove containers using podman + await auto.TypeAsync("cd deploy-output && podman compose down --volumes --remove-orphans 2>/dev/null || true"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60)); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/ContainerRuntimeCheckTests.cs b/tests/Aspire.Cli.Tests/Utils/ContainerRuntimeCheckTests.cs index 72d435400a8..9faea5c2dc6 100644 --- a/tests/Aspire.Cli.Tests/Utils/ContainerRuntimeCheckTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/ContainerRuntimeCheckTests.cs @@ -1,7 +1,7 @@ // 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.Utils.EnvironmentChecker; +using Aspire.Shared; namespace Aspire.Cli.Tests.Utils; @@ -13,13 +13,14 @@ public void ParseVersionFromJsonOutput_WithDockerJsonOutput_ReturnsBothVersions( // Real Docker version -f json output with both client and server var input = """{"Client":{"Platform":{"Name":"Docker Engine - Community"},"Version":"28.0.4","ApiVersion":"1.48","DefaultAPIVersion":"1.48","GitCommit":"b8034c0","GoVersion":"go1.23.7","Os":"linux","Arch":"amd64","BuildTime":"Tue Mar 25 15:07:16 2025","Context":"default"},"Server":{"Platform":{"Name":"Docker Engine - Community"},"Components":[{"Name":"Engine","Version":"28.0.4"}],"Version":"28.0.4","ApiVersion":"1.48"}}"""; - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input); + var info = ContainerRuntimeDetector.ParseVersionOutput(input); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.NotNull(clientVersion); Assert.Equal(new Version(28, 0, 4), clientVersion); Assert.NotNull(serverVersion); Assert.Equal(new Version(28, 0, 4), serverVersion); - Assert.Equal("default", context); + Assert.False(info.IsDockerDesktop); Assert.Null(serverOs); } @@ -29,13 +30,14 @@ public void ParseVersionFromJsonOutput_WithDockerDesktopMacJsonOutput_ReturnsBot // Docker Desktop on macOS JSON output var input = """{"Client":{"Version":"28.5.1","ApiVersion":"1.51","DefaultAPIVersion":"1.51","GitCommit":"e180ab8","GoVersion":"go1.24.8","Os":"darwin","Arch":"arm64","BuildTime":"Wed Oct 8 12:16:17 2025","Context":"desktop-linux"},"Server":{"Platform":{"Name":"Docker Desktop 4.49.0 (208700)"},"Components":[{"Name":"Engine","Version":"28.5.1"}],"Version":"28.5.1","ApiVersion":"1.51","MinAPIVersion":"1.24","GitCommit":"f8215cc","GoVersion":"go1.24.8","Os":"linux","Arch":"arm64","KernelVersion":"6.10.14-linuxkit","BuildTime":"2025-10-08T12:18:25.000000000+00:00"}}"""; - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input); + var info = ContainerRuntimeDetector.ParseVersionOutput(input); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.NotNull(clientVersion); Assert.Equal(new Version(28, 5, 1), clientVersion); Assert.NotNull(serverVersion); Assert.Equal(new Version(28, 5, 1), serverVersion); - Assert.Equal("desktop-linux", context); + Assert.True(info.IsDockerDesktop); Assert.Equal("linux", serverOs); } @@ -45,13 +47,14 @@ public void ParseVersionFromJsonOutput_WithDockerEngineJsonOutput_ReturnsBothVer // Docker Engine (Linux) JSON output var input = """{"Client":{"Platform":{"Name":"Docker Engine - Community"},"Version":"29.1.3","ApiVersion":"1.52","DefaultAPIVersion":"1.52","GitCommit":"f52814d","GoVersion":"go1.25.5","Os":"linux","Arch":"amd64","BuildTime":"Fri Dec 12 14:49:37 2025","Context":"default"},"Server":{"Platform":{"Name":"Docker Engine - Community"},"Version":"29.1.3","ApiVersion":"1.52","MinAPIVersion":"1.44","Os":"linux","Arch":"amd64","Components":[{"Name":"Engine","Version":"29.1.3"}],"GitCommit":"fbf3ed2","GoVersion":"go1.25.5","KernelVersion":"5.15.0-113-generic","BuildTime":"2025-12-12T14:49:37.000000000+00:00"}}"""; - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input); + var info = ContainerRuntimeDetector.ParseVersionOutput(input); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.NotNull(clientVersion); Assert.Equal(new Version(29, 1, 3), clientVersion); Assert.NotNull(serverVersion); Assert.Equal(new Version(29, 1, 3), serverVersion); - Assert.Equal("default", context); + Assert.False(info.IsDockerDesktop); Assert.Equal("linux", serverOs); } @@ -61,12 +64,13 @@ public void ParseVersionFromJsonOutput_WithPodmanJsonOutput_ReturnsClientVersion // Real Podman version -f json output (no Server section) var input = """{"Client":{"APIVersion":"4.9.3","Version":"4.9.3","GoVersion":"go1.22.2","GitCommit":"","BuiltTime":"Thu Jan 1 00:00:00 1970","Built":0,"OsArch":"linux/amd64","Os":"linux"}}"""; - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input); + var info = ContainerRuntimeDetector.ParseVersionOutput(input); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.NotNull(clientVersion); Assert.Equal(new Version(4, 9, 3), clientVersion); Assert.Null(serverVersion); - Assert.Null(context); + // Context is not exposed directly; tested via IsDockerDesktop Assert.Null(serverOs); } @@ -76,12 +80,13 @@ public void ParseVersionFromJsonOutput_WithDockerDesktopWindowsJsonOutput_Server // Docker Desktop on Windows may have Server:null if daemon is not running var input = """{"Client":{"Version":"29.1.3","ApiVersion":"1.52","DefaultAPIVersion":"1.52","GitCommit":"f52814d","GoVersion":"go1.25.5","Os":"windows","Arch":"amd64","BuildTime":"Fri Dec 12 14:51:52 2025","Context":"desktop-linux"},"Server":null}"""; - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input); + var info = ContainerRuntimeDetector.ParseVersionOutput(input); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.NotNull(clientVersion); Assert.Equal(new Version(29, 1, 3), clientVersion); Assert.Null(serverVersion); - Assert.Equal("desktop-linux", context); + Assert.True(info.IsDockerDesktop); Assert.Null(serverOs); } @@ -90,12 +95,13 @@ public void ParseVersionFromJsonOutput_WithOldDockerVersion_ReturnsClientVersion { var input = """{"Client":{"Version":"19.03.15","ApiVersion":"1.40"},"Server":null}"""; - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input); + var info = ContainerRuntimeDetector.ParseVersionOutput(input); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.NotNull(clientVersion); Assert.Equal(new Version(19, 3, 15), clientVersion); Assert.Null(serverVersion); - Assert.Null(context); + // Context is not exposed directly; tested via IsDockerDesktop Assert.Null(serverOs); } @@ -104,12 +110,13 @@ public void ParseVersionFromJsonOutput_WithTwoPartVersion_ReturnsVersion() { var input = """{"Client":{"Version":"20.10","ApiVersion":"1.41"}}"""; - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input); + var info = ContainerRuntimeDetector.ParseVersionOutput(input); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.NotNull(clientVersion); Assert.Equal(new Version(20, 10), clientVersion); Assert.Null(serverVersion); - Assert.Null(context); + // Context is not exposed directly; tested via IsDockerDesktop Assert.Null(serverOs); } @@ -122,11 +129,12 @@ public void ParseVersionFromJsonOutput_WithTwoPartVersion_ReturnsVersion() [InlineData("""{"Client":{}}""")] public void ParseVersionFromJsonOutput_WithInvalidInput_ReturnsNullVersions(string? input) { - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input!); + var info = ContainerRuntimeDetector.ParseVersionOutput(input!); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.Null(clientVersion); Assert.Null(serverVersion); - Assert.Null(context); + // Context is not exposed directly; tested via IsDockerDesktop Assert.Null(serverOs); } @@ -136,12 +144,13 @@ public void ParseVersionFromJsonOutput_WithOnlyServerVersion_ReturnsServerVersio // Edge case: only server version present (unusual but possible) var input = """{"Server":{"Version":"1.0.0"}}"""; - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input); + var info = ContainerRuntimeDetector.ParseVersionOutput(input); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.Null(clientVersion); Assert.NotNull(serverVersion); Assert.Equal(new Version(1, 0, 0), serverVersion); - Assert.Null(context); + // Context is not exposed directly; tested via IsDockerDesktop Assert.Null(serverOs); } @@ -150,11 +159,12 @@ public void ParseVersionFromJsonOutput_WithInvalidVersionString_ReturnsNull() { var input = """{"Client":{"Version":"not-a-version"}}"""; - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input); + var info = ContainerRuntimeDetector.ParseVersionOutput(input); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.Null(clientVersion); Assert.Null(serverVersion); - Assert.Null(context); + // Context is not exposed directly; tested via IsDockerDesktop Assert.Null(serverOs); } @@ -163,11 +173,12 @@ public void ParseVersionFromJsonOutput_WithMalformedJson_ReturnsNull() { var input = "{\"Client\":{\"Version\":\"28.0.4\""; // Missing closing braces - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input); + var info = ContainerRuntimeDetector.ParseVersionOutput(input); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.Null(clientVersion); Assert.Null(serverVersion); - Assert.Null(context); + // Context is not exposed directly; tested via IsDockerDesktop Assert.Null(serverOs); } @@ -177,13 +188,14 @@ public void ParseVersionFromJsonOutput_WithMismatchedClientServerVersions_Return // Hypothetical case where client and server versions differ var input = """{"Client":{"Version":"28.0.4"},"Server":{"Version":"27.5.1"}}"""; - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input); + var info = ContainerRuntimeDetector.ParseVersionOutput(input); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.NotNull(clientVersion); Assert.Equal(new Version(28, 0, 4), clientVersion); Assert.NotNull(serverVersion); Assert.Equal(new Version(27, 5, 1), serverVersion); - Assert.Null(context); + // Context is not exposed directly; tested via IsDockerDesktop Assert.Null(serverOs); } @@ -193,13 +205,14 @@ public void ParseVersionFromJsonOutput_WithWindowsContainerMode_ReturnsWindowsSe // Docker running in Windows container mode var input = """{"Client":{"Version":"28.0.4","Context":"default"},"Server":{"Version":"28.0.4","Os":"windows"}}"""; - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input); + var info = ContainerRuntimeDetector.ParseVersionOutput(input); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.NotNull(clientVersion); Assert.Equal(new Version(28, 0, 4), clientVersion); Assert.NotNull(serverVersion); Assert.Equal(new Version(28, 0, 4), serverVersion); - Assert.Equal("default", context); + Assert.False(info.IsDockerDesktop); Assert.Equal("windows", serverOs); } @@ -214,7 +227,7 @@ public void ParseVersionFromJsonOutput_WithWindowsContainerMode_ReturnsWindowsSe [InlineData("Docker version 20.10, build abc123", "20.10")] public void ParseVersionFromOutput_WithValidVersionString_ReturnsCorrectVersion(string input, string expectedVersion) { - var result = ContainerRuntimeCheck.ParseVersionFromOutput(input); + var result = ContainerRuntimeDetector.ParseVersionOutput(input).ClientVersion; Assert.NotNull(result); Assert.Equal(Version.Parse(expectedVersion), result); @@ -228,7 +241,7 @@ public void ParseVersionFromOutput_WithValidVersionString_ReturnsCorrectVersion( [InlineData("random text without version info")] public void ParseVersionFromOutput_WithInvalidInput_ReturnsNull(string input) { - var result = ContainerRuntimeCheck.ParseVersionFromOutput(input); + var result = ContainerRuntimeDetector.ParseVersionOutput(input).ClientVersion; Assert.Null(result); } @@ -236,9 +249,9 @@ public void ParseVersionFromOutput_WithInvalidInput_ReturnsNull(string input) [Fact] public void ParseVersionFromOutput_WithNullInput_ReturnsNull() { - var result = ContainerRuntimeCheck.ParseVersionFromOutput(null!); + var result = ContainerRuntimeDetector.ParseVersionOutput(null!); - Assert.Null(result); + Assert.Null(result.ClientVersion); } [Fact] @@ -250,7 +263,7 @@ public void ParseVersionFromOutput_WithDockerDesktopOutput_ReturnsVersion() """; - var result = ContainerRuntimeCheck.ParseVersionFromOutput(input); + var result = ContainerRuntimeDetector.ParseVersionOutput(input).ClientVersion; Assert.NotNull(result); Assert.Equal(new Version(27, 5, 1), result); @@ -265,7 +278,7 @@ podman version 4.3.1 API Version: 4.3.1 """; - var result = ContainerRuntimeCheck.ParseVersionFromOutput(input); + var result = ContainerRuntimeDetector.ParseVersionOutput(input).ClientVersion; Assert.NotNull(result); Assert.Equal(new Version(4, 3, 1), result); @@ -284,7 +297,7 @@ public void ParseVersionFromOutput_WithCaseInsensitiveVersion_ReturnsVersion() foreach (var input in inputs) { - var result = ContainerRuntimeCheck.ParseVersionFromOutput(input); + var result = ContainerRuntimeDetector.ParseVersionOutput(input).ClientVersion; Assert.NotNull(result); } } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs index 4694845207a..0ef66685757 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs @@ -1293,7 +1293,8 @@ private void ConfigureTestServices(IDistributedApplicationTestingBuilder builder builder.Services.AddSingleton(processRunner ?? new MockProcessRunner()); builder.Services.AddSingleton(); builder.Services.AddSingleton(containerRuntime ?? new FakeContainerRuntime()); - builder.Services.AddSingleton(sp => new FakeAcrLoginService(sp.GetRequiredService())); + builder.Services.AddSingleton(sp => (IContainerRuntimeResolver)sp.GetRequiredService()); + builder.Services.AddSingleton(sp => new FakeAcrLoginService(sp.GetRequiredService())); } private sealed class NoOpDeploymentStateManager : IDeploymentStateManager @@ -1730,6 +1731,7 @@ private static void ConfigureTestServicesWithFileDeploymentStateManager( builder.Services.AddSingleton(new MockProcessRunner()); builder.Services.AddSingleton(); builder.Services.AddSingleton(new FakeContainerRuntime()); - builder.Services.AddSingleton(sp => new FakeAcrLoginService(sp.GetRequiredService())); + builder.Services.AddSingleton(sp => (IContainerRuntimeResolver)sp.GetRequiredService()); + builder.Services.AddSingleton(sp => new FakeAcrLoginService(sp.GetRequiredService())); } } diff --git a/tests/Aspire.Hosting.Azure.Tests/FakeAcrLoginService.cs b/tests/Aspire.Hosting.Azure.Tests/FakeAcrLoginService.cs index 52c64379485..8b427dc22b8 100644 --- a/tests/Aspire.Hosting.Azure.Tests/FakeAcrLoginService.cs +++ b/tests/Aspire.Hosting.Azure.Tests/FakeAcrLoginService.cs @@ -12,15 +12,15 @@ internal sealed class FakeAcrLoginService : IAcrLoginService { private const string AcrUsername = "00000000-0000-0000-0000-000000000000"; - private readonly IContainerRuntime _containerRuntime; + private readonly IContainerRuntimeResolver _containerRuntimeResolver; public bool WasLoginCalled { get; private set; } public string? LastRegistryEndpoint { get; private set; } public string? LastTenantId { get; private set; } - public FakeAcrLoginService(IContainerRuntime containerRuntime) + public FakeAcrLoginService(IContainerRuntimeResolver containerRuntimeResolver) { - _containerRuntime = containerRuntime ?? throw new ArgumentNullException(nameof(containerRuntime)); + _containerRuntimeResolver = containerRuntimeResolver ?? throw new ArgumentNullException(nameof(containerRuntimeResolver)); } public async Task LoginAsync( @@ -33,8 +33,7 @@ public async Task LoginAsync( LastRegistryEndpoint = registryEndpoint; LastTenantId = tenantId; - // Call the container runtime to match real implementation behavior - // This allows tests to verify the container runtime was called - await _containerRuntime.LoginToRegistryAsync(registryEndpoint, AcrUsername, "fake-refresh-token", cancellationToken); + var containerRuntime = await _containerRuntimeResolver.ResolveAsync(cancellationToken); + await containerRuntime.LoginToRegistryAsync(registryEndpoint, AcrUsername, "fake-refresh-token", cancellationToken); } } diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs index 00529c619d0..5577f29920a 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs @@ -668,6 +668,7 @@ public async Task PushImageToRegistry_WithLocalRegistry_OnlyTagsImage() var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path, step: "push-servicea"); builder.Services.AddSingleton(); builder.Services.AddSingleton(fakeRuntime); + builder.Services.AddSingleton(sp => (IContainerRuntimeResolver)sp.GetRequiredService()); // No registry added - will use LocalContainerRegistry with empty endpoint builder.AddDockerComposeEnvironment("docker-compose"); @@ -698,6 +699,7 @@ public async Task PushImageToRegistry_WithRemoteRegistry_PushesImage() var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path, step: "push-servicea"); builder.Services.AddSingleton(new MockImageBuilderWithRuntime(fakeRuntime)); builder.Services.AddSingleton(fakeRuntime); + builder.Services.AddSingleton(sp => (IContainerRuntimeResolver)sp.GetRequiredService()); // Add a remote registry with a non-empty endpoint var registry = builder.AddContainerRegistry("acr", "myregistry.azurecr.io"); diff --git a/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs b/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs index 831cbd80008..81bcacdb454 100644 --- a/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs @@ -842,6 +842,7 @@ public async Task ProjectResourceWithContainerFilesDestinationAnnotationWorks() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: "build-projectName"); builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => (IContainerRuntimeResolver)sp.GetRequiredService()); builder.Services.AddSingleton(); // Create a test container resource that implements IResourceWithContainerFiles diff --git a/tests/Aspire.Hosting.Tests/Publishing/ComposeServiceParsingTests.cs b/tests/Aspire.Hosting.Tests/Publishing/ComposeServiceParsingTests.cs new file mode 100644 index 00000000000..2adec508074 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Publishing/ComposeServiceParsingTests.cs @@ -0,0 +1,129 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIRECONTAINERRUNTIME001 + +using Aspire.Hosting.Publishing; + +namespace Aspire.Hosting.Tests.Publishing; + +public class ComposeServiceParsingTests +{ + [Fact] + public void ParseComposeServiceEntries_NdjsonFormat_ParsesCorrectly() + { + var lines = new List + { + """{"Service":"web","Publishers":[{"URL":"","TargetPort":80,"PublishedPort":8080,"Protocol":"tcp"}]}""", + """{"Service":"cache","Publishers":[{"URL":"","TargetPort":6379,"PublishedPort":6379,"Protocol":"tcp"}]}""" + }; + + var results = ContainerRuntimeBase.ParseComposeServiceEntries(lines); + + Assert.Equal(2, results.Count); + Assert.Equal("web", results[0].Service); + Assert.Equal(80, results[0].Publishers?[0].TargetPort); + Assert.Equal(8080, results[0].Publishers?[0].PublishedPort); + Assert.Equal("cache", results[1].Service); + } + + [Fact] + public void ParseComposeServiceEntries_JsonArrayFormat_ParsesCorrectly() + { + var lines = new List + { + """[{"Service":"web","Publishers":[{"TargetPort":80,"PublishedPort":8080}]},{"Service":"db","Publishers":[]}]""" + }; + + var results = ContainerRuntimeBase.ParseComposeServiceEntries(lines); + + Assert.Equal(2, results.Count); + Assert.Equal("web", results[0].Service); + Assert.Equal("db", results[1].Service); + } + + [Fact] + public void ParseComposeServiceEntries_EmptyLines_ReturnsEmpty() + { + var results = ContainerRuntimeBase.ParseComposeServiceEntries([]); + + Assert.Empty(results); + } + + [Fact] + public void ParseComposeServiceEntries_InvalidJson_SkipsLine() + { + var lines = new List + { + "not json", + """{"Service":"web","Publishers":[{"TargetPort":80,"PublishedPort":8080}]}""" + }; + + var results = ContainerRuntimeBase.ParseComposeServiceEntries(lines); + + Assert.Single(results); + Assert.Equal("web", results[0].Service); + } + + [Fact] + public void ParsePodmanPsOutput_ParsesPortsAndLabels() + { + var lines = new List + { + """[{"Labels":{"com.docker.compose.service":"web"},"Ports":[{"host_ip":"","container_port":80,"host_port":8080,"range":1,"protocol":"tcp"}]},{"Labels":{"com.docker.compose.service":"cache"},"Ports":[{"host_ip":"","container_port":6379,"host_port":6379,"range":1,"protocol":"tcp"}]}]""" + }; + + var results = PodmanContainerRuntime.ParsePodmanPsOutput(lines); + + Assert.Equal(2, results.Count); + Assert.Equal("web", results[0].Service); + Assert.Equal(80, results[0].Publishers?[0].TargetPort); + Assert.Equal(8080, results[0].Publishers?[0].PublishedPort); + Assert.Equal("cache", results[1].Service); + Assert.Equal(6379, results[1].Publishers?[0].TargetPort); + } + + [Fact] + public void ParsePodmanPsOutput_AggregatesMultipleContainersPerService() + { + var lines = new List + { + """[{"Labels":{"com.docker.compose.service":"web"},"Ports":[{"container_port":80,"host_port":8080}]},{"Labels":{"com.docker.compose.service":"web"},"Ports":[{"container_port":443,"host_port":8443}]}]""" + }; + + var results = PodmanContainerRuntime.ParsePodmanPsOutput(lines); + + Assert.Single(results); + Assert.Equal("web", results[0].Service); + Assert.Equal(2, results[0].Publishers?.Count); + } + + [Fact] + public void ParsePodmanPsOutput_NoLabels_SkipsContainer() + { + var lines = new List + { + """[{"Labels":{},"Ports":[{"container_port":80,"host_port":8080}]}]""" + }; + + var results = PodmanContainerRuntime.ParsePodmanPsOutput(lines); + + Assert.Empty(results); + } + + [Fact] + public void ParsePodmanPsOutput_EmptyInput_ReturnsEmpty() + { + var results = PodmanContainerRuntime.ParsePodmanPsOutput([]); + + Assert.Empty(results); + } + + [Fact] + public void ParsePodmanPsOutput_InvalidJson_ReturnsEmpty() + { + var results = PodmanContainerRuntime.ParsePodmanPsOutput(["not json"]); + + Assert.Empty(results); + } +} diff --git a/tests/Aspire.Hosting.Tests/Publishing/ContainerRuntimeDetectorTests.cs b/tests/Aspire.Hosting.Tests/Publishing/ContainerRuntimeDetectorTests.cs new file mode 100644 index 00000000000..ff677089a29 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Publishing/ContainerRuntimeDetectorTests.cs @@ -0,0 +1,179 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Shared; + +namespace Aspire.Hosting.Tests.Publishing; + +public class ContainerRuntimeDetectorTests +{ + [Fact] + public void FindBestRuntime_PrefersRunningOverInstalled() + { + var runtimes = new[] + { + new ContainerRuntimeInfo { Executable = "docker", Name = "Docker", IsInstalled = true, IsRunning = false, IsDefault = true }, + new ContainerRuntimeInfo { Executable = "podman", Name = "Podman", IsInstalled = true, IsRunning = true, IsDefault = false } + }; + + var best = ContainerRuntimeDetector.FindBestRuntime(runtimes); + + Assert.Equal("podman", best?.Executable); + } + + [Fact] + public void FindBestRuntime_PrefersInstalledOverNotInstalled() + { + var runtimes = new[] + { + new ContainerRuntimeInfo { Executable = "docker", Name = "Docker", IsInstalled = false, IsRunning = false, IsDefault = true }, + new ContainerRuntimeInfo { Executable = "podman", Name = "Podman", IsInstalled = true, IsRunning = false, IsDefault = false } + }; + + var best = ContainerRuntimeDetector.FindBestRuntime(runtimes); + + Assert.Equal("podman", best?.Executable); + } + + [Fact] + public void FindBestRuntime_PrefersDefaultWhenEqual() + { + var runtimes = new[] + { + new ContainerRuntimeInfo { Executable = "docker", Name = "Docker", IsInstalled = true, IsRunning = true, IsDefault = true }, + new ContainerRuntimeInfo { Executable = "podman", Name = "Podman", IsInstalled = true, IsRunning = true, IsDefault = false } + }; + + var best = ContainerRuntimeDetector.FindBestRuntime(runtimes); + + Assert.Equal("docker", best?.Executable); + } + + [Fact] + public void FindBestRuntime_ReturnsNullForEmpty() + { + var best = ContainerRuntimeDetector.FindBestRuntime([]); + + Assert.Null(best); + } + + [Fact] + public void FindBestRuntime_ReturnsSingleRuntime() + { + var runtimes = new[] + { + new ContainerRuntimeInfo { Executable = "podman", Name = "Podman", IsInstalled = true, IsRunning = true, IsDefault = false } + }; + + var best = ContainerRuntimeDetector.FindBestRuntime(runtimes); + + Assert.Equal("podman", best?.Executable); + } + + [Fact] + public void FindBestRuntime_NeitherInstalled_ReturnsDefault() + { + var runtimes = new[] + { + new ContainerRuntimeInfo { Executable = "docker", Name = "Docker", IsInstalled = false, IsRunning = false, IsDefault = true }, + new ContainerRuntimeInfo { Executable = "podman", Name = "Podman", IsInstalled = false, IsRunning = false, IsDefault = false } + }; + + var best = ContainerRuntimeDetector.FindBestRuntime(runtimes); + + Assert.Equal("docker", best?.Executable); + } + + [Fact] + public void ParseVersionOutput_ValidDockerJson_ParsesVersions() + { + var json = """ + { + "Client": { "Version": "28.0.1", "Context": "desktop-linux" }, + "Server": { "Version": "27.5.0", "Os": "linux" } + } + """; + + var info = ContainerRuntimeDetector.ParseVersionOutput(json); + + Assert.Equal(new Version(28, 0, 1), info.ClientVersion); + Assert.Equal(new Version(27, 5, 0), info.ServerVersion); + Assert.True(info.IsDockerDesktop); + Assert.Equal("linux", info.ServerOs); + } + + [Fact] + public void ParseVersionOutput_DockerEngine_NotDesktop() + { + var json = """ + { + "Client": { "Version": "29.1.3" }, + "Server": { "Version": "29.1.3", "Os": "linux" } + } + """; + + var info = ContainerRuntimeDetector.ParseVersionOutput(json); + + Assert.Equal(new Version(29, 1, 3), info.ClientVersion); + Assert.False(info.IsDockerDesktop); + } + + [Fact] + public void ParseVersionOutput_PodmanJson_ParsesClient() + { + var json = """ + { + "Client": { "Version": "4.9.3" } + } + """; + + var info = ContainerRuntimeDetector.ParseVersionOutput(json); + + Assert.Equal(new Version(4, 9, 3), info.ClientVersion); + Assert.Null(info.ServerVersion); + Assert.False(info.IsDockerDesktop); + } + + [Fact] + public void ParseVersionOutput_NonJsonFallback_UsesRegex() + { + var text = "podman version 5.2.1"; + + var info = ContainerRuntimeDetector.ParseVersionOutput(text); + + Assert.Equal(new Version(5, 2, 1), info.ClientVersion); + } + + [Fact] + public void ParseVersionOutput_NullInput_ReturnsDefault() + { + var info = ContainerRuntimeDetector.ParseVersionOutput(null); + + Assert.Null(info.ClientVersion); + Assert.Null(info.ServerVersion); + Assert.False(info.IsDockerDesktop); + } + + [Fact] + public void ParseVersionOutput_EmptyInput_ReturnsDefault() + { + var info = ContainerRuntimeDetector.ParseVersionOutput(""); + + Assert.Null(info.ClientVersion); + } + + [Fact] + public void ParseVersionOutput_WindowsContainers_DetectsOs() + { + var json = """ + { + "Client": { "Version": "28.0.1", "Context": "desktop-linux" }, + "Server": { "Version": "28.0.1", "Os": "windows" } + } + """; + + var info = ContainerRuntimeDetector.ParseVersionOutput(json); + + Assert.Equal("windows", info.ServerOs); + } +} diff --git a/tests/Aspire.Hosting.Tests/Publishing/ContainerRuntimeResolverTests.cs b/tests/Aspire.Hosting.Tests/Publishing/ContainerRuntimeResolverTests.cs new file mode 100644 index 00000000000..d9b66250ccd --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Publishing/ContainerRuntimeResolverTests.cs @@ -0,0 +1,91 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIRECONTAINERRUNTIME001 + +using Aspire.Hosting.Dcp; +using Aspire.Hosting.Publishing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Aspire.Hosting.Tests.Publishing; + +public class ContainerRuntimeResolverTests +{ + private static ContainerRuntimeResolver CreateResolver( + string? configuredRuntime = null, + IServiceProvider? serviceProvider = null) + { + var services = new ServiceCollection(); + services.AddKeyedSingleton("docker"); + services.AddKeyedSingleton("podman"); + var sp = serviceProvider ?? services.BuildServiceProvider(); + + var dcpOptions = Options.Create(new DcpOptions { ContainerRuntime = configuredRuntime }); + return new ContainerRuntimeResolver(sp, dcpOptions, NullLoggerFactory.Instance); + } + + [Fact] + public async Task ResolveAsync_ReturnsSameInstance_OnSubsequentCalls() + { + var resolver = CreateResolver(configuredRuntime: "docker"); + + var first = await resolver.ResolveAsync(); + var second = await resolver.ResolveAsync(); + + Assert.Same(first, second); + } + + [Fact] + public async Task ResolveAsync_ReturnsSameTask_WhenCached() + { + var resolver = CreateResolver(configuredRuntime: "docker"); + + var task1 = resolver.ResolveAsync(); + var task2 = resolver.ResolveAsync(); + + Assert.Same(task1, task2); + await task1; + } + + [Fact] + public async Task ResolveAsync_ConfiguredRuntime_ReturnsKeyedService() + { + var resolver = CreateResolver(configuredRuntime: "podman"); + + var runtime = await resolver.ResolveAsync(); + + Assert.NotNull(runtime); + } + + [Fact] + public async Task ResolveAsync_AfterCancellation_RetriesWithNewToken() + { + var resolver = CreateResolver(configuredRuntime: null); + + // First call with an already-cancelled token + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // The first call may or may not throw depending on timing — + // if detection hasn't started yet, the token cancels it immediately. + Task? firstTask = null; + try + { + firstTask = resolver.ResolveAsync(cts.Token); + await firstTask; + } + catch (OperationCanceledException) + { + // Expected — first attempt was cancelled + } + + // Second call with a valid token should work (not return cached cancellation) + if (firstTask is { IsCanceled: true }) + { + var runtime = await resolver.ResolveAsync(CancellationToken.None); + Assert.NotNull(runtime); + } + } +} diff --git a/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs b/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs index 0e23c545ccf..00b6354f090 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs @@ -11,7 +11,7 @@ namespace Aspire.Hosting.Tests.Publishing; using Aspire.Hosting.ApplicationModel; -public sealed class FakeContainerRuntime(bool shouldFail = false, bool isRunning = true) : IContainerRuntime +public sealed class FakeContainerRuntime(bool shouldFail = false, bool isRunning = true) : IContainerRuntime, IContainerRuntimeResolver { public string Name => "fake-runtime"; public bool WasHealthCheckCalled { get; private set; } @@ -103,4 +103,32 @@ public Task LoginToRegistryAsync(string registryServer, string username, string } return Task.CompletedTask; } + + public Task ComposeUpAsync(ComposeOperationContext context, CancellationToken cancellationToken) + { + if (shouldFail) + { + throw new DistributedApplicationException("Fake container runtime is configured to fail"); + } + return Task.CompletedTask; + } + + public Task ComposeDownAsync(ComposeOperationContext context, CancellationToken cancellationToken) + { + if (shouldFail) + { + throw new DistributedApplicationException("Fake container runtime is configured to fail"); + } + return Task.CompletedTask; + } + + public Task?> ComposeListServicesAsync(ComposeOperationContext context, CancellationToken cancellationToken) + { + return Task.FromResult?>(null); + } + + public Task ResolveAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(this); + } }