diff --git a/eng/scripts/get-aspire-cli.ps1 b/eng/scripts/get-aspire-cli.ps1 index a775936479e..9a3cd4c1fda 100755 --- a/eng/scripts/get-aspire-cli.ps1 +++ b/eng/scripts/get-aspire-cli.ps1 @@ -1133,6 +1133,41 @@ function Get-AspireCliUrl { } } +function Invoke-AspireCliBundleSetup { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $true)] + [string]$CliPath, + [Parameter(Mandatory = $true)] + [string]$TargetOS, + [Parameter(Mandatory = $true)] + [string]$TargetArchitecture + ) + + try { + $hostOS = Get-OperatingSystem + $hostArch = Get-CLIArchitectureFromArchitecture "" + } + catch { + Write-Message "Skipping Aspire CLI bundle setup because the current platform could not be detected: $($_.Exception.Message)" -Level Warning + return + } + + if ($TargetOS -ne $hostOS -or $TargetArchitecture -ne $hostArch) { + Write-Message "Skipping Aspire CLI bundle setup for $TargetOS-$TargetArchitecture on $hostOS-$hostArch." -Level Info + return + } + + if ($PSCmdlet.ShouldProcess($CliPath, "Set up Aspire CLI bundle")) { + # Keep native stdout visible without adding it to Install-AspireCli's success output, + # whose sole return value is the target OS consumed by PATH configuration. + & $CliPath setup | Out-Host + if ($LASTEXITCODE -ne 0) { + throw "Aspire CLI bundle setup failed with exit code $LASTEXITCODE." + } + } +} + # Function to download and install the Aspire CLI function Install-AspireCli { [CmdletBinding(SupportsShouldProcess)] @@ -1253,6 +1288,8 @@ function Install-AspireCli { } } + Invoke-AspireCliBundleSetup -CliPath $cliPath -TargetOS $targetOS -TargetArchitecture $targetArch + # Return the target OS for the caller to use return $targetOS } diff --git a/eng/scripts/get-aspire-cli.sh b/eng/scripts/get-aspire-cli.sh index ed584d869e5..055d54c7a6f 100755 --- a/eng/scripts/get-aspire-cli.sh +++ b/eng/scripts/get-aspire-cli.sh @@ -21,6 +21,9 @@ VERSION="" QUALITY="" OS="" ARCH="" +INSTALLED_CLI_PATH="" +INSTALLED_CLI_OS="" +INSTALLED_CLI_ARCH="" SHOW_HELP=false VERBOSE=false KEEP_ARCHIVE=false @@ -995,6 +998,9 @@ download_and_install_archive() { cli_exe="aspire" fi cli_path="${INSTALL_PATH}/${cli_exe}" + INSTALLED_CLI_PATH="$cli_path" + INSTALLED_CLI_OS="$os" + INSTALLED_CLI_ARCH="$arch" say_info "Aspire CLI successfully installed to: ${GREEN}$cli_path${RESET}" @@ -1020,6 +1026,33 @@ download_and_install_archive() { fi } +setup_cli_bundle() { + local host_os host_arch + + if ! host_os=$(detect_os) || ! host_arch=$(get_cli_architecture_from_architecture ""); then + say_warn "Skipping Aspire CLI bundle setup because the current platform could not be detected." + return 0 + fi + + # Cross-target archive downloads are supported, but the downloaded executable cannot + # safely be run to extract its embedded bundle on a different host platform. + if [[ "$INSTALLED_CLI_OS" != "$host_os" || "$INSTALLED_CLI_ARCH" != "$host_arch" ]]; then + say_info "Skipping Aspire CLI bundle setup for ${INSTALLED_CLI_OS}-${INSTALLED_CLI_ARCH} on ${host_os}-${host_arch}." + return 0 + fi + + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would run: $INSTALLED_CLI_PATH setup" + return 0 + fi + + say_verbose "Running: $INSTALLED_CLI_PATH setup" + if ! "$INSTALLED_CLI_PATH" setup; then + say_error "Aspire CLI bundle setup failed" + return 1 + fi +} + # Main entry point — wraps everything after function definitions. # Guarded so that `source get-aspire-cli.sh` loads functions without # executing the main flow (enables Tier-1 unit tests). @@ -1106,6 +1139,10 @@ main() { printf '{"source":"script"}\n' > "$sidecar_path" fi + if ! setup_cli_bundle; then + exit 1 + fi + # Skip PATH configuration if --skip-path is set if [[ "$SKIP_PATH" != true ]]; then # Handle GitHub Actions environment diff --git a/src/Aspire.Cli/Bundles/BundleService.cs b/src/Aspire.Cli/Bundles/BundleService.cs index 65de6f98d1d..19f9ab68f8e 100644 --- a/src/Aspire.Cli/Bundles/BundleService.cs +++ b/src/Aspire.Cli/Bundles/BundleService.cs @@ -586,7 +586,7 @@ private bool TryRestoreLinks(string layoutPath, IReadOnlyDictionary /// Returns if contains the - /// essential bundle components (managed/aspire-managed and a DCP directory). + /// essential bundle components (managed/aspire-managed and the DCP executable). /// internal static bool IsVersionedLayoutValid(string versionDir) { @@ -617,7 +617,8 @@ internal static bool IsVersionedLayoutValid(string versionDir) } var dcpDir = Path.Combine(versionDir, BundleDiscovery.DcpDirectoryName); - if (!Directory.Exists(dcpDir)) + var dcpExe = BundleDiscovery.GetDcpExecutablePath(dcpDir); + if (!Directory.Exists(dcpDir) || !File.Exists(dcpExe)) { return false; } diff --git a/src/Aspire.Cli/Layout/LayoutDiscovery.cs b/src/Aspire.Cli/Layout/LayoutDiscovery.cs index 9fc954a398c..b0038c8d9f6 100644 --- a/src/Aspire.Cli/Layout/LayoutDiscovery.cs +++ b/src/Aspire.Cli/Layout/LayoutDiscovery.cs @@ -251,6 +251,7 @@ public bool IsBundleModeAvailable(string? projectDirectory = null) var bundleManagedPath = Path.Combine(bundlePath, BundleDiscovery.ManagedDirectoryName); var bundleDcpPath = Path.Combine(bundlePath, BundleDiscovery.DcpDirectoryName); var managedExeName = BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName); + var bundleDcpExe = BundleDiscovery.GetDcpExecutablePath(bundleDcpPath); _logger.LogDebug("TryInferLayout: Checking layout at {Path}", layoutPath); _logger.LogDebug(" {Dir}/{Managed}/: {Exists}", BundleDiscovery.BundleDirectoryName, BundleDiscovery.ManagedDirectoryName, Directory.Exists(bundleManagedPath) ? "exists" : "MISSING"); @@ -260,8 +261,9 @@ public bool IsBundleModeAvailable(string? projectDirectory = null) { var bundleManagedExe = Path.Combine(bundleManagedPath, managedExeName); _logger.LogDebug(" {Dir}/{Managed}/{Exe}: {Exists}", BundleDiscovery.BundleDirectoryName, BundleDiscovery.ManagedDirectoryName, managedExeName, File.Exists(bundleManagedExe) ? "exists" : "MISSING"); + _logger.LogDebug(" {Dir}/{Dcp}/{Exe}: {Exists}", BundleDiscovery.BundleDirectoryName, BundleDiscovery.DcpDirectoryName, Path.GetFileName(bundleDcpExe), File.Exists(bundleDcpExe) ? "exists" : "MISSING"); - if (File.Exists(bundleManagedExe)) + if (File.Exists(bundleManagedExe) && File.Exists(bundleDcpExe)) { _logger.LogDebug("TryInferLayout: New bundle/ layout is valid"); return new LayoutConfiguration @@ -279,6 +281,7 @@ public bool IsBundleModeAvailable(string? projectDirectory = null) // Legacy layout: top-level managed/ and dcp/ directories (or reparse points). var managedPath = Path.Combine(layoutPath, BundleDiscovery.ManagedDirectoryName); var dcpPath = Path.Combine(layoutPath, BundleDiscovery.DcpDirectoryName); + var dcpExePath = BundleDiscovery.GetDcpExecutablePath(dcpPath); _logger.LogDebug(" {Dir}/: {Exists}", BundleDiscovery.ManagedDirectoryName, Directory.Exists(managedPath) ? "exists" : "MISSING"); _logger.LogDebug(" {Dir}/: {Exists}", BundleDiscovery.DcpDirectoryName, Directory.Exists(dcpPath) ? "exists" : "MISSING"); @@ -292,10 +295,11 @@ public bool IsBundleModeAvailable(string? projectDirectory = null) // Check for aspire-managed executable var managedExePath = Path.Combine(managedPath, managedExeName); _logger.LogDebug(" managed/{ManagedExe}: {Exists}", managedExeName, File.Exists(managedExePath) ? "exists" : "MISSING"); + _logger.LogDebug(" dcp/{DcpExe}: {Exists}", Path.GetFileName(dcpExePath), File.Exists(dcpExePath) ? "exists" : "MISSING"); - if (!File.Exists(managedExePath)) + if (!File.Exists(managedExePath) || !File.Exists(dcpExePath)) { - _logger.LogDebug("TryInferLayout: Layout rejected - aspire-managed not found"); + _logger.LogDebug("TryInferLayout: Layout rejected - required executable not found"); return null; } @@ -338,7 +342,9 @@ private bool ValidateLayout(LayoutConfiguration layout) // Require DCP for valid layouts var dcpPath = layout.GetComponentPath(LayoutComponent.Dcp); - if (dcpPath is null || !Directory.Exists(dcpPath)) + if (dcpPath is null || + !Directory.Exists(dcpPath) || + !File.Exists(BundleDiscovery.GetDcpExecutablePath(dcpPath))) { _logger.LogDebug("Layout validation failed: DCP not found"); return false; diff --git a/src/Aspire.Cli/Projects/AppHostInfoResolver.cs b/src/Aspire.Cli/Projects/AppHostInfoResolver.cs index cc73231edb7..e23c3787509 100644 --- a/src/Aspire.Cli/Projects/AppHostInfoResolver.cs +++ b/src/Aspire.Cli/Projects/AppHostInfoResolver.cs @@ -117,9 +117,9 @@ private async Task FetchAppHostInfoCoreAsync(FileInfo projec var expectedCacheKey = diskCache.GetCacheKey(projectFile); // Mirror the property/item shape used by DotNetCliRunner.GetAppHostInformationAsync and - // additionally request AspireUseCliBundle, UserSecretsId, and run metadata so the CLI - // bundle handoff, --isolated user-secrets clone, and post-build AppHost launch path do - // not require their own MSBuild evaluations. + // additionally request AspireUseCliBundle, UserSecretsId, and run metadata so the CLI bundle + // handoff, --isolated user-secrets clone, and post-build AppHost launch path do not require + // their own MSBuild evaluations. // Adding extra -getProperty names is an evaluation-only cost. // // The Run* properties (RunCommand, RunArguments, RunWorkingDirectory) are only diff --git a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs index 79f1342ba0a..10d5334815a 100644 --- a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs +++ b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs @@ -1394,7 +1394,6 @@ public async Task ValidateAppHostAsync(FileInfo appHost // The resolver owns the cache/MSBuild fallback so validation and later run/publish // decisions share a single source of truth for AppHost project metadata. - using var cliBundleLease = await AcquireCliBundleLayoutAsync(cancellationToken); var information = await _appHostInfoResolver.GetAppHostInfoAsync(appHostFile, cancellationToken); if (information.ExitCode == 0 && information.IsAspireHost) @@ -1426,7 +1425,6 @@ public async Task ValidateAppHostAsync(FileInfo appHost // Use the same MSBuild-based inspection as validation so version resolution // follows the project model that run/publish already rely on, including // SDK-style projects, package references, and Central Package Management. - using var cliBundleLease = await AcquireCliBundleLayoutAsync(cancellationToken); var information = await _appHostInfoResolver.GetAppHostInfoAsync(appHostFile, cancellationToken); return information.ExitCode == 0 && information.IsAspireHost ? information.AspireHostingVersion @@ -1511,8 +1509,10 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken // is fine for non-CliBundle AppHosts that don't use WithTerminal() — the lease // is best-effort and a missing layout just means no terminal host env vars. var canQueryCliBundleProperty = !isSingleFileAppHost || !context.NoBuild; - var injectDcpAndDashboard = canQueryCliBundleProperty - && await IsUsingCliBundleAsync(effectiveAppHostFile, cancellationToken); + var appHostInfo = canQueryCliBundleProperty + ? await _appHostInfoResolver.GetAppHostInfoAsync(effectiveAppHostFile, cancellationToken) + : null; + var injectDcpAndDashboard = appHostInfo?.IsUsingCliBundle == true; ConfigureCliBundleEnvironment(env, cliBundleLease, injectDcpAndDashboard); // RunCommand may display captured AppHost output as soon as BuildCompletionSource is signaled. @@ -2278,9 +2278,6 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca var effectiveAppHostFile = context.AppHostFile; var isSingleFileAppHost = !IsProjectFile(effectiveAppHostFile) && IsValidSingleFileAppHost(effectiveAppHostFile); var env = new Dictionary(context.EnvironmentVariables); - var cliBundleLease = await AcquireCliBundleLayoutAsync(cancellationToken); - using var cliBundleLeaseScope = cliBundleLease; - ConfigureCliBundleEnvironment(env, cliBundleLease, injectDcpAndDashboard: false); // Check compatibility for project-based apphosts if (!isSingleFileAppHost) @@ -2305,12 +2302,6 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca } } - // See RunAsync for the rationale: terminal host env vars are injected even when - // the AppHost did not opt into AspireUseCliBundle, but DCP/Dashboard env vars are - // not (they would clobber per-RID NuGet metadata). - var injectDcpAndDashboardForPublish = await IsUsingCliBundleAsync(effectiveAppHostFile, cancellationToken); - ConfigureCliBundleEnvironment(env, cliBundleLease, injectDcpAndDashboardForPublish); - // Build the apphost (unless --no-build is specified) if (!isSingleFileAppHost && !context.NoBuild) { @@ -2470,14 +2461,6 @@ await _runner.InitUserSecretsAsync( } } - private async Task IsUsingCliBundleAsync(FileInfo projectFile, CancellationToken cancellationToken) - { - // Reuse the cached MSBuild result so `AspireUseCliBundle` is fetched alongside the - // IsAspireHost/version inspection rather than triggering a third project evaluation. - var info = await _appHostInfoResolver.GetAppHostInfoAsync(projectFile, cancellationToken); - return info.IsUsingCliBundle; - } - private Task AcquireCliBundleLayoutAsync(CancellationToken cancellationToken) => _bundleService.EnsureExtractedAndAcquireLayoutAsync("cli", "dotnet-apphost", cancellationToken); @@ -2494,28 +2477,32 @@ private void ConfigureCliBundleEnvironment( // disk) and would otherwise spam the debug log on every run. if (injectDcpAndDashboard) { - _logger.LogDebug("AspireUseCliBundle is enabled, but the Aspire CLI bundle layout was not available from this CLI process."); + _logger.LogDebug("AspireUseCliBundle is enabled, but the Aspire CLI bundle layout was not available from this CLI process. The AppHost will resolve configured, inherited, or assembly-metadata paths."); } // Don't return yet — repo-mode runs (DEBUG, `dotnet run --project src/Aspire.Cli`) // can still inject the terminal host path from the just-built artifact even when // no bundle layout exists at all (e.g. clean dev machine with no `aspire` install). } - if (!env.ContainsKey("AspireCliBundlePath") && !string.IsNullOrEmpty(layout?.LayoutPath)) + if (!HasEnvironmentOverride(env, "AspireCliBundlePath") && !string.IsNullOrEmpty(layout?.LayoutPath)) { env["AspireCliBundlePath"] = layout.LayoutPath; } if (injectDcpAndDashboard && layout is not null) { - if (!env.ContainsKey(BundleDiscovery.DcpPathEnvVar) && layout.GetDcpPath() is { } dcpPath) + if (!IsUsableDcpDirectory(GetEffectiveEnvironmentValue(env, BundleDiscovery.DcpPathEnvVar)) && + layout.GetDcpPath() is { } layoutDcpPath && + IsUsableDcpDirectory(layoutDcpPath)) { - env[BundleDiscovery.DcpPathEnvVar] = dcpPath; + env[BundleDiscovery.DcpPathEnvVar] = layoutDcpPath; } - if (!env.ContainsKey(BundleDiscovery.DashboardPathEnvVar) && layout.GetManagedPath() is { } managedPath) + if (!IsUsableDashboardPath(GetEffectiveEnvironmentValue(env, BundleDiscovery.DashboardPathEnvVar)) && + layout.GetManagedPath() is { } layoutManagedPath && + IsUsableDashboardPath(layoutManagedPath)) { - env[BundleDiscovery.DashboardPathEnvVar] = managedPath; + env[BundleDiscovery.DashboardPathEnvVar] = layoutManagedPath; } } @@ -2539,13 +2526,13 @@ private void ConfigureCliBundleEnvironment( // "older CLI" diagnostic. Installed CLIs are unaffected because DetectRepositoryRoot // only resolves via env var in release builds. // 3) Bundle layout aspire-managed (normal `aspire run` install path). - if (!env.ContainsKey(BundleDiscovery.TerminalHostPathEnvVar)) + if (!HasEnvironmentOverride(env, BundleDiscovery.TerminalHostPathEnvVar)) { var terminalHostPath = TryGetRepoLocalManagedPath() ?? layout?.GetManagedPath(); - if (terminalHostPath is not null) + if (terminalHostPath is not null && IsUsableDashboardPath(terminalHostPath)) { env[BundleDiscovery.TerminalHostPathEnvVar] = terminalHostPath; - if (!env.ContainsKey(BundleDiscovery.TerminalHostInvocationArgsEnvVar)) + if (!HasEnvironmentOverride(env, BundleDiscovery.TerminalHostInvocationArgsEnvVar)) { env[BundleDiscovery.TerminalHostInvocationArgsEnvVar] = "terminalhost"; } @@ -2555,11 +2542,20 @@ private void ConfigureCliBundleEnvironment( layoutLease?.AddEnvironment(env); } - /// - /// Resolves the repo-local aspire-managed binary when the CLI is running from - /// an Aspire repo checkout (typically dotnet run --project src/Aspire.Cli). - /// Returns null in release builds and when no repo-local build exists. - /// + private bool HasEnvironmentOverride(IReadOnlyDictionary env, string name) + => !string.IsNullOrWhiteSpace(GetEffectiveEnvironmentValue(env, name)); + + private string? GetEffectiveEnvironmentValue(IReadOnlyDictionary env, string name) + => env.TryGetValue(name, out var value) ? value : _environment.GetEnvironmentVariable(name); + + private static bool IsUsableDcpDirectory(string? path) + => !string.IsNullOrWhiteSpace(path) && + Directory.Exists(path) && + File.Exists(BundleDiscovery.GetDcpExecutablePath(path)); + + private static bool IsUsableDashboardPath(string? path) + => !string.IsNullOrWhiteSpace(path) && File.Exists(path); + /// /// Resolves the repo-local aspire-managed binary when the CLI is running from /// an Aspire repo checkout (typically dotnet run --project src/Aspire.Cli). diff --git a/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs index d60a49b0980..343edb3bab0 100644 --- a/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs @@ -332,5 +332,6 @@ public static string NoBuildNotSupportedWithWatchMode { return ResourceManager.GetString("NoBuildNotSupportedWithWatchMode", resourceCulture); } } + } } diff --git a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets index bfa347add2a..a5365380581 100644 --- a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets +++ b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets @@ -20,6 +20,8 @@ + + @@ -215,13 +217,10 @@ namespace Projects%3B + - - <_AspireCliBundleResolutionOptional Condition="'$(AspireCliPath)' == '' and '$(AspireCliBundlePath)' == '' and ('$(_AspireResolvedCliInvocationMode)' == 'Dnx' or '$(_AspireResolvedDnxHostPath)' != '')">true <_AspireResolvedDnxHostArgumentsPrefix Condition="'$(_AspireResolvedDnxHostArguments)' != ''">$(_AspireResolvedDnxHostArguments) @@ -230,9 +229,160 @@ namespace Projects%3B + Condition="'$(DesignTimeBuild)' != 'true' and '$(IsAspireHost)' == 'true' and '$(AspireUseCliBundle)' == 'true' and ('$(DcpDir)' == '' or ('$(AspireDashboardDir)' == '' and '$(AspireDashboardPath)' == ''))"> + + + <_AspireCliBundleSelectedCliPath Condition="'$(_AspireResolvedCliInvocationMode)' == 'Aspire'">$(_AspireResolvedCliPath) + + <_AspireCliBundleWarnOnInvalidPaths>false + <_AspireCliBundleWarnOnInvalidPaths Condition="'$(AspireCliBundlePath)' != '' or ('$(AspireCliPath)' != '' and !Exists('$(AspireCliPath)'))">true + + + AspireCliPath="$(_AspireCliBundleSelectedCliPath)" + SearchPathForAspireCli="false" + WarnOnInvalidPaths="$(_AspireCliBundleWarnOnInvalidPaths)"> + + + + + + + + + + + $(_AspireCliBundleDcpDir) + $(_AspireCliBundleDashboardDir) + $(_AspireCliBundleDashboardPath) + $(_AspireCliBundleTerminalHostDir) + $(_AspireCliBundleTerminalHostPath) + $(_AspireCliBundleTerminalHostInvocationArgs) + + + + <_AspireCliSetupPath Condition="'$(AspireCliPath)' != ''">$(AspireCliPath) + <_AspireCliSetupPath Condition="'$(AspireCliPath)' == '' and '$(_AspireResolvedCliInvocationMode)' == 'Aspire'">$(_AspireResolvedCliPath) + <_AspireCliSetupHome Condition="'$(_AspireCliSetupHome)' == ''">$(_AspireDefaultHomeDirectory) + <_AspireCliBundleSetupTimeout Condition="'$(_AspireCliBundleSetupTimeout)' == ''">120000 + + <_AspireCliSetupExecutable Condition="'$(AspireCliBundlePath)' == '' and Exists('$(_AspireCliSetupPath)')">$(_AspireCliSetupPath) + <_AspireCliSetupExecutable Condition="'$(AspireCliBundlePath)' == '' and '$(AspireCliPath)' == '' and '$(_AspireResolvedCliInvocationMode)' == 'Dnx' and Exists('$(_AspireResolvedDnxHostPath)')">$(_AspireResolvedDnxHostPath) + <_AspireCliSetupDiagnosticCommand Condition="'$(_AspireCliSetupExecutable)' != '' and '$(_AspireCliSetupPath)' != ''">"$(_AspireCliSetupPath)" setup + <_AspireCliSetupDiagnosticCommand Condition="'$(_AspireCliSetupExecutable)' != '' and '$(_AspireCliSetupPath)' == ''">"$(_AspireResolvedDnxHostPath)" $(_AspireResolvedDnxHostArgumentsPrefix)--yes $(_AspireCliDnxPackageReference) -- setup --install-path "$(_AspireCliSetupHome)" + + + + + <_AspireCliSetupArgument Remove="@(_AspireCliSetupArgument)" /> + <_AspireCliSetupArgument Include="AspireSetup" Condition="'$(_AspireCliSetupExecutable)' != '' and '$(_AspireCliSetupPath)' != ''"> + setup + + <_AspireCliSetupArgument Include="@(_AspireResolvedDnxHostArgument)" Condition="'$(_AspireCliSetupExecutable)' != '' and '$(_AspireCliSetupPath)' == ''" /> + <_AspireCliSetupArgument Include="DnxNonInteractive" Condition="'$(_AspireCliSetupExecutable)' != '' and '$(_AspireCliSetupPath)' == ''"> + --yes + + <_AspireCliSetupArgument Include="DnxPackage" Condition="'$(_AspireCliSetupExecutable)' != '' and '$(_AspireCliSetupPath)' == ''"> + $(_AspireCliDnxPackageReference) + + <_AspireCliSetupArgument Include="DnxSeparator" Condition="'$(_AspireCliSetupExecutable)' != '' and '$(_AspireCliSetupPath)' == ''"> + -- + + <_AspireCliSetupArgument Include="DnxSetup" Condition="'$(_AspireCliSetupExecutable)' != '' and '$(_AspireCliSetupPath)' == ''"> + setup + + <_AspireCliSetupArgument Include="DnxInstallPathOption" Condition="'$(_AspireCliSetupExecutable)' != '' and '$(_AspireCliSetupPath)' == ''"> + --install-path + + <_AspireCliSetupArgument Include="DnxInstallPath" Condition="'$(_AspireCliSetupExecutable)' != '' and '$(_AspireCliSetupPath)' == ''"> + $(_AspireCliSetupHome) + + + + + + + + + + + + + + + + + + + + + + $(_AspireCliBundleDcpDir) + $(_AspireCliBundleDashboardDir) + $(_AspireCliBundleDashboardPath) + $(_AspireCliBundleTerminalHostDir) + $(_AspireCliBundleTerminalHostPath) + $(_AspireCliBundleTerminalHostInvocationArgs) + + + + + <_AspireCliFallbackDnxSetupExecutable Condition="'$(AspireCliBundlePath)' == '' and '$(AspireCliPath)' == '' and '$(_AspireResolvedCliInvocationMode)' == 'Aspire' and Exists('$(_AspireResolvedDnxHostPath)')">$(_AspireResolvedDnxHostPath) + <_AspireCliFallbackDnxSetupDiagnosticCommand Condition="'$(_AspireCliFallbackDnxSetupExecutable)' != ''">"$(_AspireResolvedDnxHostPath)" $(_AspireResolvedDnxHostArgumentsPrefix)--yes $(_AspireCliDnxPackageReference) -- setup --install-path "$(_AspireCliSetupHome)" + + + + <_AspireCliFallbackDnxSetupArgument Remove="@(_AspireCliFallbackDnxSetupArgument)" /> + <_AspireCliFallbackDnxSetupArgument Include="@(_AspireResolvedDnxHostArgument)" Condition="'$(_AspireCliFallbackDnxSetupExecutable)' != ''" /> + <_AspireCliFallbackDnxSetupArgument Include="DnxNonInteractive" Condition="'$(_AspireCliFallbackDnxSetupExecutable)' != ''"> + --yes + + <_AspireCliFallbackDnxSetupArgument Include="DnxPackage" Condition="'$(_AspireCliFallbackDnxSetupExecutable)' != ''"> + $(_AspireCliDnxPackageReference) + + <_AspireCliFallbackDnxSetupArgument Include="DnxSeparator" Condition="'$(_AspireCliFallbackDnxSetupExecutable)' != ''"> + -- + + <_AspireCliFallbackDnxSetupArgument Include="DnxSetup" Condition="'$(_AspireCliFallbackDnxSetupExecutable)' != ''"> + setup + + <_AspireCliFallbackDnxSetupArgument Include="DnxInstallPathOption" Condition="'$(_AspireCliFallbackDnxSetupExecutable)' != ''"> + --install-path + + <_AspireCliFallbackDnxSetupArgument Include="DnxInstallPath" Condition="'$(_AspireCliFallbackDnxSetupExecutable)' != ''"> + $(_AspireCliSetupHome) + + + + + + + + + + + @@ -255,10 +405,30 @@ namespace Projects%3B $(MSBuildProjectName) is configured to use the Aspire CLI bundle, but the bundle could not be resolved. New features require the Aspire CLI to be installed. The Aspire dashboard & DCP packages will be deprecated in a future release. Install the Aspire CLI from https://get.aspire.dev and ensure the aspire command is available on PATH, or set AspireCliBundlePath/AspireCliPath to a valid Aspire CLI installation or bundle layout. + <_AspireCliBundleNotFoundError Condition="'$(_AspireCliSetupExecutable)' != '' or '$(_AspireCliFallbackDnxSetupExecutable)' != ''"> + $(_AspireCliBundleNotFoundError) + Automatic Aspire CLI bundle setup did not produce a usable DCP and dashboard layout. + + <_AspireCliBundleNotFoundError Condition="'$(_AspireCliSetupFailureMessage)' != ''"> + $(_AspireCliBundleNotFoundError) + Automatic Aspire CLI bundle setup failed: $(_AspireCliSetupFailureMessage) Run '$(_AspireCliSetupDiagnosticCommand)' to diagnose the failure. + + <_AspireCliBundleNotFoundError Condition="'$(_AspireCliSetupFailureMessage)' == '' and '$(_AspireCliSetupExitCode)' != '' and '$(_AspireCliSetupExitCode)' != '0'"> + $(_AspireCliBundleNotFoundError) + Automatic Aspire CLI bundle setup failed with exit code $(_AspireCliSetupExitCode). Run '$(_AspireCliSetupDiagnosticCommand)' to diagnose the failure. + + <_AspireCliBundleNotFoundError Condition="'$(_AspireCliFallbackDnxSetupFailureMessage)' != ''"> + $(_AspireCliBundleNotFoundError) + Paired Aspire CLI bundle setup through DNX failed: $(_AspireCliFallbackDnxSetupFailureMessage) Run '$(_AspireCliFallbackDnxSetupDiagnosticCommand)' to diagnose the failure. + + <_AspireCliBundleNotFoundError Condition="'$(_AspireCliFallbackDnxSetupFailureMessage)' == '' and '$(_AspireCliFallbackDnxSetupExitCode)' != '' and '$(_AspireCliFallbackDnxSetupExitCode)' != '0'"> + $(_AspireCliBundleNotFoundError) + Paired Aspire CLI bundle setup through DNX failed with exit code $(_AspireCliFallbackDnxSetupExitCode). Run '$(_AspireCliFallbackDnxSetupDiagnosticCommand)' to diagnose the failure. + diff --git a/src/Aspire.Hosting.Tasks/ResolveAspireCliBundle.cs b/src/Aspire.Hosting.Tasks/ResolveAspireCliBundle.cs index 7c69c59ea0e..b02a44255c7 100644 --- a/src/Aspire.Hosting.Tasks/ResolveAspireCliBundle.cs +++ b/src/Aspire.Hosting.Tasks/ResolveAspireCliBundle.cs @@ -28,6 +28,25 @@ public sealed class ResolveAspireCliBundle : Microsoft.Build.Utilities.Task /// public string? AspireCliPath { get; set; } + /// + /// Gets or sets a value indicating whether to search PATH for Aspire CLI executables. + /// + /// + /// Explicit paths and the Aspire home directory are still evaluated when this value is . + /// + public bool SearchPathForAspireCli { get; set; } = true; + + /// + /// Gets or sets a value indicating whether invalid explicitly configured paths produce warnings. + /// + public bool WarnOnInvalidPaths { get; set; } = true; + + /// + /// The Aspire home directory used to find and prepare extracted bundle layouts. + /// + [Output] + public string? AspireHomeDirectory { get; set; } + /// /// The resolved DCP directory. /// @@ -69,6 +88,9 @@ public sealed class ResolveAspireCliBundle : Microsoft.Build.Utilities.Task public override bool Execute() { + var aspireHomeDirectory = GetDefaultAspireHomeDirectory(); + AspireHomeDirectory = aspireHomeDirectory; + if (!string.IsNullOrWhiteSpace(AspireCliBundlePath)) { if (TryResolveFromLayoutPath(AspireCliBundlePath, out var explicitBundle)) @@ -77,7 +99,11 @@ public override bool Execute() return true; } - Log.LogWarning("The AspireCliBundlePath value '{0}' does not point to a valid Aspire CLI bundle layout.", AspireCliBundlePath); + if (WarnOnInvalidPaths) + { + Log.LogWarning("The AspireCliBundlePath value '{0}' does not point to a valid Aspire CLI bundle layout.", AspireCliBundlePath); + } + return true; } @@ -89,17 +115,21 @@ public override bool Execute() return true; } - Log.LogWarning("The AspireCliPath value '{0}' does not point to an existing Aspire CLI executable with a valid bundle layout.", AspireCliPath); + if (WarnOnInvalidPaths) + { + Log.LogWarning("The AspireCliPath value '{0}' does not point to an existing Aspire CLI executable with a valid bundle layout.", AspireCliPath); + } + return true; } - if (TryResolveFromPath(out var pathBundle)) + if (SearchPathForAspireCli && TryResolveFromPath(out var pathBundle)) { SetOutputs(pathBundle); return true; } - if (TryResolveFromLayoutPath(GetDefaultAspireHomeDirectory(), out var aspireHomeBundle)) + if (TryResolveFromLayoutPath(aspireHomeDirectory, out var aspireHomeBundle)) { SetOutputs(aspireHomeBundle); return true; diff --git a/src/Aspire.Hosting.Tasks/ResolveAspireCliInvocation.cs b/src/Aspire.Hosting.Tasks/ResolveAspireCliInvocation.cs index 642d86979a0..ebe02eb7833 100644 --- a/src/Aspire.Hosting.Tasks/ResolveAspireCliInvocation.cs +++ b/src/Aspire.Hosting.Tasks/ResolveAspireCliInvocation.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; namespace Aspire.Hosting.Tasks; @@ -56,6 +57,12 @@ public sealed class ResolveAspireCliInvocation : Microsoft.Build.Utilities.Task [Output] public string? ResolvedDnxHostArguments { get; set; } + /// + /// Gets the individual arguments that select DNX on . + /// + [Output] + public ITaskItem[] ResolvedDnxHostArgumentItems { get; set; } = []; + public override bool Execute() { if (!string.IsNullOrWhiteSpace(AspireCliPath)) @@ -127,6 +134,12 @@ private bool TryResolveDnxHost(string dnxPath) // behavior while bypassing cmd.exe, which would reinterpret forwarded AppHost arguments. ResolvedDnxHostPath = dotnetPath; ResolvedDnxHostArguments = $"exec \"{sdkPath}\" dnx"; + ResolvedDnxHostArgumentItems = + [ + new TaskItem("exec"), + new TaskItem(sdkPath), + new TaskItem("dnx") + ]; return true; } diff --git a/src/Aspire.Hosting.Tasks/RunAspireCliCommand.cs b/src/Aspire.Hosting.Tasks/RunAspireCliCommand.cs new file mode 100644 index 00000000000..331b43a4bdd --- /dev/null +++ b/src/Aspire.Hosting.Tasks/RunAspireCliCommand.cs @@ -0,0 +1,358 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using System.Text; +using Microsoft.Build.Framework; + +namespace Aspire.Hosting.Tasks; + +/// +/// Runs an Aspire CLI command with structured arguments and bounded execution. +/// +public sealed class RunAspireCliCommand : Microsoft.Build.Utilities.Task +{ + private const string ArgumentValueMetadataName = "Value"; + private const string CommandShimPathEnvironmentVariable = "__ASPIRE_MSBUILD_COMMAND_PATH"; + private const string CommandShimArgumentEnvironmentVariablePrefix = "__ASPIRE_MSBUILD_COMMAND_ARGUMENT_"; +#if NETFRAMEWORK + private const int ProcessTreeTerminationTimeoutMilliseconds = 5_000; +#endif + + /// + /// Gets or sets the executable or command shim to run. + /// + [Required] + public string FileName { get; set; } = null!; + + /// + /// Gets or sets the ordered command arguments. + /// + /// + /// An item can carry its argument in Value metadata when its item identity is used only + /// to preserve ordering or avoid MSBuild item-list splitting. + /// + public ITaskItem[] Arguments { get; set; } = []; + + /// + /// Gets or sets the maximum command duration in milliseconds. + /// + public int TimeoutMilliseconds { get; set; } = 120_000; + + /// + /// Gets the command exit code, or -1 when the command did not exit normally. + /// + [Output] + public int ExitCode { get; set; } = -1; + + /// + /// Gets a value indicating whether the command exceeded . + /// + [Output] + public bool TimedOut { get; set; } + + /// + /// Gets the process launch or timeout failure description. + /// + [Output] + public string? FailureMessage { get; set; } + + public override bool Execute() + { + if (string.IsNullOrWhiteSpace(FileName)) + { + Log.LogError("An executable path is required."); + return false; + } + + if (TimeoutMilliseconds <= 0) + { + Log.LogError("The command timeout must be greater than zero."); + return false; + } + + var arguments = Arguments.Select(GetArgumentValue).ToArray(); + using var process = new Process + { + StartInfo = CreateStartInfo(arguments) + }; + + try + { + if (!process.Start()) + { + FailureMessage = $"The command '{FileName}' could not be started."; + return true; + } + } + catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or NotSupportedException) + { + FailureMessage = $"The command '{FileName}' could not be started: {ex.Message}"; + return true; + } + + // Read both streams concurrently to avoid deadlock when a pipe buffer fills. + var standardOutputTask = process.StandardOutput.ReadToEndAsync(); + var standardErrorTask = process.StandardError.ReadToEndAsync(); + + if (!process.WaitForExit(TimeoutMilliseconds)) + { + TimedOut = true; + if (!TerminateProcess(process)) + { + return true; + } + } + + process.WaitForExit(); + + var standardOutput = standardOutputTask.GetAwaiter().GetResult(); + var standardError = standardErrorTask.GetAwaiter().GetResult(); + LogProcessOutput(standardOutput); + LogProcessOutput(standardError); + + if (TimedOut) + { + FailureMessage ??= $"The command timed out after {TimeoutMilliseconds} milliseconds."; + return true; + } + + ExitCode = process.ExitCode; + return true; + } + + private ProcessStartInfo CreateStartInfo(IReadOnlyList arguments) + { + var startInfo = IsWindowsCommandShim(FileName) + ? new ProcessStartInfo(GetCommandProcessorPath(), BuildCommandProcessorArguments(arguments.Count)) + : new ProcessStartInfo(FileName); + + startInfo.CreateNoWindow = true; + startInfo.RedirectStandardOutput = true; + startInfo.RedirectStandardError = true; + startInfo.UseShellExecute = false; + + if (IsWindowsCommandShim(FileName)) + { + SetEnvironmentVariable(startInfo, CommandShimPathEnvironmentVariable, FileName); + for (var index = 0; index < arguments.Count; index++) + { + SetEnvironmentVariable(startInfo, $"{CommandShimArgumentEnvironmentVariablePrefix}{index}", arguments[index]); + } + } + else + { +#if NETFRAMEWORK + startInfo.Arguments = string.Join(" ", arguments.Select(QuoteWindowsArgument)); +#else + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } +#endif + } + + return startInfo; + } + + private bool TerminateProcess(Process process) + { +#if NETFRAMEWORK + return TerminateProcessTree(process); +#else + try + { + process.Kill(entireProcessTree: true); + return true; + } + catch (InvalidOperationException) + { + // The process exited between the timeout and termination attempt. + return true; + } + catch (Win32Exception ex) + { + FailureMessage = $"The timed-out command '{FileName}' could not be terminated: {ex.Message}"; + return false; + } +#endif + } + +#if NETFRAMEWORK + private bool TerminateProcessTree(Process process) + { + int processId; + try + { + processId = process.Id; + } + catch (InvalidOperationException) + { + // The process exited between the timeout and termination attempt. + return true; + } + + var taskKillPath = Path.Combine(Environment.SystemDirectory, "taskkill.exe"); + using var taskKill = new Process + { + StartInfo = new ProcessStartInfo( + taskKillPath, + $"/PID {processId.ToString(CultureInfo.InvariantCulture)} /T /F") + { + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + } + }; + + try + { + if (!taskKill.Start()) + { + FailureMessage = $"The process-tree terminator '{taskKillPath}' could not be started."; + return false; + } + + // taskkill is part of Windows and /T terminates the specified process and its descendants. + // Resolve it from the system directory so a restricted or test-modified PATH cannot redirect it. + // See https://learn.microsoft.com/windows-server/administration/windows-commands/taskkill. + var standardOutputTask = taskKill.StandardOutput.ReadToEndAsync(); + var standardErrorTask = taskKill.StandardError.ReadToEndAsync(); + + if (!taskKill.WaitForExit(ProcessTreeTerminationTimeoutMilliseconds)) + { + try + { + taskKill.Kill(); + } + catch (InvalidOperationException) + { + // The helper exited between the timeout and termination attempt. + } + + if (taskKill.HasExited || taskKill.WaitForExit(ProcessTreeTerminationTimeoutMilliseconds)) + { + _ = standardOutputTask.GetAwaiter().GetResult(); + _ = standardErrorTask.GetAwaiter().GetResult(); + } + + FailureMessage = $"The process-tree terminator '{taskKillPath}' did not exit within {ProcessTreeTerminationTimeoutMilliseconds} milliseconds."; + return false; + } + + var standardOutput = standardOutputTask.GetAwaiter().GetResult(); + var standardError = standardErrorTask.GetAwaiter().GetResult(); + if (taskKill.ExitCode != 0) + { + var details = string.IsNullOrWhiteSpace(standardError) ? standardOutput : standardError; + FailureMessage = $"The process-tree terminator '{taskKillPath}' exited with code {taskKill.ExitCode}." + + (string.IsNullOrWhiteSpace(details) ? string.Empty : $" {details.Trim()}"); + return false; + } + + return true; + } + catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or NotSupportedException) + { + FailureMessage = $"The process-tree terminator '{taskKillPath}' failed: {ex.Message}"; + return false; + } + } +#endif + + private void LogProcessOutput(string output) + { + if (!string.IsNullOrEmpty(output)) + { + // Setup output is diagnostic only. Logging it as a message prevents a recovered attempt + // from becoming a warning-as-error failure because the tool wrote warning-like text. + Log.LogMessage(MessageImportance.Low, "{0}", output); + } + } + + private static string GetArgumentValue(ITaskItem argument) + { + var value = argument.GetMetadata(ArgumentValueMetadataName); + return string.IsNullOrEmpty(value) ? argument.ItemSpec : value; + } + + private static bool IsWindowsCommandShim(string path) + { + return Path.DirectorySeparatorChar == '\\' + && (path.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".bat", StringComparison.OrdinalIgnoreCase)); + } + + private static string GetCommandProcessorPath() + { + return Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe"; + } + + private static string BuildCommandProcessorArguments(int argumentCount) + { + var command = new StringBuilder($"\"%{CommandShimPathEnvironmentVariable}%\""); + + for (var index = 0; index < argumentCount; index++) + { + command.Append(" \"%"); + command.Append(CommandShimArgumentEnvironmentVariablePrefix); + command.Append(index.ToString(CultureInfo.InvariantCulture)); + command.Append("%\""); + } + + // Expand each child-only variable once. cmd.exe does not recursively expand percent-delimited + // text introduced by an environment variable, so literal %NAME% path segments remain intact. + return $"/D /V:OFF /S /C \"{command}\""; + } + + private static void SetEnvironmentVariable(ProcessStartInfo startInfo, string name, string value) + { +#if NETFRAMEWORK + startInfo.EnvironmentVariables[name] = value; +#else + startInfo.Environment[name] = value; +#endif + } + +#if NETFRAMEWORK + private static string QuoteWindowsArgument(string argument) + { + if (argument.Length > 0 && !argument.Any(static character => char.IsWhiteSpace(character) || character == '"')) + { + return argument; + } + + var result = new StringBuilder(argument.Length + 2); + result.Append('"'); + var backslashCount = 0; + + foreach (var character in argument) + { + if (character == '\\') + { + backslashCount++; + continue; + } + + if (character == '"') + { + result.Append('\\', (backslashCount * 2) + 1); + result.Append('"'); + backslashCount = 0; + continue; + } + + result.Append('\\', backslashCount); + backslashCount = 0; + result.Append(character); + } + + result.Append('\\', backslashCount * 2); + result.Append('"'); + return result.ToString(); + } +#endif +} diff --git a/src/Aspire.Hosting/Dcp/DcpOptions.cs b/src/Aspire.Hosting/Dcp/DcpOptions.cs index 18ce2909754..2595301eeed 100644 --- a/src/Aspire.Hosting/Dcp/DcpOptions.cs +++ b/src/Aspire.Hosting/Dcp/DcpOptions.cs @@ -142,18 +142,18 @@ internal sealed class DcpOptions public bool EnableAspireContainerTunnel { get; set; } = true; } -internal class ValidateDcpOptions : IValidateOptions +internal class ValidateDcpOptions(DistributedApplicationExecutionContext executionContext) : IValidateOptions { public ValidateOptionsResult Validate(string? name, DcpOptions options) { var builder = new ValidateOptionsResultBuilder(); - if (string.IsNullOrWhiteSpace(options.CliPath)) + if (executionContext.IsRunMode && string.IsNullOrWhiteSpace(options.CliPath)) { builder.AddError("The path to the DCP executable used for Aspire orchestration is required.", "CliPath"); } - if (string.IsNullOrWhiteSpace(options.DashboardPath)) + if (executionContext.IsRunMode && string.IsNullOrWhiteSpace(options.DashboardPath)) { builder.AddError("The path to the Aspire Dashboard binaries is missing.", "DashboardPath"); } diff --git a/tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptFunctionTests.cs b/tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptFunctionTests.cs index 09a11c2c2bb..3d54e71ce96 100644 --- a/tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptFunctionTests.cs +++ b/tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptFunctionTests.cs @@ -428,6 +428,36 @@ public async Task GetCliArchitectureFromArchitecture_UnsupportedArch_Fails() #endregion + #region setup_cli_bundle + + [Fact] + public async Task SetupCliBundle_InvokesSetupAndPropagatesFailure() + { + using var env = new TestEnvironment(); + var cliPath = Path.Combine(env.TempDirectory, "aspire"); + var capturePath = Path.Combine(env.TempDirectory, "setup-args.txt"); + await File.WriteAllTextAsync(cliPath, ($$""" + #!/bin/sh + printf '%s\n' "$@" > "{{capturePath}}" + exit 23 + """).ReplaceLineEndings("\n")); + FileHelper.MakeExecutable(cliPath); + + using var cmd = new ScriptFunctionCommand( + s_releaseScript, + $"INSTALLED_CLI_PATH='{cliPath}'; INSTALLED_CLI_OS=\"$(detect_os)\"; INSTALLED_CLI_ARCH=\"$(get_cli_architecture_from_architecture '')\"; DRY_RUN=false; setup_cli_bundle", + env, + _testOutput); + + var result = await cmd.ExecuteAsync(); + + Assert.NotEqual(0, result.ExitCode); + Assert.Equal("setup", (await File.ReadAllTextAsync(capturePath)).Trim()); + Assert.Contains("bundle setup failed", result.Output, StringComparison.OrdinalIgnoreCase); + } + + #endregion + #region construct_aspire_extension_url [Theory] diff --git a/tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptPSFunctionTests.cs b/tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptPSFunctionTests.cs index cbdd5dbd60e..8e13f7ed7a2 100644 --- a/tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptPSFunctionTests.cs +++ b/tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptPSFunctionTests.cs @@ -399,6 +399,72 @@ public async Task GetCLIArchitectureFromArchitecture_UnsupportedArch_Fails() #endregion + #region Invoke-AspireCliBundleSetup + + [Fact] + public async Task InvokeAspireCliBundleSetup_InvokesSetupAndPropagatesFailure() + { + using var env = new TestEnvironment(); + var cliPath = Path.Combine(env.TempDirectory, OperatingSystem.IsWindows() ? "aspire.cmd" : "aspire"); + var capturePath = Path.Combine(env.TempDirectory, "setup-args.txt"); + var contents = OperatingSystem.IsWindows() + ? $""" + @echo off + > "{capturePath}" echo %~1 + exit /b 23 + """ + : $$""" + #!/bin/sh + printf '%s\n' "$@" > "{{capturePath}}" + exit 23 + """; + await File.WriteAllTextAsync(cliPath, contents.ReplaceLineEndings(OperatingSystem.IsWindows() ? "\r\n" : "\n")); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(cliPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + var escapedCliPath = cliPath.Replace("'", "''"); + using var cmd = new ScriptFunctionCommand( + s_releaseScript, + $"Invoke-AspireCliBundleSetup -CliPath '{escapedCliPath}' -TargetOS (Get-OperatingSystem) -TargetArchitecture (Get-CLIArchitectureFromArchitecture '')", + env, + _testOutput); + + var result = await cmd.ExecuteAsync(); + + Assert.NotEqual(0, result.ExitCode); + Assert.Equal("setup", (await File.ReadAllTextAsync(capturePath)).Trim()); + Assert.Contains("bundle setup failed", result.Output, StringComparison.OrdinalIgnoreCase); + Assert.Contains("23", result.Output); + } + + [Fact] + public async Task InvokeAspireCliBundleSetup_UnsupportedHostArchitecture_SkipsSetup() + { + using var env = new TestEnvironment(); + var cliPath = Path.Combine(env.TempDirectory, "setup-invoked.txt"); + var escapedCliPath = cliPath.Replace("'", "''"); + var expression = $$""" + function Get-MachineArchitecture { 'mips' } + Invoke-AspireCliBundleSetup -CliPath '{{escapedCliPath}}' -TargetOS (Get-OperatingSystem) -TargetArchitecture 'x64' + """; + using var cmd = new ScriptFunctionCommand( + s_releaseScript, + expression, + env, + _testOutput); + + var result = await cmd.ExecuteAsync(); + + result.EnsureSuccessful(); + Assert.Contains("Skipping Aspire CLI bundle setup because the current platform could not be detected", result.Output); + Assert.Contains("not supported", result.Output, StringComparison.OrdinalIgnoreCase); + Assert.False(File.Exists(cliPath)); + } + + #endregion + #region Get-AspireExtensionUrl [Theory] diff --git a/tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptPowerShellTests.cs b/tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptPowerShellTests.cs index cc56384473b..a70c68ba0d6 100644 --- a/tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptPowerShellTests.cs +++ b/tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptPowerShellTests.cs @@ -51,6 +51,11 @@ public async Task WhatIf_ShowsActions() result.EnsureSuccessful(); Assert.Contains("What if", result.Output, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Set up Aspire CLI bundle", result.Output); + Assert.True( + result.Output.IndexOf("Write route sidecar", StringComparison.Ordinal) < + result.Output.IndexOf("Set up Aspire CLI bundle", StringComparison.Ordinal), + "Bundle setup should be planned after the install-route sidecar is written."); } [Fact] @@ -127,6 +132,7 @@ public async Task SkipPathFlag_IsRecognized() result.EnsureSuccessful(); Assert.Contains("Skipping PATH", result.Output, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Set up Aspire CLI bundle", result.Output); } [Fact] @@ -218,6 +224,21 @@ public async Task InstallExtensionWithNonDevQuality_ReturnsError(string quality) Assert.Contains("dev", result.Output, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task WhatIfWithExtension_PlansExtensionBeforeBundleSetup() + { + using var env = new TestEnvironment(); + using var cmd = new ScriptToolCommand(s_scriptPath, env, _testOutput); + + var result = await cmd.ExecuteAsync("-Quality", "dev", "-InstallExtension", "-WhatIf"); + + result.EnsureSuccessful(); + var extensionIndex = result.Output.IndexOf("Installing VS Code extension", StringComparison.Ordinal); + var setupIndex = result.Output.IndexOf("Set up Aspire CLI bundle", StringComparison.Ordinal); + Assert.True(extensionIndex >= 0, "VS Code extension installation should be planned."); + Assert.True(setupIndex > extensionIndex, "Bundle setup should be planned after VS Code extension installation."); + } + [Theory] [InlineData("dev")] [InlineData("staging")] diff --git a/tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptShellTests.cs b/tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptShellTests.cs index 60afa22b126..c75dabacd2a 100644 --- a/tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptShellTests.cs +++ b/tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptShellTests.cs @@ -62,6 +62,12 @@ public async Task DryRun_ShowsDownloadAndInstallSteps() Assert.Contains("download", result.Output, StringComparison.OrdinalIgnoreCase); Assert.Contains("install", result.Output, StringComparison.OrdinalIgnoreCase); Assert.Contains("[DRY RUN]", result.Output); + Assert.Contains("[DRY RUN] Would run:", result.Output); + Assert.Contains("aspire setup", result.Output); + Assert.True( + result.Output.IndexOf("route sidecar", StringComparison.OrdinalIgnoreCase) < + result.Output.IndexOf("[DRY RUN] Would run:", StringComparison.Ordinal), + "Bundle setup should be planned after the install-route sidecar is written."); } [Fact] @@ -155,6 +161,7 @@ public async Task SkipPathFlag_IsRecognized() result.EnsureSuccessful(); Assert.Contains("Skipping PATH", result.Output, StringComparison.OrdinalIgnoreCase); + Assert.Contains("aspire setup", result.Output); } [Theory] @@ -170,6 +177,21 @@ public async Task InstallExtensionWithNonDevQuality_ReturnsError(string quality) Assert.Contains("--quality dev", result.Output, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task DryRunWithExtension_PlansExtensionBeforeBundleSetup() + { + using var env = new TestEnvironment(); + using var cmd = new ScriptToolCommand(s_scriptPath, env, _testOutput); + + var result = await cmd.ExecuteAsync("--dry-run", "--quality", "dev", "--install-extension"); + + result.EnsureSuccessful(); + var extensionIndex = result.Output.IndexOf("Installing VS Code extension", StringComparison.Ordinal); + var setupIndex = result.Output.IndexOf("[DRY RUN] Would run:", StringComparison.Ordinal); + Assert.True(extensionIndex >= 0, "VS Code extension installation should be planned."); + Assert.True(setupIndex > extensionIndex, "Bundle setup should be planned after VS Code extension installation."); + } + [Theory] [InlineData("dev")] [InlineData("staging")] diff --git a/tests/Aspire.Cli.Tests/BundleServiceIntegrationTests.cs b/tests/Aspire.Cli.Tests/BundleServiceIntegrationTests.cs index 8439204c8e1..4f2bd82c881 100644 --- a/tests/Aspire.Cli.Tests/BundleServiceIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/BundleServiceIntegrationTests.cs @@ -196,6 +196,22 @@ public async Task ExtractAsync_CleansUpStaleVersionDirectories() } } + [Fact] + public async Task ExtractAsync_PayloadWithoutDcpExecutableFailsVerification() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var layoutRoot = workspace.WorkspaceRoot.FullName; + var payload = CreateFakeBundlePayload(includeDcpExecutable: false); + var service = CreateService( + new TestBundlePayloadProvider(payload), + new TestLayoutDiscovery(layoutRoot)); + + var result = await service.ExtractAsync(layoutRoot, force: true); + + Assert.Equal(BundleExtractResult.ExtractionFailed, result); + Assert.False(Directory.Exists(Path.Combine(layoutRoot, BundleDiscovery.BundleDirectoryName))); + } + [Fact] [SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "Windows file sharing semantics are required.")] public async Task MoveDirectoryWithRetryAsync_FileTemporarilyLocked_RetriesUntilReleased() @@ -528,7 +544,7 @@ public async Task ExtractAsync_ReplacesReparsePointsOnUpgrade() /// Creates a tar.gz byte array containing a fake bundle layout with the /// required wrapper directory for strip-components=1 extraction. /// - internal static byte[] CreateFakeBundlePayload(string contentMarker = "fake-bundle") + internal static byte[] CreateFakeBundlePayload(string contentMarker = "fake-bundle", bool includeDcpExecutable = true) { using var ms = new MemoryStream(); @@ -548,14 +564,19 @@ internal static byte[] CreateFakeBundlePayload(string contentMarker = "fake-bund }; tar.WriteEntry(managedEntry); - // dcp/ directory with a placeholder file. + // dcp/ directory and platform executable. tar.WriteEntry(new PaxTarEntry(TarEntryType.Directory, $"aspire-payload/{BundleDiscovery.DcpDirectoryName}/")); - var dcpEntry = new PaxTarEntry(TarEntryType.RegularFile, $"aspire-payload/{BundleDiscovery.DcpDirectoryName}/dcp-placeholder") + if (includeDcpExecutable) { - DataStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes($"dcp-{contentMarker}\n")) - }; - tar.WriteEntry(dcpEntry); + var dcpEntry = new PaxTarEntry( + TarEntryType.RegularFile, + $"aspire-payload/{BundleDiscovery.DcpDirectoryName}/{BundleDiscovery.GetDcpExecutableName()}") + { + DataStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes($"dcp-{contentMarker}\n")) + }; + tar.WriteEntry(dcpEntry); + } } return ms.ToArray(); @@ -608,8 +629,9 @@ private sealed class TestLayoutDiscovery(string layoutRoot) : ILayoutDiscovery var dcpDir = Path.Combine(bundleDir, BundleDiscovery.DcpDirectoryName); var managedExeName = BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName); var managedExe = Path.Combine(managedDir, managedExeName); + var dcpExe = BundleDiscovery.GetDcpExecutablePath(dcpDir); - if (!Directory.Exists(managedDir) || !File.Exists(managedExe) || !Directory.Exists(dcpDir)) + if (!Directory.Exists(managedDir) || !File.Exists(managedExe) || !Directory.Exists(dcpDir) || !File.Exists(dcpExe)) { return null; } diff --git a/tests/Aspire.Cli.Tests/BundleServiceTests.cs b/tests/Aspire.Cli.Tests/BundleServiceTests.cs index 66501dd3030..29c1f27985b 100644 --- a/tests/Aspire.Cli.Tests/BundleServiceTests.cs +++ b/tests/Aspire.Cli.Tests/BundleServiceTests.cs @@ -117,7 +117,7 @@ public void ComputeVersionId_IsFilesystemSafe() } [Fact] - public void IsVersionedLayoutValid_RequiresManagedExecutableAndDcpDirectory() + public void IsVersionedLayoutValid_RequiresManagedExecutableAndDcpExecutable() { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); var dir = workspace.WorkspaceRoot.FullName; @@ -149,6 +149,18 @@ public void IsVersionedLayoutValid_RequiresDcpDirectory() Assert.False(BundleService.IsVersionedLayoutValid(dir)); } + [Fact] + public void IsVersionedLayoutValid_RequiresDcpExecutable() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var dir = workspace.WorkspaceRoot.FullName; + CreateFakeBundleLayout(dir); + + File.Delete(BundleDiscovery.GetDcpExecutablePath(Path.Combine(dir, BundleDiscovery.DcpDirectoryName))); + + Assert.False(BundleService.IsVersionedLayoutValid(dir)); + } + [Theory] [InlineData(unchecked((int)0x80070005), true)] // ERROR_ACCESS_DENIED [InlineData(unchecked((int)0x80070020), true)] // ERROR_SHARING_VIOLATION @@ -224,6 +236,6 @@ private static void CreateFakeBundleLayout(string root) var dcpDir = Path.Combine(root, BundleDiscovery.DcpDirectoryName); Directory.CreateDirectory(dcpDir); - File.WriteAllText(Path.Combine(dcpDir, "placeholder"), "dcp"); + File.WriteAllText(BundleDiscovery.GetDcpExecutablePath(dcpDir), "dcp"); } } diff --git a/tests/Aspire.Cli.Tests/Caching/AppHostInfoDiskCacheTests.cs b/tests/Aspire.Cli.Tests/Caching/AppHostInfoDiskCacheTests.cs index a013408b202..db5940d3411 100644 --- a/tests/Aspire.Cli.Tests/Caching/AppHostInfoDiskCacheTests.cs +++ b/tests/Aspire.Cli.Tests/Caching/AppHostInfoDiskCacheTests.cs @@ -1,6 +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.Text.Json; using Aspire.Cli.Caching; using Aspire.Cli.Configuration; using Aspire.Cli.Tests.TestServices; @@ -49,6 +50,7 @@ private static FileInfo CreateProjectFile(TemporaryWorkspace workspace, string n RunWorkingDirectory = "/repo/src/AppHost", RunArguments = "--from-msbuild", TargetFramework = "net10.0", + TargetFrameworks = "net10.0;net9.0", }; private static IEnumerable EnumerateCacheEntries(TemporaryWorkspace workspace) @@ -97,6 +99,45 @@ public async Task CacheEntryMissingRunCommandIsTreatedAsMiss() Assert.Null(hit); } + [Fact] + public async Task CachePayloadContainsOnlyProjectInspectionMetadata() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var cache = CreateCache(workspace); + var projectFile = CreateProjectFile(workspace); + + await cache.SetAsync( + projectFile, + cache.GetCacheKey(projectFile), + SampleEntry() with { IsUsingCliBundle = true }, + CancellationToken.None).DefaultTimeout(); + + var cachePath = Assert.Single(EnumerateCacheEntries(workspace)); + using var document = JsonDocument.Parse(await File.ReadAllTextAsync(cachePath)); + var propertyNames = document.RootElement + .EnumerateObject() + .Select(property => property.Name) + .Order(StringComparer.Ordinal) + .ToArray(); + + Assert.Equal( + [ + "aspireHostingVersion", + "exitCode", + "isAspireHost", + "isUsingCliBundle", + "runArguments", + "runCommand", + "runWorkingDirectory", + "schemaVersion", + "targetFramework", + "targetFrameworks", + "targetPath", + "userSecretsId", + ], + propertyNames); + } + [Fact] public async Task TouchingProjectFileInvalidatesCacheEntry() { diff --git a/tests/Aspire.Cli.Tests/Layout/LayoutDiscoveryReparsePointTests.cs b/tests/Aspire.Cli.Tests/Layout/LayoutDiscoveryReparsePointTests.cs index 06ae22e2b34..08a207b2c42 100644 --- a/tests/Aspire.Cli.Tests/Layout/LayoutDiscoveryReparsePointTests.cs +++ b/tests/Aspire.Cli.Tests/Layout/LayoutDiscoveryReparsePointTests.cs @@ -3,6 +3,7 @@ using Aspire.Cli.Layout; using Aspire.Cli.Tests.Acquisition; +using Aspire.Cli.Tests.Utils; using Aspire.Cli.Utils; using Aspire.Shared; using Microsoft.Extensions.Logging.Abstractions; @@ -101,6 +102,7 @@ public void DiscoverLayout_ResolvesThroughReparsePoints() File.WriteAllText( Path.Combine(versionedManaged, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)), "stub"); + File.WriteAllText(BundleDiscovery.GetDcpExecutablePath(versionedDcp), "stub"); // Create a single bundle/ link pointing at the versioned directory. var bundleLink = Path.Combine(layoutRoot, BundleDiscovery.BundleDirectoryName); @@ -210,6 +212,34 @@ public void DiscoverLayout_PrefersRelativeLayoutOverAspireHomeLayout() } } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DiscoverLayout_RejectsRelativeLayoutWithoutDcpExecutable(bool useBundleDirectory) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var layoutRoot = workspace.WorkspaceRoot.FullName; + var componentRoot = useBundleDirectory + ? Path.Combine(layoutRoot, BundleDiscovery.BundleDirectoryName) + : layoutRoot; + var managedDir = Directory.CreateDirectory(Path.Combine(componentRoot, BundleDiscovery.ManagedDirectoryName)); + Directory.CreateDirectory(Path.Combine(componentRoot, BundleDiscovery.DcpDirectoryName)); + File.WriteAllText( + Path.Combine(managedDir.FullName, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)), + "stub"); + var binaryPath = Path.Combine(layoutRoot, OperatingSystem.IsWindows() ? "aspire.exe" : "aspire"); + File.WriteAllText(binaryPath, "stub"); + + var discovery = new LayoutDiscovery(NullLogger.Instance, new TestEnvironment()) + { + ProcessPathOverride = binaryPath + }; + + var layout = discovery.DiscoverLayout(); + + Assert.NotEqual(layoutRoot, layout?.LayoutPath); + } + private static void CreateValidBundleLayout(string layoutRoot) { var bundleDir = Path.Combine(layoutRoot, BundleDiscovery.BundleDirectoryName); @@ -220,5 +250,6 @@ private static void CreateValidBundleLayout(string layoutRoot) File.WriteAllText( Path.Combine(managedDir, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)), "stub"); + File.WriteAllText(BundleDiscovery.GetDcpExecutablePath(dcpDir), "stub"); } } diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostInfoResolverTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostInfoResolverTests.cs index 65b5acc9e1b..2a0a6ee8eda 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostInfoResolverTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostInfoResolverTests.cs @@ -168,15 +168,17 @@ public async Task GetAppHostInfoAsync_CallerCancellationDoesNotCancelSharedEvalu } [Fact] - public async Task GetAppHostInfoAsync_RequestsComputeRunArgumentsTarget() + public async Task GetAppHostInfoAsync_RequestsOnlyEvaluationSafeTarget() { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); var projectFile = CreateProjectFile(workspace); + string[]? capturedProperties = null; string[]? capturedTargets = null; var runner = new TestDotNetCliRunner { - GetProjectItemsAndPropertiesAsyncCallbackWithTargets = (_, _, _, targets, _, _) => + GetProjectItemsAndPropertiesAsyncCallbackWithTargets = (_, _, properties, targets, _, _) => { + capturedProperties = properties; capturedTargets = targets; return (0, CreateAppHostInfoJson()); }, @@ -189,8 +191,12 @@ public async Task GetAppHostInfoAsync_RequestsComputeRunArgumentsTarget() // The direct-launch path reads RunCommand/RunArguments/RunWorkingDirectory, which the // SDK only populates after ComputeRunArguments has run. The resolver must request that // target on its single MSBuild probe so the cached run metadata is correct. + Assert.NotNull(capturedProperties); + Assert.Equal( + ["IsAspireHost", "AspireHostingSDKVersion", "AspireUseCliBundle", "UserSecretsId", "RunCommand", "TargetPath", "RunWorkingDirectory", "RunArguments", "TargetFramework", "TargetFrameworks"], + capturedProperties); Assert.NotNull(capturedTargets); - Assert.Contains("ComputeRunArguments", capturedTargets); + Assert.Equal(["ComputeRunArguments"], capturedTargets); } private static FileInfo CreateProjectFile(TemporaryWorkspace workspace) diff --git a/tests/Aspire.Cli.Tests/Projects/DotNetAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/DotNetAppHostProjectTests.cs index b4aa699b5bf..80ec87a8bbb 100644 --- a/tests/Aspire.Cli.Tests/Projects/DotNetAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/DotNetAppHostProjectTests.cs @@ -100,6 +100,50 @@ public async Task ValidateAppHostAsync_AppHostConventionalProject_EvaluatesViaRe Assert.Equal(1, resolver.CallCount); } + [Fact] + public async Task ValidationAndVersionInspectionDoNotAcquireCliBundle() + { + var appHostFile = CreateProjectAppHost(); + _ = CreateCliBundle(out var layout); + var bundleAcquisitionRequested = false; + var bundleService = new TestBundleService(isBundle: true) + { + Layout = layout, + EnsureExtractedAndAcquireLayoutAsyncCallback = _ => + { + bundleAcquisitionRequested = true; + return Task.CompletedTask; + } + }; + var resolver = new TestAppHostInfoResolver + { + GetAppHostInfoAsyncCallback = (_, _) => Task.FromResult(new AppHostProjectInfo( + ExitCode: 0, + IsAspireHost: true, + AspireHostingVersion: "13.0.0", + IsUsingCliBundle: true, + UserSecretsId: null, + RunCommand: null, + TargetPath: null, + RunWorkingDirectory: null, + RunArguments: null, + TargetFramework: "net10.0", + TargetFrameworks: null)) + }; + var project = CreateDotNetAppHostProject( + new TestDotNetCliRunner(), + layout, + resolver, + options => options.BundleServiceFactory = _ => bundleService); + + var validation = await project.ValidateAppHostAsync(appHostFile, CancellationToken.None); + var version = await project.GetAspireHostingVersionAsync(appHostFile, CancellationToken.None); + + Assert.True(validation.IsValid); + Assert.Equal("13.0.0", version); + Assert.False(bundleAcquisitionRequested); + } + [Fact] public async Task ValidateAppHostAsync_EvaluatesCleanlyButNotAspireHost_RejectsWithoutPossiblyUnbuildable() { @@ -428,6 +472,328 @@ public async Task RunAsync_ProjectAppHostUsingCliBundlePassesBundleEnvironmentTo Assert.Equal(0, exitCode); } + [Fact] + public async Task RunAsync_ProjectAppHostUsingCliBundlePreservesCallerProvidedRuntimePaths() + { + UseFakeRepoRoot(); + var appHostFile = CreateProjectAppHost(); + _ = CreateCliBundle(out var layout); + var customDcpDirectory = Directory.CreateDirectory(Path.Combine(_workspace.WorkspaceRoot.FullName, "custom-dcp")); + File.WriteAllText(BundleDiscovery.GetDcpExecutablePath(customDcpDirectory.FullName), ""); + var customDashboardPath = Path.Combine( + _workspace.WorkspaceRoot.FullName, + BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)); + File.WriteAllText(customDashboardPath, ""); + + var runner = new TestDotNetCliRunner + { + BuildAsyncCallback = (_, _, _, _) => 0, + GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => + (0, JsonSerializer.SerializeToDocument(new + { + Properties = new + { + IsAspireHost = "true", + AspireHostingSDKVersion = VersionHelper.GetDefaultTemplateVersion(), + AspireUseCliBundle = "true", + }, + Items = new { } + })) + }; + var project = CreateDotNetAppHostProject(runner, layout); + + runner.RunAsyncCallback = (_, _, _, _, _, env, _, _, _) => + { + Assert.Equal(customDcpDirectory.FullName, env![BundleDiscovery.DcpPathEnvVar]); + Assert.Equal(customDashboardPath, env[BundleDiscovery.DashboardPathEnvVar]); + return Task.FromResult(0); + }; + + var exitCode = await project.RunAsync(new AppHostProjectContext + { + AppHostFile = appHostFile, + NoBuild = false, + NoRestore = false, + WorkingDirectory = _workspace.WorkspaceRoot, + EnvironmentVariables = new Dictionary + { + [BundleDiscovery.DcpPathEnvVar] = customDcpDirectory.FullName, + [BundleDiscovery.DashboardPathEnvVar] = customDashboardPath, + } + }, CancellationToken.None); + + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task RunAsync_ProjectAppHostUsingCliBundlePreservesInheritedRuntimePaths() + { + UseFakeRepoRoot(); + var appHostFile = CreateProjectAppHost(); + var bundleRoot = CreateCliBundle(out var layout); + var inheritedDcpDirectory = Directory.CreateDirectory(Path.Combine(_workspace.WorkspaceRoot.FullName, "inherited-dcp")).FullName; + File.WriteAllText(BundleDiscovery.GetDcpExecutablePath(inheritedDcpDirectory), ""); + var inheritedDashboardPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "inherited-dashboard"); + File.WriteAllText(inheritedDashboardPath, ""); + var environment = new TestEnvironment(new Dictionary + { + [BundleDiscovery.DcpPathEnvVar] = inheritedDcpDirectory, + [BundleDiscovery.DashboardPathEnvVar] = inheritedDashboardPath, + }); + + var runner = new TestDotNetCliRunner + { + BuildAsyncCallback = (_, _, _, _) => 0, + GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => + (0, JsonSerializer.SerializeToDocument(new + { + Properties = new + { + IsAspireHost = "true", + AspireHostingSDKVersion = VersionHelper.GetDefaultTemplateVersion(), + AspireUseCliBundle = "true", + }, + Items = new { } + })) + }; + var project = CreateDotNetAppHostProject(runner, layout, environment: environment); + + runner.RunAsyncCallback = (_, _, _, _, _, env, _, _, _) => + { + // Inherited values remain in the process environment. They must not be shadowed + // by values in the child-process overlay built from the current CLI layout. + Assert.False(env!.ContainsKey(BundleDiscovery.DcpPathEnvVar)); + Assert.False(env.ContainsKey(BundleDiscovery.DashboardPathEnvVar)); + Assert.Equal(bundleRoot.FullName, env["AspireCliBundlePath"]); + return Task.FromResult(0); + }; + + var exitCode = await project.RunAsync(new AppHostProjectContext + { + AppHostFile = appHostFile, + NoBuild = false, + NoRestore = false, + WorkingDirectory = _workspace.WorkspaceRoot, + EnvironmentVariables = new Dictionary() + }, CancellationToken.None); + + Assert.Equal(0, exitCode); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task RunAsync_ProjectAppHostUsingCliBundleReplacesUnusableRuntimePaths( + bool useExplicitOverlay, + bool useEmptyValues) + { + UseFakeRepoRoot(); + var appHostFile = CreateProjectAppHost(); + _ = CreateCliBundle(out var layout); + var unusableDcpDirectory = useEmptyValues + ? "" + : Path.Combine(_workspace.WorkspaceRoot.FullName, "missing-dcp"); + var unusableDashboardPath = useEmptyValues + ? "" + : Path.Combine(_workspace.WorkspaceRoot.FullName, "missing-dashboard"); + var environmentVariables = new Dictionary + { + [BundleDiscovery.DcpPathEnvVar] = unusableDcpDirectory, + [BundleDiscovery.DashboardPathEnvVar] = unusableDashboardPath, + }; + var inheritedEnvironment = useExplicitOverlay + ? new TestEnvironment() + : new TestEnvironment(environmentVariables.ToDictionary(pair => pair.Key, pair => (string?)pair.Value)); + + var runner = new TestDotNetCliRunner + { + BuildAsyncCallback = (_, _, _, _) => 0, + GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => + (0, JsonSerializer.SerializeToDocument(new + { + Properties = new + { + IsAspireHost = "true", + AspireHostingSDKVersion = VersionHelper.GetDefaultTemplateVersion(), + AspireUseCliBundle = "true", + }, + Items = new { } + })) + }; + var project = CreateDotNetAppHostProject(runner, layout, environment: inheritedEnvironment); + + runner.RunAsyncCallback = (_, _, _, _, _, env, _, _, _) => + { + Assert.Equal(layout.GetDcpPath(), env![BundleDiscovery.DcpPathEnvVar]); + Assert.Equal(layout.GetManagedPath(), env[BundleDiscovery.DashboardPathEnvVar]); + return Task.FromResult(0); + }; + + var exitCode = await project.RunAsync(new AppHostProjectContext + { + AppHostFile = appHostFile, + NoBuild = false, + NoRestore = false, + WorkingDirectory = _workspace.WorkspaceRoot, + EnvironmentVariables = useExplicitOverlay ? environmentVariables : new Dictionary() + }, CancellationToken.None); + + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task RunAsync_ProjectAppHostUsingCliBundleDoesNotInjectInspectionRuntimePaths() + { + UseFakeRepoRoot(); + var appHostFile = CreateProjectAppHost(); + var runnerCalled = false; + var runner = new TestDotNetCliRunner + { + BuildAsyncCallback = (_, _, _, _) => 0, + GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => + (0, JsonSerializer.SerializeToDocument(new + { + Properties = new + { + IsAspireHost = "true", + AspireHostingSDKVersion = VersionHelper.GetDefaultTemplateVersion(), + AspireUseCliBundle = "true", + DcpDir = "inspection-dcp", + AspireDashboardPath = "inspection-dashboard", + }, + Items = new { } + })), + RunAsyncCallback = (_, _, _, _, _, env, _, _, _) => + { + runnerCalled = true; + Assert.False(env!.ContainsKey(BundleDiscovery.DcpPathEnvVar)); + Assert.False(env.ContainsKey(BundleDiscovery.DashboardPathEnvVar)); + return Task.FromResult(42); + } + }; + var project = CreateDotNetAppHostProject(runner); + + var exitCode = await project.RunAsync(new AppHostProjectContext + { + AppHostFile = appHostFile, + NoBuild = false, + NoRestore = false, + WorkingDirectory = _workspace.WorkspaceRoot, + EnvironmentVariables = new Dictionary() + }, CancellationToken.None); + + Assert.Equal(42, exitCode); + Assert.True(runnerCalled); + } + + [Fact] + public async Task RunAsync_ProjectAppHostUsingCliBundleDoesNotInjectMissingLayoutComponents() + { + UseFakeRepoRoot(); + var appHostFile = CreateProjectAppHost(); + _ = CreateCliBundle(out var layout); + File.Delete(BundleDiscovery.GetDcpExecutablePath(layout.GetDcpPath()!)); + File.Delete(layout.GetManagedPath()!); + + var runner = new TestDotNetCliRunner + { + BuildAsyncCallback = (_, _, _, _) => 0, + GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => + (0, JsonSerializer.SerializeToDocument(new + { + Properties = new + { + IsAspireHost = "true", + AspireHostingSDKVersion = VersionHelper.GetDefaultTemplateVersion(), + AspireUseCliBundle = "true", + }, + Items = new { } + })), + RunAsyncCallback = (_, _, _, _, _, env, _, _, _) => + { + Assert.False(env!.ContainsKey(BundleDiscovery.DcpPathEnvVar)); + Assert.False(env.ContainsKey(BundleDiscovery.DashboardPathEnvVar)); + Assert.False(env.ContainsKey(BundleDiscovery.TerminalHostPathEnvVar)); + return Task.FromResult(0); + } + }; + var project = CreateDotNetAppHostProject(runner, layout); + + var exitCode = await project.RunAsync(new AppHostProjectContext + { + AppHostFile = appHostFile, + NoBuild = false, + NoRestore = false, + WorkingDirectory = _workspace.WorkspaceRoot, + EnvironmentVariables = new Dictionary() + }, CancellationToken.None); + + Assert.Equal(0, exitCode); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task PublishAsync_ProjectAppHostUsingCliBundleDoesNotAcquireOrInjectBundle(bool noBuild) + { + UseFakeRepoRoot(); + var appHostFile = CreateProjectAppHost(); + _ = CreateCliBundle(out var layout); + var bundleAcquisitionRequested = false; + var bundleService = new TestBundleService(isBundle: true) + { + Layout = layout, + EnsureExtractedAndAcquireLayoutAsyncCallback = _ => + { + bundleAcquisitionRequested = true; + return Task.CompletedTask; + } + }; + var runner = new TestDotNetCliRunner + { + BuildAsyncCallback = (_, _, _, _) => 0, + GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => + (0, JsonSerializer.SerializeToDocument(new + { + Properties = new + { + IsAspireHost = "true", + AspireHostingSDKVersion = VersionHelper.GetDefaultTemplateVersion(), + AspireUseCliBundle = "true", + }, + Items = new { } + })), + RunAsyncCallback = (_, _, runnerNoBuild, _, _, env, _, options, _) => + { + Assert.True(runnerNoBuild); + Assert.True(options.NoLaunchProfile); + Assert.False(env!.ContainsKey("AspireCliBundlePath")); + Assert.False(env.ContainsKey(BundleDiscovery.DcpPathEnvVar)); + Assert.False(env.ContainsKey(BundleDiscovery.DashboardPathEnvVar)); + Assert.False(env.ContainsKey(BundleDiscovery.TerminalHostPathEnvVar)); + return Task.FromResult(0); + } + }; + var project = CreateDotNetAppHostProject( + runner, + layout, + configureServices: options => options.BundleServiceFactory = _ => bundleService); + + var exitCode = await project.PublishAsync(new PublishContext + { + AppHostFile = appHostFile, + NoBuild = noBuild, + WorkingDirectory = _workspace.WorkspaceRoot, + Arguments = ["--operation", "publish"], + EnvironmentVariables = new Dictionary() + }, CancellationToken.None); + + Assert.Equal(0, exitCode); + Assert.False(bundleAcquisitionRequested); + } + [Fact] public async Task RunAsync_ProjectAppHostNotUsingCliBundleStillReceivesTerminalHostEnvironment() { @@ -1530,29 +1896,25 @@ public async Task PublishAsync_SingleFileAppHostUsesEnvironmentArgumentWhenProvi } [Fact] - public async Task PublishAsync_SingleFileAppHostUsingCliBundlePassesBundleEnvironmentToRunner() + public async Task PublishAsync_SingleFileAppHostUsingCliBundleDoesNotAcquireOrInjectBundle() { var appHostFile = CreateSingleFileAppHost(useCliBundle: true); - var bundleRoot = CreateCliBundle(out var layout); - - var runner = new TestDotNetCliRunner + _ = CreateCliBundle(out var layout); + var bundleAcquisitionRequested = false; + var bundleService = new TestBundleService(isBundle: true) { - GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, properties, _, _) => + Layout = layout, + EnsureExtractedAndAcquireLayoutAsyncCallback = _ => { - Assert.Equal(appHostFile.FullName, projectFile.FullName); - Assert.Contains("AspireUseCliBundle", properties); - return (0, JsonDocument.Parse(""" - { - "Properties": { - "MSBuildVersion": "17.0.0", - "AspireUseCliBundle": "true" - }, - "Items": {} - } - """)); + bundleAcquisitionRequested = true; + return Task.CompletedTask; } }; - var project = CreateDotNetAppHostProject(runner, layout); + var runner = new TestDotNetCliRunner(); + var project = CreateDotNetAppHostProject( + runner, + layout, + configureServices: options => options.BundleServiceFactory = _ => bundleService); runner.RunAsyncCallback = (projectFile, watch, noBuild, noRestore, args, env, _, options, _) => { @@ -1563,10 +1925,10 @@ public async Task PublishAsync_SingleFileAppHostUsingCliBundlePassesBundleEnviro Assert.True(options.NoLaunchProfile); Assert.Equal(["--operation", "publish"], args); Assert.Equal("Production", env![KnownAspNetCoreConfigNames.DotNetEnvironment]); - Assert.Equal(Path.Combine(bundleRoot.FullName, BundleDiscovery.DcpDirectoryName), env[BundleDiscovery.DcpPathEnvVar]); - Assert.Equal( - Path.Combine(bundleRoot.FullName, BundleDiscovery.ManagedDirectoryName, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)), - env[BundleDiscovery.DashboardPathEnvVar]); + Assert.False(env.ContainsKey("AspireCliBundlePath")); + Assert.False(env.ContainsKey(BundleDiscovery.DcpPathEnvVar)); + Assert.False(env.ContainsKey(BundleDiscovery.DashboardPathEnvVar)); + Assert.False(env.ContainsKey(BundleDiscovery.TerminalHostPathEnvVar)); return Task.FromResult(0); }; @@ -1579,6 +1941,7 @@ public async Task PublishAsync_SingleFileAppHostUsingCliBundlePassesBundleEnviro }, CancellationToken.None); Assert.Equal(0, exitCode); + Assert.False(bundleAcquisitionRequested); } [Fact] @@ -3517,8 +3880,12 @@ private FileInfo CreateOrdinaryProject(string fileName = "Library.csproj") private DirectoryInfo CreateCliBundle(out LayoutConfiguration layout) { var bundleRoot = Directory.CreateDirectory(Path.Combine(_workspace.WorkspaceRoot.FullName, Guid.NewGuid().ToString())); - Directory.CreateDirectory(Path.Combine(bundleRoot.FullName, BundleDiscovery.DcpDirectoryName)); - Directory.CreateDirectory(Path.Combine(bundleRoot.FullName, BundleDiscovery.ManagedDirectoryName)); + var dcpDirectory = Directory.CreateDirectory(Path.Combine(bundleRoot.FullName, BundleDiscovery.DcpDirectoryName)); + var managedDirectory = Directory.CreateDirectory(Path.Combine(bundleRoot.FullName, BundleDiscovery.ManagedDirectoryName)); + File.WriteAllText(BundleDiscovery.GetDcpExecutablePath(dcpDirectory.FullName), ""); + File.WriteAllText( + Path.Combine(managedDirectory.FullName, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)), + ""); layout = new LayoutConfiguration { @@ -3537,7 +3904,8 @@ private DotNetAppHostProject CreateDotNetAppHostProject( TestDotNetCliRunner runner, LayoutConfiguration? layout = null, IAppHostInfoResolver? appHostInfoResolver = null, - Action? configureServices = null) + Action? configureServices = null, + IEnvironment? environment = null) { var services = CliTestHelper.CreateServiceCollection(_workspace, outputHelper, options => { @@ -3553,6 +3921,12 @@ private DotNetAppHostProject CreateDotNetAppHostProject( configureServices?.Invoke(options); }); + if (environment is not null) + { + services.RemoveAll(); + services.AddSingleton(environment); + } + if (appHostInfoResolver is not null) { services.RemoveAll(); diff --git a/tests/Aspire.Hosting.Sdk.Tests.FakeCommand/Program.cs b/tests/Aspire.Hosting.Sdk.Tests.FakeCommand/Program.cs index ffd98ae176c..525747e083b 100644 --- a/tests/Aspire.Hosting.Sdk.Tests.FakeCommand/Program.cs +++ b/tests/Aspire.Hosting.Sdk.Tests.FakeCommand/Program.cs @@ -1,15 +1,48 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Globalization; + if (args is ["--list-sdks"]) { Console.WriteLine($"13.5.0 [{Path.Combine(AppContext.BaseDirectory, "sdk")}]"); return; } +if (args is ["--hang"]) +{ + var pidPath = Environment.GetEnvironmentVariable("ASPIRE_TEST_HANG_PID_PATH") + ?? throw new InvalidOperationException("ASPIRE_TEST_HANG_PID_PATH is not set."); + File.WriteAllText(pidPath, Environment.ProcessId.ToString(CultureInfo.InvariantCulture)); + await Task.Delay(Timeout.InfiniteTimeSpan); + return; +} + var dnxArgumentIndex = Array.IndexOf(args, "dnx"); var forwardedArgs = dnxArgumentIndex >= 0 ? args[(dnxArgumentIndex + 1)..] : args; +var setupArgumentIndex = Array.IndexOf(forwardedArgs, "setup"); +var installPathArgumentIndex = Array.IndexOf(forwardedArgs, "--install-path"); +// The targets exercise setup as either: +// aspire.exe setup +// dotnet ... dnx --yes aspire.cli[@] -- setup --install-path +var setupInstallPath = forwardedArgs switch +{ + ["setup"] => AppContext.BaseDirectory, + _ when setupArgumentIndex >= 0 && + installPathArgumentIndex >= 0 && + installPathArgumentIndex + 1 < forwardedArgs.Length => forwardedArgs[installPathArgumentIndex + 1], + _ => null +}; +if (setupInstallPath is not null) +{ + var dcpDirectory = Directory.CreateDirectory(Path.Combine(setupInstallPath, "bundle", "dcp")); + var managedDirectory = Directory.CreateDirectory(Path.Combine(setupInstallPath, "bundle", "managed")); + File.WriteAllText(Path.Combine(dcpDirectory.FullName, OperatingSystem.IsWindows() ? "dcp.exe" : "dcp"), ""); + File.WriteAllText(Path.Combine(managedDirectory.FullName, OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed"), ""); + return; +} + if (forwardedArgs.Length > 0 && forwardedArgs[^1] == "--version") { if (File.Exists(Path.Combine(AppContext.BaseDirectory, "fail-version"))) diff --git a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs index 9d5389daa98..3af16446f29 100644 --- a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs +++ b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using System.Globalization; using System.Reflection; using System.Security; using System.Text.Json; @@ -13,6 +14,7 @@ namespace Aspire.Hosting.Sdk.Tests; public class AppHostSdkTargetsTests(ITestOutputHelper outputHelper) { private const string AspireCliVersion = "13.5.0"; + private const string HangingCommandPidEnvironmentVariable = "ASPIRE_TEST_HANG_PID_PATH"; private const string SuppressCliRunHookEnvironmentVariable = "ASPIRE_SUPPRESS_CLI_RUN_HOOK"; private static readonly string[] s_supportedRids = @@ -559,7 +561,12 @@ public async Task ComputeRunArgumentsFallsBackToDnxWhenPathAspireCliVersionIsBel var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "fake-cli")); await CreateFakeAspireCliWithVersionAsync(fakeCliDirectory.FullName, "13.4.5"); var dnxPath = await CreateFakeDnxAsync(fakeCliDirectory.FullName); - var environment = CreatePathEnvironment(fakeCliDirectory.FullName); + var environment = new Dictionary + { + [GetPathEnvironmentVariableName()] = CreatePathWithoutAspire(fakeCliDirectory.FullName) + }; + var aspireHome = Path.Combine(workspace.Path, "aspire-home"); + environment["ASPIRE_HOME"] = aspireHome; var buildResult = await RunDotNetWithArgumentsAsync( project.ProjectDirectory, @@ -577,6 +584,255 @@ public async Task ComputeRunArgumentsFallsBackToDnxWhenPathAspireCliVersionIsBel Assert.Equal("true", properties["_AspireCliVersionSupportsRunHook"]); Assert.Equal(GetExpectedDnxRunCommand(dnxPath), properties["RunCommand"]); Assert.Equal(GetExpectedDnxRunArguments(dnxPath, project, "--custom foo"), properties["RunArguments"]); + AssertCliBundleExists(aspireHome, buildResult.Output); + } + + [Fact] + public async Task ResolveAspireCliBundlePathsUsesDnxBundleWhenDnxModeIsSelected() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var staleCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "stale-cli")); + await CreateFakeAspireCliWithVersionAsync(staleCliDirectory.FullName, AspireCliVersion); + var staleBundle = CreateFakeCliBundle(staleCliDirectory.FullName); + var dnxDirectoryName = OperatingSystem.IsWindows() + ? "dnx" + : "dnx $HOME `aspire-test-command`"; + var dnxDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, dnxDirectoryName)); + await CreateFakeDnxAsync(dnxDirectory.FullName); + var aspireHomeDirectoryName = OperatingSystem.IsWindows() + ? "aspire-home" + : "aspire-home $HOME `aspire-test-command`"; + var aspireHome = Path.Combine(workspace.Path, aspireHomeDirectoryName); + var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true, + """ + + Dnx + + """, + includeBundlePaths: false); + var environment = new Dictionary + { + ["ASPIRE_HOME"] = aspireHome, + [GetPathEnvironmentVariableName()] = CreatePathWithoutAspire(staleCliDirectory.FullName, dnxDirectory.FullName) + }; + + // Explicit Dnx mode can use an unversioned package reference so a tool manifest can select + // the CLI version. Force that shape here because the fake setup must support both forms. + var properties = await GetResolveAspireCliBundlePathPropertiesAsync( + project, + environment, + ["-p:_AspireCliDnxPackageReference=aspire.cli"]); + var dnxBundle = GetFakeCliBundlePaths(aspireHome); + + Assert.Equal("Dnx", properties["_AspireResolvedCliInvocationMode"]); + Assert.Equal(dnxBundle.DcpDirectory, Path.TrimEndingDirectorySeparator(properties["DcpDir"])); + Assert.Equal(dnxBundle.ManagedDirectory, Path.TrimEndingDirectorySeparator(properties["AspireDashboardDir"])); + Assert.Equal(dnxBundle.ManagedPath, properties["AspireDashboardPath"]); + Assert.NotEqual(staleBundle.DcpDirectory, Path.TrimEndingDirectorySeparator(properties["DcpDir"])); + AssertCliBundleExists(aspireHome, JsonSerializer.Serialize(properties)); + } + + [Fact] + public async Task ResolveAspireCliBundlePathsPreservesUnixShellMetacharactersInAspireCliPath() + { + Assert.SkipWhen(OperatingSystem.IsWindows(), "This test validates Unix process argument handling."); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var fakeCliDirectory = Directory.CreateDirectory( + Path.Combine(workspace.Path, "cli $HOME `aspire-test-command`")); + var aspireCliPath = await CreateFakeAspireCliThatSetsUpBundleAsync(fakeCliDirectory.FullName); + var aspireHome = Path.Combine(workspace.Path, "empty-aspire-home"); + var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true, includeBundlePaths: false); + var environment = new Dictionary + { + ["ASPIRE_HOME"] = aspireHome, + ["AspireCliPath"] = aspireCliPath + }; + + var properties = await GetResolveAspireCliBundlePathPropertiesAsync(project, environment, ["-warnaserror"]); + var selectedBundle = GetFakeCliBundlePaths(fakeCliDirectory.FullName); + + Assert.Equal(aspireCliPath, properties["_AspireResolvedCliPath"]); + Assert.Equal(selectedBundle.DcpDirectory, Path.TrimEndingDirectorySeparator(properties["DcpDir"])); + Assert.Equal(selectedBundle.ManagedDirectory, Path.TrimEndingDirectorySeparator(properties["AspireDashboardDir"])); + Assert.Equal(selectedBundle.ManagedPath, properties["AspireDashboardPath"]); + AssertCliBundleExists(fakeCliDirectory.FullName, JsonSerializer.Serialize(properties)); + } + + [Fact] + public async Task ResolveAspireCliBundlePathsUsesSelectedAspireCliWhenLaterPathCandidateHasBundleWithWarningsAsErrors() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var selectedCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "selected-cli")); + var selectedCliPath = await CreateFakeAspireCliThatSetsUpBundleAsync(selectedCliDirectory.FullName); + var staleCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "stale-cli")); + await CreateFakeAspireCliWithVersionAsync(staleCliDirectory.FullName, AspireCliVersion); + var staleBundle = CreateFakeCliBundle(staleCliDirectory.FullName); + var aspireHome = Path.Combine(workspace.Path, "aspire-home"); + var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true, includeBundlePaths: false); + var environment = new Dictionary + { + ["ASPIRE_HOME"] = aspireHome, + [GetPathEnvironmentVariableName()] = CreatePathWithoutAspire(selectedCliDirectory.FullName, staleCliDirectory.FullName) + }; + + var properties = await GetResolveAspireCliBundlePathPropertiesAsync(project, environment, ["-warnaserror"]); + var selectedBundle = GetFakeCliBundlePaths(selectedCliDirectory.FullName); + + Assert.Equal("Aspire", properties["_AspireResolvedCliInvocationMode"]); + Assert.Equal(selectedCliPath, properties["_AspireResolvedCliPath"]); + Assert.Equal(selectedBundle.DcpDirectory, Path.TrimEndingDirectorySeparator(properties["DcpDir"])); + Assert.Equal(selectedBundle.ManagedDirectory, Path.TrimEndingDirectorySeparator(properties["AspireDashboardDir"])); + Assert.Equal(selectedBundle.ManagedPath, properties["AspireDashboardPath"]); + Assert.NotEqual(staleBundle.DcpDirectory, Path.TrimEndingDirectorySeparator(properties["DcpDir"])); + AssertCliBundleExists(selectedCliDirectory.FullName, JsonSerializer.Serialize(properties)); + } + + [Fact] + public async Task ResolveAspireCliBundlePathsEscapesPercentSignsInNativeAspireCliPathOnWindows() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "This test validates native Windows executable setup."); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "native%ASPIRE_TEST_LITERAL%")); + var aspireCliPath = CreateFakeNativeAspireCli(fakeCliDirectory.FullName); + var aspireHome = Path.Combine(workspace.Path, "aspire-home"); + var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true, includeBundlePaths: false); + var environment = new Dictionary + { + ["ASPIRE_HOME"] = aspireHome, + ["ASPIRE_TEST_LITERAL"] = "expanded", + ["AspireCliPath"] = aspireCliPath + }; + + var properties = await GetResolveAspireCliBundlePathPropertiesAsync(project, environment, ["-warnaserror"]); + var selectedBundle = GetFakeCliBundlePaths(fakeCliDirectory.FullName); + + Assert.Equal(selectedBundle.DcpDirectory, Path.TrimEndingDirectorySeparator(properties["DcpDir"])); + Assert.Equal(selectedBundle.ManagedDirectory, Path.TrimEndingDirectorySeparator(properties["AspireDashboardDir"])); + Assert.Equal(selectedBundle.ManagedPath, properties["AspireDashboardPath"]); + AssertCliBundleExists(fakeCliDirectory.FullName, JsonSerializer.Serialize(properties)); + Assert.False(Directory.Exists(Path.Combine(workspace.Path, "nativeexpanded"))); + } + + [Fact] + public async Task BuildFallsBackToDnxWhenPathAspireCliSetupTimesOut() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true, includeBundlePaths: false); + var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "fake-cli")); + await CreateFakeAspireCliThatHangsOnSetupAsync(fakeCliDirectory.FullName); + await CreateFakeDnxAsync(fakeCliDirectory.FullName); + var aspireHome = Path.Combine(workspace.Path, "aspire-home"); + var environment = new Dictionary + { + ["ASPIRE_HOME"] = aspireHome, + [GetPathEnvironmentVariableName()] = CreatePathWithoutAspire(fakeCliDirectory.FullName) + }; + + var buildResult = await RunDotNetWithArgumentsAsync( + project.ProjectDirectory, + ["build", "-nologo", project.ProjectFile, "-p:_AspireCliBundleSetupTimeout=100", "-warnaserror"], + environment); + + Assert.True(buildResult.ExitCode == 0, buildResult.Output); + AssertCliBundleExists(aspireHome, buildResult.Output); + } + + [Fact] + public async Task RunAspireCliCommandKillsCommandShimProcessTreeOnTimeoutInFullFrameworkMsBuild() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "This test validates the net472 task under full-framework MSBuild."); + + var msbuildPath = await FindFullFrameworkMSBuildAsync(); + Assert.SkipUnless(msbuildPath is not null, "Full-framework MSBuild could not be found with vswhere."); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var fakeCommandDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "fake-command")); + CopyFakeCommandHost(fakeCommandDirectory.FullName); + var commandShimPath = Path.Combine(fakeCommandDirectory.FullName, "aspire.cmd"); + await File.WriteAllTextAsync(commandShimPath, """ + @echo off + "%~dp0dotnet.exe" --hang + """); + + var childPidPath = Path.Combine(workspace.Path, "child.pid"); + var taskAssemblyPath = GetAssemblyMetadataPath("AspireHostingTasksNetFrameworkAssemblyPath"); + var projectPath = Path.Combine(workspace.Path, "RunAspireCliCommand.proj"); + await File.WriteAllTextAsync(projectPath, $$""" + + + + + + + + + + + + + + """); + + int? childProcessId = null; + try + { + var result = await RunProcessWithArgumentsAsync( + msbuildPath!, + workspace.Path, + [projectPath, "-nologo", "-t:Test"], + new Dictionary + { + [HangingCommandPidEnvironmentVariable] = childPidPath + }, + TimeSpan.FromSeconds(30)); + + Assert.True(result.ExitCode == 0, result.Output); + Assert.Contains("CommandTimedOut=true", result.Output, StringComparison.OrdinalIgnoreCase); + Assert.True(File.Exists(childPidPath), $"The child process did not record its PID. MSBuild output:{Environment.NewLine}{result.Output}"); + + childProcessId = int.Parse(await File.ReadAllTextAsync(childPidPath), CultureInfo.InvariantCulture); + using var survivingChild = TryGetProcessById(childProcessId.Value); + Assert.Null(survivingChild); + childProcessId = null; + } + finally + { + if (childProcessId is { } processId) + { + using var childProcess = TryGetProcessById(processId); + if (childProcess is not null && !childProcess.HasExited) + { + childProcess.Kill(entireProcessTree: true); + } + } + } + } + + [Fact] + public async Task BuildUsesEnvironmentAspireHomeWhenMsBuildPropertyDiffers() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true, includeBundlePaths: false); + var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "fake-cli")); + await CreateFakeDnxAsync(fakeCliDirectory.FullName); + var environmentAspireHome = Path.Combine(workspace.Path, "environment-aspire-home"); + var propertyAspireHome = Path.Combine(workspace.Path, "property-aspire-home"); + var environment = new Dictionary + { + ["ASPIRE_HOME"] = environmentAspireHome, + [GetPathEnvironmentVariableName()] = CreatePathWithoutAspire(fakeCliDirectory.FullName) + }; + + var buildResult = await RunDotNetWithArgumentsAsync( + project.ProjectDirectory, + ["build", "-nologo", project.ProjectFile, $"-p:ASPIRE_HOME={propertyAspireHome}"], + environment); + + Assert.True(buildResult.ExitCode == 0, buildResult.Output); + AssertCliBundleExists(environmentAspireHome, buildResult.Output); + Assert.False(Directory.Exists(Path.Combine(propertyAspireHome, "bundle"))); } [Fact] @@ -796,14 +1052,42 @@ private static async Task> GetComputeRunArgumentsProp RunHookProject project, string[]? extraArguments = null, IDictionary? environment = null) + { + return await GetTargetPropertiesAsync( + project, + "ComputeRunArguments", + "RunCommand,RunArguments,RunWorkingDirectory,_AspireResolvedCliInvocationMode,_AspireResolvedCliPath,_AspireResolvedDnxPath,_AspireResolvedDnxHostPath,_AspireResolvedDnxHostArguments,_AspireResolvedCliVersion,_AspireCliVersionSupportsRunHook,_AspireCliVersionCommand", + extraArguments, + environment); + } + + private static async Task> GetResolveAspireCliBundlePathPropertiesAsync( + RunHookProject project, + IDictionary environment, + string[]? extraArguments = null) + { + return await GetTargetPropertiesAsync( + project, + "ResolveAspireCliBundlePaths", + "DcpDir,AspireDashboardDir,AspireDashboardPath,_AspireResolvedCliInvocationMode,_AspireResolvedCliPath", + extraArguments, + environment: environment); + } + + private static async Task> GetTargetPropertiesAsync( + RunHookProject project, + string target, + string propertyNames, + string[]? extraArguments, + IDictionary? environment) { var arguments = new List { "msbuild", "-nologo", "-restore", - "-t:ComputeRunArguments", - "-getProperty:RunCommand,RunArguments,RunWorkingDirectory,_AspireResolvedCliInvocationMode,_AspireResolvedCliPath,_AspireResolvedDnxPath,_AspireResolvedDnxHostPath,_AspireResolvedDnxHostArguments,_AspireResolvedCliVersion,_AspireCliVersionSupportsRunHook,_AspireCliVersionCommand", + $"-t:{target}", + $"-getProperty:{propertyNames}", project.ProjectFile }; @@ -846,6 +1130,57 @@ exit 0 return aspirePath; } + private static async Task CreateFakeAspireCliThatSetsUpBundleAsync(string fakeCliDirectory) + { + var aspirePath = Path.Combine(fakeCliDirectory, OperatingSystem.IsWindows() ? "aspire.cmd" : "aspire"); + var contents = OperatingSystem.IsWindows() + ? """ + @echo off + if not "%~1"=="setup" exit /b 2 + mkdir "%~dp0bundle\dcp" + mkdir "%~dp0bundle\managed" + type nul > "%~dp0bundle\dcp\dcp.exe" + type nul > "%~dp0bundle\managed\aspire-managed.exe" + """ + : """ + #!/bin/sh + if [ "$1" != "setup" ]; then + exit 2 + fi + install_path="$(dirname "$0")" + mkdir -p "$install_path/bundle/dcp" "$install_path/bundle/managed" + : > "$install_path/bundle/dcp/dcp" + : > "$install_path/bundle/managed/aspire-managed" + """; + + await File.WriteAllTextAsync(aspirePath, contents.ReplaceLineEndings(OperatingSystem.IsWindows() ? "\r\n" : "\n")); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(aspirePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + return aspirePath; + } + + private static (string DcpDirectory, string ManagedDirectory, string ManagedPath) CreateFakeCliBundle(string layoutRoot) + { + var bundle = GetFakeCliBundlePaths(layoutRoot); + Directory.CreateDirectory(bundle.DcpDirectory); + Directory.CreateDirectory(bundle.ManagedDirectory); + File.WriteAllText(Path.Combine(bundle.DcpDirectory, OperatingSystem.IsWindows() ? "dcp.exe" : "dcp"), ""); + File.WriteAllText(bundle.ManagedPath, ""); + return bundle; + } + + private static (string DcpDirectory, string ManagedDirectory, string ManagedPath) GetFakeCliBundlePaths(string layoutRoot) + { + var bundleRoot = Path.Combine(layoutRoot, "bundle"); + var dcpDirectory = Path.Combine(bundleRoot, "dcp"); + var managedDirectory = Path.Combine(bundleRoot, "managed"); + var managedPath = Path.Combine(managedDirectory, OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed"); + return (dcpDirectory, managedDirectory, managedPath); + } + private static async Task CreateFakeAspireCommandShimAsync(string fakeCliDirectory, string extension = ".cmd") { var aspirePath = Path.Combine(fakeCliDirectory, $"aspire{extension}"); @@ -912,6 +1247,12 @@ goto loop echo "13.5.0" exit 0 fi + if [ "$1" = "--yes" ] && { [ "$2" = "aspire.cli@13.5.0" ] || [ "$2" = "aspire.cli" ]; } && [ "$3" = "--" ] && [ "$4" = "setup" ] && [ "$5" = "--install-path" ]; then + mkdir -p "$6/bundle/dcp" "$6/bundle/managed" + : > "$6/bundle/dcp/dcp" + : > "$6/bundle/managed/aspire-managed" + exit 0 + fi printf '%s\n' "$@" > "$ASPIRE_TEST_CAPTURE_PATH" """).ReplaceLineEndings("\n")); @@ -986,12 +1327,7 @@ @echo off echo {{version}} exit /b 0 ) - type nul > "%ASPIRE_TEST_CAPTURE_PATH%" - :loop - if "%~1"=="" exit /b 0 - >> "%ASPIRE_TEST_CAPTURE_PATH%" echo %~1 - shift - goto loop + exit /b 42 """); return; @@ -1004,7 +1340,7 @@ await File.WriteAllTextAsync(aspirePath, ($$""" echo "{{version}}" exit 0 fi - printf '%s\n' "$@" > "$ASPIRE_TEST_CAPTURE_PATH" + exit 42 """).ReplaceLineEndings("\n")); File.SetUnixFileMode(aspirePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); } @@ -1073,6 +1409,44 @@ exit 0 File.SetUnixFileMode(aspirePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); } + private static async Task CreateFakeAspireCliThatHangsOnSetupAsync(string fakeCliDirectory) + { + if (OperatingSystem.IsWindows()) + { + await File.WriteAllTextAsync(Path.Combine(fakeCliDirectory, "aspire.cmd"), """ + @echo off + if "%~1"=="--version" ( + echo 13.5.0 + exit /b 0 + ) + if "%~1"=="setup" ( + :loop + ping -n 2 127.0.0.1 > nul + goto loop + ) + exit /b 0 + """); + + return; + } + + var aspirePath = Path.Combine(fakeCliDirectory, "aspire"); + await File.WriteAllTextAsync(aspirePath, (""" + #!/bin/sh + if [ "$1" = "--version" ]; then + echo "13.5.0" + exit 0 + fi + if [ "$1" = "setup" ]; then + while true; do + sleep 1 + done + fi + exit 0 + """).ReplaceLineEndings("\n")); + File.SetUnixFileMode(aspirePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + private static async Task CreateFakeAspireCliThatFailsVersionAsync(string fakeCliDirectory) { if (OperatingSystem.IsWindows()) @@ -1144,6 +1518,34 @@ private static string GetAspireHostingTasksAssemblyPath() return taskAssemblyPath!; } + private static async Task FindFullFrameworkMSBuildAsync() + { + var vswherePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), + "Microsoft Visual Studio", + "Installer", + "vswhere.exe"); + if (!File.Exists(vswherePath)) + { + return null; + } + + var result = await RunProcessWithArgumentsAsync( + vswherePath, + Path.GetDirectoryName(vswherePath)!, + ["-latest", "-products", "*", "-requires", "Microsoft.Component.MSBuild", "-find", @"MSBuild\**\Bin\MSBuild.exe"], + environment: null, + TimeSpan.FromSeconds(30)); + if (result.ExitCode != 0) + { + return null; + } + + return result.StandardOutput + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .FirstOrDefault(File.Exists); + } + private static string GetAssemblyMetadataPath(string metadataName) { var path = typeof(AppHostSdkTargetsTests).Assembly @@ -1237,6 +1639,16 @@ private static void AssertUsesDotNetRun(Dictionary properties, R Assert.Equal(project.ProjectDirectory, properties["RunWorkingDirectory"]); } + private static void AssertCliBundleExists(string aspireHome, string output) + { + Assert.True( + File.Exists(Path.Combine(aspireHome, "bundle", "dcp", OperatingSystem.IsWindows() ? "dcp.exe" : "dcp")), + output); + Assert.True( + File.Exists(Path.Combine(aspireHome, "bundle", "managed", OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed")), + output); + } + private static string GetExpectedDotNetRunCommand(RunHookProject project) { var executableName = OperatingSystem.IsWindows() ? "AppHost.exe" : "AppHost"; @@ -1259,13 +1671,13 @@ private static Dictionary CreatePathEnvironment(string directory }; } - private static string CreatePathWithoutAspire(string firstDirectory) - => CreatePathWithoutCommand(firstDirectory, "aspire"); + private static string CreatePathWithoutAspire(params string[] firstDirectories) + => CreatePathWithoutCommand(firstDirectories, "aspire"); - private static string CreatePathWithoutDnx(string firstDirectory) - => CreatePathWithoutCommand(firstDirectory, "dnx"); + private static string CreatePathWithoutDnx(params string[] firstDirectories) + => CreatePathWithoutCommand(firstDirectories, "dnx"); - private static string CreatePathWithoutCommand(string firstDirectory, string commandName) + private static string CreatePathWithoutCommand(string[] firstDirectories, string commandName) { var pathEnvironmentVariable = GetPathEnvironmentVariableName(); var currentPath = Environment.GetEnvironmentVariable(pathEnvironmentVariable) ?? string.Empty; @@ -1273,7 +1685,7 @@ private static string CreatePathWithoutCommand(string firstDirectory, string com .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) .Where(directory => !ContainsCommand(directory, commandName)); - return string.Join(Path.PathSeparator, pathDirectories.Prepend(firstDirectory)); + return string.Join(Path.PathSeparator, firstDirectories.Concat(pathDirectories)); } private static bool ContainsCommand(string directory, string commandName) @@ -1319,9 +1731,17 @@ private static bool ContainsCommand(string directory, string commandName) return (process.ExitCode, output + error); } - private static async Task RunDotNetWithArgumentsAsync(string workingDirectory, string[] arguments, IDictionary? environment = null) + private static Task RunDotNetWithArgumentsAsync(string workingDirectory, string[] arguments, IDictionary? environment = null) + => RunProcessWithArgumentsAsync("dotnet", workingDirectory, arguments, environment, TimeSpan.FromMinutes(3)); + + private static async Task RunProcessWithArgumentsAsync( + string fileName, + string workingDirectory, + string[] arguments, + IDictionary? environment, + TimeSpan timeout) { - var startInfo = new ProcessStartInfo("dotnet") + var startInfo = new ProcessStartInfo(fileName) { RedirectStandardOutput = true, RedirectStandardError = true, @@ -1352,7 +1772,7 @@ private static async Task RunDotNetWithArgumentsAsync(string worki var outputTask = process.StandardOutput.ReadToEndAsync(); var errorTask = process.StandardError.ReadToEndAsync(); - using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(3)); + using var cts = new CancellationTokenSource(timeout); try { @@ -1361,12 +1781,24 @@ private static async Task RunDotNetWithArgumentsAsync(string worki catch (OperationCanceledException) { process.Kill(entireProcessTree: true); - throw new TimeoutException($"dotnet {string.Join(' ', arguments)} timed out after 3 minutes."); + throw new TimeoutException($"{fileName} {string.Join(' ', arguments)} timed out after {timeout}."); } return new DotNetResult(process.ExitCode, await outputTask, await errorTask); } + private static Process? TryGetProcessById(int processId) + { + try + { + return Process.GetProcessById(processId); + } + catch (ArgumentException) + { + return null; + } + } + private static string GetRepoRoot() { var directory = AppContext.BaseDirectory; diff --git a/tests/Aspire.Hosting.Sdk.Tests/Aspire.Hosting.Sdk.Tests.csproj b/tests/Aspire.Hosting.Sdk.Tests/Aspire.Hosting.Sdk.Tests.csproj index bbaa07d2897..67525bfb3d3 100644 --- a/tests/Aspire.Hosting.Sdk.Tests/Aspire.Hosting.Sdk.Tests.csproj +++ b/tests/Aspire.Hosting.Sdk.Tests/Aspire.Hosting.Sdk.Tests.csproj @@ -41,6 +41,7 @@ + diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpCliArgsTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpCliArgsTests.cs index 6da8ad87b80..dcb05beba3c 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpCliArgsTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpCliArgsTests.cs @@ -119,35 +119,40 @@ public void WhitespaceDcpPublisherPathConfigurationFallsBackToBundlePaths() Assert.Equal(bundleDashboardPath, dcpOptions.DashboardPath); } - [Fact] - public void DcpOptionsValidationFailsForWhitespacePaths() + [Theory] + [InlineData(DistributedApplicationOperation.Run, true)] + [InlineData(DistributedApplicationOperation.Publish, false)] + public void DcpOptionsValidationRequiresPathsOnlyInRunMode(DistributedApplicationOperation operation, bool shouldFail) { - var validator = new ValidateDcpOptions(); + var validator = new ValidateDcpOptions(new DistributedApplicationExecutionContext(operation)); var result = validator.Validate(null, new DcpOptions { CliPath = " ", DashboardPath = "\t", }); - Assert.True(result.Failed); - Assert.Contains("The path to the DCP executable used for Aspire orchestration is required.", result.FailureMessage); - Assert.Contains("The path to the Aspire Dashboard binaries is missing.", result.FailureMessage); + Assert.Equal(shouldFail, result.Failed); + if (shouldFail) + { + Assert.Contains("The path to the DCP executable used for Aspire orchestration is required.", result.FailureMessage); + Assert.Contains("The path to the Aspire Dashboard binaries is missing.", result.FailureMessage); + } } [Fact] - public void DcpOptionsValidationFailsForInvalidProxylessEndpointPortRange() + public void DcpOptionsValidationStillFailsForInvalidProxylessEndpointPortRangeInPublishMode() { - var validator = new ValidateDcpOptions(); + var validator = new ValidateDcpOptions(new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish)); var result = validator.Validate(null, new DcpOptions { - CliPath = "dcp", - DashboardPath = "dashboard", ProxylessEndpointPortRangeStart = 32767, ProxylessEndpointPortRangeEnd = 10000, }); Assert.True(result.Failed); - Assert.Contains("The proxyless endpoint port range start must be less than or equal to the range end.", result.FailureMessage); + Assert.Contains( + "The proxyless endpoint port range start must be less than or equal to the range end.", + Assert.Single(result.Failures!)); } [Fact] diff --git a/tests/Aspire.Hosting.Tests/MSBuildTests.cs b/tests/Aspire.Hosting.Tests/MSBuildTests.cs index 4313fd7c32e..ebb9508ef8e 100644 --- a/tests/Aspire.Hosting.Tests/MSBuildTests.cs +++ b/tests/Aspire.Hosting.Tests/MSBuildTests.cs @@ -4,8 +4,10 @@ using System.Diagnostics; using System.IO.Compression; using System.Text; +using System.Text.Json; using System.Xml.Linq; using Aspire.Hosting.Tests.Utils; +using Aspire.Shared; namespace Aspire.Hosting.Tests; @@ -319,6 +321,34 @@ private static (int ExitCode, string Output) RunDotNet(string workingDirectory, return (process.ExitCode, output.ToString()); } + [Fact] + public void AppHostInspectionQuerySucceedsForPlainAppHostNamedProject() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var projectPath = Path.Combine(workspace.WorkspaceRoot.FullName, "Plain.AppHost.csproj"); + File.WriteAllText(projectPath, """ + + + net8.0 + + + """); + + var result = RunDotNet( + workspace.WorkspaceRoot.FullName, + $"msbuild -getProperty:MSBuildVersion,IsAspireHost,AspireHostingSDKVersion,AspireUseCliBundle,UserSecretsId,RunCommand,TargetPath,RunWorkingDirectory,RunArguments,TargetFramework,TargetFrameworks -getItem:PackageReference,AspireProjectOrPackageReference,PackageVersion -t:ComputeRunArguments \"{projectPath}\"", + timeoutMilliseconds: 180_000, + new Dictionary + { + ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1", + ["DOTNET_CLI_WORKLOAD_UPDATE_NOTIFY_DISABLE"] = "1", + }); + + Assert.True(result.ExitCode == 0, $"MSBuild query failed: {Environment.NewLine}{result.Output}"); + using var document = JsonDocument.Parse(result.Output); + Assert.Equal(string.Empty, document.RootElement.GetProperty("Properties").GetProperty("IsAspireHost").GetString()); + } + [Fact] public async Task CliBundleOptInResolvesExplicitBundlePath() { @@ -435,6 +465,186 @@ public async Task CliBundleOptInResolvesAspireHomeBundleForSidecarlessCli() Assert.Equal(bundle.ManagedPath, resolvedPaths[2]); } + [Fact] + public async Task CliBundleOptInPreparesMissingBundleBesidePathCli() + { + var repoRoot = MSBuildUtils.GetRepoRoot(); + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var aspireHome = Path.Combine(workspace.WorkspaceRoot.FullName, "empty-aspire-home"); + var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "fake-cli")); + _ = CreateFakeAspireCliThatSetsUpBundle(fakeCliDirectory.FullName); + var appHostDirectory = CreateSdkBundleAppHostProject(workspace.WorkspaceRoot.FullName, repoRoot, additionalProperties: ""); + + CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot); + + BuildProject(appHostDirectory, new Dictionary + { + ["ASPIRE_HOME"] = aspireHome, + ["PATH"] = GetPathWithoutAspire(fakeCliDirectory.FullName) + }); + + var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt")); + var bundleRoot = Path.Combine(workspace.WorkspaceRoot.FullName, BundleDiscovery.BundleDirectoryName); + var expectedDcpDir = EnsureTrailingSeparator(Path.Combine(bundleRoot, "dcp")); + var expectedManagedDir = EnsureTrailingSeparator(Path.Combine(bundleRoot, "managed")); + Assert.Equal(expectedDcpDir, resolvedPaths[0]); + Assert.Equal(expectedManagedDir, resolvedPaths[1]); + Assert.Equal(Path.Combine(expectedManagedDir, OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed"), resolvedPaths[2]); + Assert.False(Directory.Exists(Path.Combine(aspireHome, BundleDiscovery.BundleDirectoryName))); + } + + [Fact] + public void CliBundleOptInSkipsSetupDuringDesignTimeBuild() + { + var repoRoot = MSBuildUtils.GetRepoRoot(); + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var aspireHome = Path.Combine(workspace.WorkspaceRoot.FullName, "empty-aspire-home"); + var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "fake-cli")); + var fakeCliPath = CreateFakeAspireCliThatSetsUpBundle(fakeCliDirectory.FullName); + var appHostDirectory = CreateSdkBundleAppHostProject( + workspace.WorkspaceRoot.FullName, + repoRoot, + $""" + {fakeCliPath} + """); + + CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot); + + var result = RunDotNet( + appHostDirectory, + "build --disable-build-servers -p:DesignTimeBuild=true", + timeoutMilliseconds: 180_000, + new Dictionary { ["ASPIRE_HOME"] = aspireHome }); + + Assert.True(result.ExitCode == 0, $"Design-time build failed: {Environment.NewLine}{result.Output}"); + Assert.False(Directory.Exists(Path.Combine(aspireHome, BundleDiscovery.BundleDirectoryName))); + Assert.False(Directory.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, BundleDiscovery.BundleDirectoryName))); + } + + [Fact] + public void CliBundleSetupFailureReportsAspire009WhenWarningsAreErrors() + { + var repoRoot = MSBuildUtils.GetRepoRoot(); + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var aspireHome = Path.Combine(workspace.WorkspaceRoot.FullName, "empty-aspire-home"); + var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "fake-cli")); + var fakeCliPath = CreateFakeAspireCliThatFailsSetup(fakeCliDirectory.FullName); + var appHostDirectory = CreateSdkBundleAppHostProject( + workspace.WorkspaceRoot.FullName, + repoRoot, + $""" + {fakeCliPath} + """); + + CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot); + + var result = RunDotNet( + appHostDirectory, + "build --disable-build-servers -warnaserror", + timeoutMilliseconds: 180_000, + new Dictionary { ["ASPIRE_HOME"] = aspireHome }); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("ASPIRE009", result.Output); + Assert.Contains("failed with exit code 42", result.Output); + Assert.Contains($"Run '\"{fakeCliPath}\" setup'", result.Output); + } + + [Fact] + public void CliBundleSetupTimeoutReportsAspire009() + { + var repoRoot = MSBuildUtils.GetRepoRoot(); + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var aspireHome = Path.Combine(workspace.WorkspaceRoot.FullName, "empty-aspire-home"); + var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "fake-cli")); + var fakeCliPath = CreateFakeAspireCliThatTimesOutSetup(fakeCliDirectory.FullName); + var appHostDirectory = CreateSdkBundleAppHostProject( + workspace.WorkspaceRoot.FullName, + repoRoot, + $""" + {fakeCliPath} + <_AspireCliBundleSetupTimeout>100 + """); + + CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot); + + var output = BuildProjectWithFailure( + appHostDirectory, + new Dictionary { ["ASPIRE_HOME"] = aspireHome }); + + Assert.Contains("ASPIRE009", output); + Assert.Contains("Automatic Aspire CLI bundle setup did not produce a usable DCP and dashboard layout.", output); + Assert.Contains("The command timed out after 100 milliseconds.", output); + } + + [Fact] + public async Task CliBundleOptInPreservesLiteralPercentCharactersInWindowsCommandShimPath() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows command-shim escaping is required."); + + var repoRoot = MSBuildUtils.GetRepoRoot(); + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var aspireHome = Path.Combine(workspace.WorkspaceRoot.FullName, "empty-aspire-home"); + var literalSegment = "%ASPIRE_LITERAL_SEGMENT%"; + var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, literalSegment)); + var fakeCliPath = CreateFakeAspireCliThatSetsUpBundle(fakeCliDirectory.FullName); + var appHostDirectory = CreateSdkBundleAppHostProject( + workspace.WorkspaceRoot.FullName, + repoRoot, + $""" + {fakeCliPath} + """); + + CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot); + + BuildProject(appHostDirectory, new Dictionary + { + ["ASPIRE_HOME"] = aspireHome, + ["ASPIRE_LITERAL_SEGMENT"] = "expanded-segment" + }); + + var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt")); + var bundleRoot = Path.Combine(workspace.WorkspaceRoot.FullName, BundleDiscovery.BundleDirectoryName); + Assert.Equal(EnsureTrailingSeparator(Path.Combine(bundleRoot, BundleDiscovery.DcpDirectoryName)), resolvedPaths[0]); + Assert.Equal(EnsureTrailingSeparator(Path.Combine(bundleRoot, BundleDiscovery.ManagedDirectoryName)), resolvedPaths[1]); + } + + [Fact] + public void CliBundleSetupFailureDiagnosticPreservesLiteralPercentCharacters() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows command-shim escaping is required."); + + var repoRoot = MSBuildUtils.GetRepoRoot(); + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var literalSegment = "%ASPIRE_LITERAL_SEGMENT%"; + var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, literalSegment)); + var fakeCliPath = CreateFakeAspireCliThatFailsSetup(fakeCliDirectory.FullName); + var appHostDirectory = CreateSdkBundleAppHostProject( + workspace.WorkspaceRoot.FullName, + repoRoot, + $""" + {fakeCliPath} + """); + + CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot); + + var output = BuildProjectWithFailure(appHostDirectory, new Dictionary + { + ["ASPIRE_HOME"] = Path.Combine(workspace.WorkspaceRoot.FullName, "empty-aspire-home"), + ["ASPIRE_LITERAL_SEGMENT"] = "expanded-segment" + }); + + Assert.Contains($"Run '\"{fakeCliPath}\" setup'", output); + Assert.DoesNotContain("^^%%", output); + Assert.DoesNotContain("cmd /D /V:OFF", output); + } + [Fact] public async Task CliBundleOptInResolvesAspireHomeBundleWithoutCliOnPath() { @@ -553,7 +763,7 @@ public void CliBundleOptInFailsWhenExplicitBundlePathIsInvalid() [Theory] [InlineData(null)] [InlineData("Dnx")] - public void CliBundleDnxInvocationAllowsMissingBuildTimeBundle(string? invocationMode) + public void CliBundleDnxInvocationFailsWhenSetupCannotProduceLayout(string? invocationMode) { var repoRoot = MSBuildUtils.GetRepoRoot(); using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -569,27 +779,33 @@ public void CliBundleDnxInvocationAllowsMissingBuildTimeBundle(string? invocatio additionalProperties); var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "fake-cli")); var dnxPath = Path.Combine(fakeCliDirectory.FullName, OperatingSystem.IsWindows() ? "dnx.exe" : "dnx"); - File.WriteAllText(dnxPath, ""); - if (!OperatingSystem.IsWindows()) + if (OperatingSystem.IsWindows()) { + // Use a terminating PE executable. A copied cmd.exe starts an interactive shell + // because the DNX arguments do not include /C and leaves the test waiting for timeout. + File.Copy(Path.Combine(Environment.SystemDirectory, "where.exe"), dnxPath); + } + else + { + File.WriteAllText(dnxPath, "#!/bin/sh\nexit 42\n"); File.SetUnixFileMode(dnxPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); } CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot); - BuildProject(appHostDirectory, new Dictionary + var output = BuildProjectWithFailure(appHostDirectory, new Dictionary { ["ASPIRE_HOME"] = Path.Combine(workspace.WorkspaceRoot.FullName, "empty-aspire-home"), - ["PATH"] = $"{fakeCliDirectory.FullName}{Path.PathSeparator}{GetDotNetOnlyPath()}" + ["PATH"] = GetPathWithoutAspire(fakeCliDirectory.FullName) }); - var appHostIdentityMetadata = File.ReadAllLines(Path.Combine(appHostDirectory, "obj", "apphost-identity-metadata.txt")); - Assert.Equal( - [ - $"apphostprojectpath={appHostDirectory}", - "apphostprojectname=AppHost.csproj" - ], - appHostIdentityMetadata); + Assert.Contains("ASPIRE009", output); + Assert.Contains("the bundle could not be resolved", output); + Assert.Contains("failed with exit code", output); + if (!OperatingSystem.IsWindows()) + { + Assert.Contains("failed with exit code 42", output); + } } [Fact] @@ -797,6 +1013,92 @@ private static string CreateFakeAspireCli(string directory) return cliPath; } + private static string CreateFakeAspireCliThatSetsUpBundle(string directory) + { + var cliPath = Path.Combine(directory, OperatingSystem.IsWindows() ? "aspire.cmd" : "aspire"); + var dcpExecutable = OperatingSystem.IsWindows() ? "dcp.exe" : "dcp"; + var managedExecutable = OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed"; + var contents = OperatingSystem.IsWindows() + ? $$""" + @echo off + if not "%~1"=="setup" exit /b 2 + mkdir "%~dp0..\bundle\dcp" + mkdir "%~dp0..\bundle\managed" + type nul > "%~dp0..\bundle\dcp\{{dcpExecutable}}" + type nul > "%~dp0..\bundle\managed\{{managedExecutable}}" + """ + : $$""" + #!/bin/sh + if [ "$1" != "setup" ]; then + exit 2 + fi + install_path="$(dirname "$0")/.." + mkdir -p "$install_path/bundle/dcp" "$install_path/bundle/managed" + : > "$install_path/bundle/dcp/{{dcpExecutable}}" + : > "$install_path/bundle/managed/{{managedExecutable}}" + """; + + File.WriteAllText(cliPath, contents.ReplaceLineEndings(OperatingSystem.IsWindows() ? "\r\n" : "\n")); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(cliPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + return cliPath; + } + + private static string CreateFakeAspireCliThatTimesOutSetup(string directory) + { + var cliPath = Path.Combine(directory, OperatingSystem.IsWindows() ? "aspire.cmd" : "aspire"); + var contents = OperatingSystem.IsWindows() + ? """ + @echo off + if not "%~1"=="setup" exit /b 2 + ping -n 6 127.0.0.1 > nul + """ + : """ + #!/bin/sh + if [ "$1" != "setup" ]; then + exit 2 + fi + sleep 5 + """; + + File.WriteAllText(cliPath, contents.ReplaceLineEndings(OperatingSystem.IsWindows() ? "\r\n" : "\n")); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(cliPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + return cliPath; + } + + private static string CreateFakeAspireCliThatFailsSetup(string directory) + { + var cliPath = Path.Combine(directory, OperatingSystem.IsWindows() ? "aspire.cmd" : "aspire"); + var contents = OperatingSystem.IsWindows() + ? """ + @echo off + if not "%~1"=="setup" exit /b 2 + exit /b 42 + """ + : """ + #!/bin/sh + if [ "$1" != "setup" ]; then + exit 2 + fi + exit 42 + """; + + File.WriteAllText(cliPath, contents.ReplaceLineEndings(OperatingSystem.IsWindows() ? "\r\n" : "\n")); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(cliPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + return cliPath; + } + private static string EnsureTrailingSeparator(string path) { return path.EndsWith(Path.DirectorySeparatorChar) ? path : path + Path.DirectorySeparatorChar; @@ -812,6 +1114,25 @@ private static string GetDotNetOnlyPath() return dotnetDirectory; } + private static string GetPathWithoutAspire(string firstDirectory) + { + var currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + var pathDirectories = currentPath + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Where(directory => !ContainsCommand(directory, "aspire")); + + return string.Join(Path.PathSeparator, pathDirectories.Prepend(firstDirectory)); + } + + private static bool ContainsCommand(string directory, string commandName) + { + var executableNames = OperatingSystem.IsWindows() + ? new[] { $"{commandName}.exe", $"{commandName}.cmd", $"{commandName}.bat", commandName } + : [commandName]; + + return executableNames.Any(executableName => File.Exists(Path.Combine(directory.Trim().Trim('"'), executableName))); + } + /// /// Tests that when TreatProjectReferencesAsResources is set to false, /// ProjectReference items are not mutated with Aspire-specific metadata.