Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
258 changes: 252 additions & 6 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,250 @@ jobs:
with:
versionOverrideArg: ${{ inputs.versionOverrideArg }}

cli_starter_validation_win:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: to follow precedent as well as make this file easier to parse, can we split the actual implementation of the validation to a separate file that gets ipmorted here?

name: Aspire CLI Starter Validation (Windows)
runs-on: windows-latest
timeout-minutes: 10
needs: [build_packages, build_cli_archive_windows]
if: ${{ github.event_name == 'pull_request' && github.repository_owner == 'microsoft' }}
env:
GH_TOKEN: ${{ github.token }}
MAX_STARTUP_SECONDS: '120'
RESOURCE_READY_TIMEOUT_SECONDS: '120'
steps:
- name: Checkout PR head
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}

- name: Setup .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
global-json-file: global.json

- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '22'

- name: Install Aspire CLI from PR dogfood script
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$scriptPath = Join-Path $env:GITHUB_WORKSPACE "eng/scripts/get-aspire-cli-pr.ps1"
& $scriptPath ${{ github.event.pull_request.number }} -WorkflowRunId ${{ github.run_id }} -SkipExtension

aspire --version

- name: Create starter app and validate startup
timeout-minutes: 10
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true

$validationRoot = Join-Path $env:RUNNER_TEMP 'aspire-cli-starter-validation'
Remove-Item -Recurse -Force $validationRoot -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $validationRoot -Force | Out-Null

$templates = @(
@{
TemplateId = 'aspire-ts-starter'
ProjectName = 'AspireCliTsStarterSmoke'
ExpectedResources = @('app', 'frontend')
},
@{
TemplateId = 'aspire-starter'
ProjectName = 'AspireCliCsStarterSmoke'
ExpectedResources = @('apiservice')
}
)
$failures = [System.Collections.Generic.List[string]]::new()

foreach ($template in $templates) {
$templateId = $template.TemplateId
$projectName = $template.ProjectName
$expectedResources = @($template.ExpectedResources)
$templateRoot = Join-Path $validationRoot $templateId
$diagnosticsDir = Join-Path $templateRoot 'diagnostics'
$projectRoot = Join-Path $templateRoot $projectName
$startStdOutPath = Join-Path $diagnosticsDir 'aspire-start.stdout.log'
$startStdErrPath = Join-Path $diagnosticsDir 'aspire-start.stderr.log'
$startCombinedPath = Join-Path $diagnosticsDir 'aspire-start.log'
$preStartStopLogPath = Join-Path $diagnosticsDir 'aspire-stop-before-start.log'
$stopLogPath = Join-Path $diagnosticsDir 'aspire-stop.log'

Comment thread
sebastienros marked this conversation as resolved.
New-Item -ItemType Directory -Path $templateRoot -Force | Out-Null
New-Item -ItemType Directory -Path $diagnosticsDir -Force | Out-Null

Push-Location $templateRoot
try {
try {
aspire new $templateId --name $projectName --output $projectRoot --channel pr-${{ github.event.pull_request.number }} --non-interactive --nologo

try {
aspire stop *> $preStartStopLogPath
}
catch {
$preStartStopOutput = if (Test-Path $preStartStopLogPath) { Get-Content $preStartStopLogPath -Raw } else { '' }
if ($preStartStopOutput -notmatch 'No running apphost found\.') {
Write-Warning "$templateId pre-start cleanup with aspire stop failed: $($_.Exception.Message)"
if ($preStartStopOutput) {
Write-Host $preStartStopOutput
}
}
}

$startAt = Get-Date
$process = Start-Process -FilePath 'aspire' `
-ArgumentList @('start') `
-WorkingDirectory $projectRoot `
-RedirectStandardOutput $startStdOutPath `
-RedirectStandardError $startStdErrPath `
-PassThru

try {
$process | Wait-Process -Timeout ([int]$env:MAX_STARTUP_SECONDS) -ErrorAction Stop
}
catch {
if (-not $process.HasExited) {
$process | Stop-Process -Force -ErrorAction SilentlyContinue
}

throw "${templateId}: aspire start did not exit within $($env:MAX_STARTUP_SECONDS) seconds."
}

$elapsed = (Get-Date) - $startAt
$startOutput = @(
if (Test-Path $startStdOutPath) { Get-Content $startStdOutPath }
if (Test-Path $startStdErrPath) { Get-Content $startStdErrPath }
) -join [Environment]::NewLine

Set-Content -Path $startCombinedPath -Value $startOutput

if ($process.ExitCode -ne 0) {
throw "${templateId}: aspire start failed with exit code $($process.ExitCode)."
}

if ($elapsed.TotalSeconds -ge [int]$env:MAX_STARTUP_SECONDS) {
throw "${templateId}: aspire start took $([math]::Round($elapsed.TotalSeconds, 2)) seconds, which exceeds the $($env:MAX_STARTUP_SECONDS)-second limit."
}

if ($startOutput -match 'Timeout waiting for apphost to start') {
throw "${templateId}: aspire start reported a startup timeout."
}

if ($startOutput -notmatch 'Apphost started successfully\.') {
throw "${templateId}: aspire start did not report a successful startup."
}

Set-Location $projectRoot

$resourcesStdOutPath = Join-Path $diagnosticsDir 'aspire-resources.stdout.log'
$resourcesStdErrPath = Join-Path $diagnosticsDir 'aspire-resources.stderr.log'
$resourcesCombinedPath = Join-Path $diagnosticsDir 'aspire-resources.log'

$resourcesProcess = Start-Process -FilePath 'aspire' `
-ArgumentList @('resources') `
-WorkingDirectory $projectRoot `
-RedirectStandardOutput $resourcesStdOutPath `
-RedirectStandardError $resourcesStdErrPath `
-Wait `
-PassThru

$resourcesOutput = @(
if (Test-Path $resourcesStdOutPath) { Get-Content $resourcesStdOutPath }
if (Test-Path $resourcesStdErrPath) { Get-Content $resourcesStdErrPath }
) -join [Environment]::NewLine

Set-Content -Path $resourcesCombinedPath -Value $resourcesOutput
Write-Host $resourcesOutput

if ($resourcesProcess.ExitCode -ne 0) {
throw "${templateId}: aspire resources failed with exit code $($resourcesProcess.ExitCode)."
}

foreach ($resourceName in $expectedResources) {
$sanitizedResourceName = $resourceName -replace '[^A-Za-z0-9_.-]', '_'
$waitStdOutPath = Join-Path $diagnosticsDir "aspire-wait-${sanitizedResourceName}.stdout.log"
$waitStdErrPath = Join-Path $diagnosticsDir "aspire-wait-${sanitizedResourceName}.stderr.log"
$waitCombinedPath = Join-Path $diagnosticsDir "aspire-wait-${sanitizedResourceName}.log"

$waitProcess = Start-Process -FilePath 'aspire' `
-ArgumentList @('wait', $resourceName, '--status', 'up', '--timeout', $env:RESOURCE_READY_TIMEOUT_SECONDS) `
-WorkingDirectory $projectRoot `
-RedirectStandardOutput $waitStdOutPath `
-RedirectStandardError $waitStdErrPath `
-Wait `
-PassThru

$waitOutput = @(
if (Test-Path $waitStdOutPath) { Get-Content $waitStdOutPath }
if (Test-Path $waitStdErrPath) { Get-Content $waitStdErrPath }
) -join [Environment]::NewLine

Set-Content -Path $waitCombinedPath -Value $waitOutput

if ($waitProcess.ExitCode -ne 0) {
throw "${templateId}: aspire wait for resource $resourceName failed with exit code $($waitProcess.ExitCode)."
}
}

Write-Host "$templateId started in $([math]::Round($elapsed.TotalSeconds, 2)) seconds."
}
catch {
$message = $_.Exception.Message
Write-Warning $message
$failures.Add($message)
}
}
finally {
if (Test-Path $projectRoot) {
Push-Location $projectRoot
try {
aspire stop *> $stopLogPath
}
catch {
Write-Warning "$templateId cleanup with aspire stop failed: $($_.Exception.Message)"
if (Test-Path $stopLogPath) {
Get-Content $stopLogPath
}
}
finally {
Pop-Location
}
}

Pop-Location
}
}

if ($failures.Count -gt 0) {
throw ("Starter validation failures:`n- " + ($failures -join "`n- "))
}

- name: Collect CLI logs
if: always()
shell: pwsh
run: |
$validationRoot = Join-Path $env:RUNNER_TEMP 'aspire-cli-starter-validation'
$cliLogsDir = Join-Path $HOME '.aspire\logs'
$sharedLogsDir = Join-Path $validationRoot 'cli-logs'

if (Test-Path $cliLogsDir) {
New-Item -ItemType Directory -Path $validationRoot -Force | Out-Null
Copy-Item -Path $cliLogsDir -Destination $sharedLogsDir -Recurse -Force
}

- name: Upload starter validation diagnostics
if: always()
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: cli-starter-validation-windows
path: ${{ runner.temp }}/aspire-cli-starter-validation
if-no-files-found: ignore

extension_tests_win:
name: Run VS Code extension tests (Windows)
runs-on: windows-latest
Expand Down Expand Up @@ -219,7 +463,7 @@ jobs:
uses: ./.github/workflows/typescript-sdk-tests.yml

results:
if: ${{ always() && github.repository_owner == 'dotnet' }}
if: ${{ always() && github.repository_owner == 'microsoft' }}
runs-on: ubuntu-latest
name: Final Test Results
needs: [
Expand All @@ -229,6 +473,7 @@ jobs:
build_cli_archive_windows,
build_cli_archive_macos,
extension_tests_win,
cli_starter_validation_win,
typescript_sdk_tests,
tests_no_nugets,
tests_no_nugets_overflow,
Expand Down Expand Up @@ -294,11 +539,12 @@ jobs:
contains(needs.*.result, 'cancelled') ||
(github.event_name == 'pull_request' &&
(needs.extension_tests_win.result == 'skipped' ||
needs.typescript_sdk_tests.result == 'skipped' ||
needs.tests_no_nugets.result == 'skipped' ||
needs.tests_requires_nugets_linux.result == 'skipped' ||
needs.tests_requires_nugets_windows.result == 'skipped' ||
(fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_macos).include[0] != null &&
needs.cli_starter_validation_win.result == 'skipped' ||
needs.typescript_sdk_tests.result == 'skipped' ||
needs.tests_no_nugets.result == 'skipped' ||
needs.tests_requires_nugets_linux.result == 'skipped' ||
needs.tests_requires_nugets_windows.result == 'skipped' ||
(fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_macos).include[0] != null &&
needs.tests_requires_nugets_macos.result == 'skipped') ||
needs.tests_requires_cli_archive.result == 'skipped' ||
needs.polyglot_validation.result == 'skipped')) ||
Expand Down
28 changes: 27 additions & 1 deletion src/Aspire.Cli/Certificates/CertificateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Aspire.Cli.Interaction;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Microsoft.AspNetCore.Certificates.Generation;

namespace Aspire.Cli.Certificates;
Expand All @@ -31,9 +32,12 @@ internal interface ICertificateService
internal sealed class CertificateService(
ICertificateToolRunner certificateToolRunner,
IInteractionService interactionService,
AspireCliTelemetry telemetry) : ICertificateService
AspireCliTelemetry telemetry,
ICliHostEnvironment hostEnvironment,
Comment thread
joperezr marked this conversation as resolved.
Func<bool>? isWindows = null) : ICertificateService
{
private const string SslCertDirEnvVar = "SSL_CERT_DIR";
private readonly Func<bool> _isWindows = isWindows ?? OperatingSystem.IsWindows;

public async Task<EnsureCertificatesTrustedResult> EnsureCertificatesTrustedAsync(CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -74,6 +78,24 @@ private async Task HandleMachineReadableTrustAsync(
// If not trusted at all, run the trust operation
if (trustResult.IsNotTrusted)
{
if (_isWindows() && !hostEnvironment.SupportsInteractiveInput)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While I understand the motivation here, I can't help but feel that this is probably kind of custom. Also, IIUC this would make it where it would be impossible to turrn this on in GitHub actions, Azure pipelines, jenkins etc, which while it is probably a good default, it means that if someone for whatever reason does want to prompt for trust it is not possible.

Totally ok to push back, but what do you think about having an env variable that we check instead that only controls this particular behavior, so something like: ASPIRE_SKIP_CERTIFICATE_TRUST or something along those lines? Again, totally ok if you think this is better, just curious to hear your thoughts.

{
if (!trustResult.HasCertificates)
{
var ensureResultCode = await interactionService.ShowStatusAsync(
InteractionServiceStrings.CheckingCertificates,
() => Task.FromResult(certificateToolRunner.EnsureHttpCertificateExists()),
emoji: KnownEmojis.LockedWithKey);

if (!IsSuccessfulEnsureResult(ensureResultCode))
{
interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.CertificatesMayNotBeFullyTrusted, ensureResultCode));
}
}

return;
}

var trustResultCode = await interactionService.ShowStatusAsync(
InteractionServiceStrings.TrustingCertificates,
() => Task.FromResult(certificateToolRunner.TrustHttpCertificate()),
Expand All @@ -99,6 +121,10 @@ private async Task HandleMachineReadableTrustAsync(
}
}

private static bool IsSuccessfulEnsureResult(EnsureCertificateResult result) =>
result is EnsureCertificateResult.Succeeded
or EnsureCertificateResult.ValidCertificatePresent;

private static void ConfigureSslCertDir(Dictionary<string, string> environmentVariables)
{
// Get the dev-certs trust path (respects DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY override)
Expand Down
5 changes: 5 additions & 0 deletions src/Aspire.Cli/Certificates/ICertificateToolRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ internal interface ICertificateToolRunner
/// </summary>
CertificateTrustResult CheckHttpCertificate();

/// <summary>
/// Ensures the HTTPS development certificate exists without trusting it.
/// </summary>
EnsureCertificateResult EnsureHttpCertificateExists();

/// <summary>
/// Trusts the HTTPS development certificate, creating one if necessary.
/// </summary>
Expand Down
10 changes: 10 additions & 0 deletions src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ public EnsureCertificateResult TrustHttpCertificate()
trust: true);
}

public EnsureCertificateResult EnsureHttpCertificateExists()
{
var now = DateTimeOffset.Now;
return certificateManager.EnsureAspNetCoreHttpsDevelopmentCertificate(
now,
now.Add(TimeSpan.FromDays(365)),
trust: false,
isInteractive: false);
}

/// Win32 ERROR_CANCELLED (0x4C7) encoded as an HRESULT (0x800704C7).
/// Thrown when the user dismisses the Windows certificate-store security dialog.
private const int UserCancelledHResult = unchecked((int)0x800704C7);
Expand Down
Loading
Loading