diff --git a/.github/actions/create-pull-request/action.yml b/.github/actions/create-pull-request/action.yml index c211663474f..d58dd17aeb8 100644 --- a/.github/actions/create-pull-request/action.yml +++ b/.github/actions/create-pull-request/action.yml @@ -29,6 +29,10 @@ inputs: description: 'Set to true if the branch is already pushed remotely (skips commit/push)' required: false default: 'false' + draft: + description: 'Create the pull request as a draft' + required: false + default: 'false' outputs: pull-request-number: description: 'The pull request number' @@ -91,6 +95,7 @@ runs: PR_TITLE: ${{ inputs.title }} PR_BODY: ${{ inputs.body }} LABELS: ${{ inputs.labels }} + DRAFT: ${{ inputs.draft }} run: | # Check if a PR already exists for this branch EXISTING_PR=$(gh pr list --head "$BRANCH" --base "$BASE" --json number,url --jq '.[0] // empty') @@ -133,12 +138,18 @@ runs: trap 'rm -f "$BODY_FILE"' EXIT printf '%s\n' "$PR_BODY" > "$BODY_FILE" + DRAFT_ARGS=() + if [ "$DRAFT" = "true" ]; then + DRAFT_ARGS+=(--draft) + fi + # Create the pull request without eval — all args are properly quoted PR_URL=$(gh pr create \ --title "$PR_TITLE" \ --body-file "$BODY_FILE" \ --base "$BASE" \ --head "$BRANCH" \ + "${DRAFT_ARGS[@]}" \ "${LABEL_ARGS[@]}") rm -f "$BODY_FILE" diff --git a/.github/workflows/update-aspire-skills-bundle.yml b/.github/workflows/update-aspire-skills-bundle.yml new file mode 100644 index 00000000000..ac28ca9dad8 --- /dev/null +++ b/.github/workflows/update-aspire-skills-bundle.yml @@ -0,0 +1,77 @@ +name: Update Aspire Skills Bundle + +on: + workflow_dispatch: + inputs: + version: + description: "Aspire skills release version to embed. Defaults to the currently pinned version." + required: false + type: string + schedule: + - cron: '0 17 * * *' # 9am PT / 17:00 UTC + +permissions: + contents: write + pull-requests: write + +jobs: + update-and-pr: + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'microsoft' }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Generate GitHub App Token for bundle update + id: update-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + app-id: ${{ secrets.ASPIRE_BOT_APP_ID }} + private-key: ${{ secrets.ASPIRE_BOT_PRIVATE_KEY }} + + - name: Update embedded Aspire skills bundle + shell: pwsh + env: + GH_TOKEN: ${{ steps.update-token.outputs.token }} + VERSION: ${{ inputs.version }} + run: | + if ([string]::IsNullOrWhiteSpace($env:VERSION)) { + ./eng/scripts/update-aspire-skills-bundle.ps1 + } + else { + ./eng/scripts/update-aspire-skills-bundle.ps1 -Version $env:VERSION + } + + - name: Restore solution + run: ./restore.sh + + - name: Test Aspire skills installer + run: > + dotnet test --project tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj + --no-launch-profile + -- + --filter-class "*.AspireSkillsInstallerTests" + --filter-class "*.AspireSkillsBundleTests" + --filter-class "*.AgentInitCommandTests" + --filter-not-trait "quarantined=true" + --filter-not-trait "outerloop=true" + + - name: Generate GitHub App Token for pull request + id: pr-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + app-id: ${{ secrets.ASPIRE_BOT_APP_ID }} + private-key: ${{ secrets.ASPIRE_BOT_PRIVATE_KEY }} + + - name: Create or update pull request + uses: ./.github/actions/create-pull-request + with: + token: ${{ steps.pr-token.outputs.token }} + branch: update-aspire-skills-bundle + base: main + draft: true + commit-message: "[Automated] Update Aspire skills bundle" + labels: | + area-cli + area-engineering-systems + title: "[Automated] Update Aspire skills bundle" + body: "Auto-generated update to refresh the embedded Aspire skills bundle fallback used by the Aspire CLI." diff --git a/.github/workflows/verify-aspire-skills-bundle.yml b/.github/workflows/verify-aspire-skills-bundle.yml new file mode 100644 index 00000000000..db82170a3dc --- /dev/null +++ b/.github/workflows/verify-aspire-skills-bundle.yml @@ -0,0 +1,26 @@ +name: Verify Aspire Skills Bundle + +on: + workflow_dispatch: + pull_request: + paths: + - 'src/Aspire.Cli/Agents/AspireSkills/Embedded/**' + - 'eng/scripts/verify-aspire-skills-bundle.ps1' + - '.github/workflows/verify-aspire-skills-bundle.yml' + +permissions: + contents: read + attestations: read + +jobs: + verify: + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'microsoft' }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Verify embedded Aspire skills bundle attestation + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./eng/scripts/verify-aspire-skills-bundle.ps1 diff --git a/eng/scripts/update-aspire-skills-bundle.ps1 b/eng/scripts/update-aspire-skills-bundle.ps1 new file mode 100644 index 00000000000..94c2212c641 --- /dev/null +++ b/eng/scripts/update-aspire-skills-bundle.ps1 @@ -0,0 +1,164 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [string]$Version, + [string]$Repository = 'microsoft/aspire-skills' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $true + +$scriptDir = $PSScriptRoot +$repoRoot = (Resolve-Path (Join-Path $scriptDir '..\..')).Path +$embeddedDir = Join-Path $repoRoot 'src\Aspire.Cli\Agents\AspireSkills\Embedded' +$metadataPath = Join-Path $embeddedDir 'aspire-skills.metadata.json' +$installerPath = Join-Path $repoRoot 'src\Aspire.Cli\Agents\AspireSkills\AspireSkillsInstaller.cs' +$cliProjectPath = Join-Path $repoRoot 'src\Aspire.Cli\Aspire.Cli.csproj' + +function Invoke-GitHubCli { + param( + [Parameter(Mandatory = $true, ValueFromRemainingArguments = $true)] + [string[]]$Arguments + ) + + & gh @Arguments + if ($LASTEXITCODE -ne 0) { + throw "gh $($Arguments -join ' ') failed with exit code $LASTEXITCODE." + } +} + +function Get-UnprefixedVersion([string]$Value) { + if ([string]::IsNullOrWhiteSpace($Value)) { + throw 'A version is required.' + } + + if ($Value.StartsWith('v', [System.StringComparison]::OrdinalIgnoreCase)) { + return $Value.Substring(1) + } + + return $Value +} + +function Get-CurrentEmbeddedVersion { + if (-not (Test-Path $metadataPath)) { + throw "Embedded Aspire skills metadata was not found at '$metadataPath'. Pass -Version to choose the initial version." + } + + $metadata = Get-Content -Raw -Path $metadataPath | ConvertFrom-Json + return Get-UnprefixedVersion $metadata.version +} + +function Set-TextFile { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Content + ) + + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::WriteAllText($Path, $Content.TrimEnd("`r", "`n") + [System.Environment]::NewLine, $utf8NoBom) +} + +function Get-GitHubRelease([string]$NormalizedVersion) { + $tagCandidates = @("v$NormalizedVersion", $NormalizedVersion) | Select-Object -Unique + + foreach ($tag in $tagCandidates) { + try { + $json = Invoke-GitHubCli release view $tag --repo $Repository --json 'tagName,assets' + return $json | ConvertFrom-Json + } + catch { + Write-Host "Release '$tag' was not found in '$Repository'." + } + } + + throw "Could not find an Aspire skills release for version '$NormalizedVersion' in '$Repository'." +} + +function Get-ReleaseAsset($Release, [string]$NormalizedVersion) { + $assetNameCandidates = foreach ($archiveExtension in @('.zip', '.tar.gz', '.tgz')) { + "aspire-skills-v$NormalizedVersion$archiveExtension" + "aspire-skills-$NormalizedVersion$archiveExtension" + } + + foreach ($assetName in $assetNameCandidates) { + $asset = $Release.assets | Where-Object { $_.name -ieq $assetName } | Select-Object -First 1 + if ($null -ne $asset) { + return $asset + } + } + + throw "Release '$($Release.tagName)' does not contain a supported Aspire skills archive asset for version '$NormalizedVersion'." +} + +if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { + throw "The GitHub CLI ('gh') is required to update the embedded Aspire skills bundle." +} + +$normalizedVersion = if ([string]::IsNullOrWhiteSpace($Version)) { + Get-CurrentEmbeddedVersion +} +else { + Get-UnprefixedVersion $Version +} + +New-Item -ItemType Directory -Force -Path $embeddedDir | Out-Null + +Write-Host "Resolving Aspire skills release '$normalizedVersion' from '$Repository'..." +$release = Get-GitHubRelease $normalizedVersion +$asset = Get-ReleaseAsset $release $normalizedVersion + +$tempDir = [System.IO.Directory]::CreateTempSubdirectory('aspire-skills-update-').FullName +try { + Write-Host "Downloading '$($asset.name)' from '$Repository' release '$($release.tagName)'..." + Invoke-GitHubCli release download $release.tagName --repo $Repository --pattern $asset.name --dir $tempDir --clobber + + $archivePath = Join-Path $tempDir $asset.name + if (-not (Test-Path $archivePath)) { + throw "Expected downloaded asset '$archivePath' was not found." + } + + $certIdentity = "https://github.com/$Repository/.github/workflows/publish.yml@refs/tags/$($release.tagName)" + Write-Host "Verifying GitHub artifact attestation for '$($asset.name)'..." + Invoke-GitHubCli attestation verify $archivePath --repo $Repository --cert-identity $certIdentity --cert-oidc-issuer 'https://token.actions.githubusercontent.com' + + $hash = (Get-FileHash -Algorithm SHA256 $archivePath).Hash.ToLowerInvariant() + $targetArchivePath = Join-Path $embeddedDir $asset.name + + Get-ChildItem -Path $embeddedDir -File -Force | + Where-Object { $_.Name -match '^aspire-skills-.*\.(zip|tar\.gz|tgz)$' -and $_.Name -ne $asset.name } | + Remove-Item -Force + + Copy-Item -Path $archivePath -Destination $targetArchivePath -Force + + $metadata = [ordered]@{ + version = $normalizedVersion + repository = $Repository + tag = $release.tagName + assetName = $asset.name + sha256 = $hash + } + Set-TextFile -Path $metadataPath -Content ($metadata | ConvertTo-Json) + + $installerContent = Get-Content -Raw -Path $installerPath + $installerContent = [regex]::Replace( + $installerContent, + 'internal const string Version = "[^"]+";', + "internal const string Version = ""$normalizedVersion"";") + Set-TextFile -Path $installerPath -Content $installerContent + + $cliProjectContent = Get-Content -Raw -Path $cliProjectPath + $cliProjectContent = [regex]::Replace( + $cliProjectContent, + 'Agents\\AspireSkills\\Embedded\\aspire-skills-[^"]+\.(zip|tar\.gz|tgz)', + "Agents\AspireSkills\Embedded\$($asset.name)") + Set-TextFile -Path $cliProjectPath -Content $cliProjectContent + + Write-Host "Embedded Aspire skills bundle updated to '$($asset.name)' with SHA-256 '$hash'." +} +finally { + if (Test-Path $tempDir) { + Remove-Item -Path $tempDir -Recurse -Force + } +} diff --git a/eng/scripts/verify-aspire-skills-bundle.ps1 b/eng/scripts/verify-aspire-skills-bundle.ps1 new file mode 100644 index 00000000000..550a5e34b03 --- /dev/null +++ b/eng/scripts/verify-aspire-skills-bundle.ps1 @@ -0,0 +1,63 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [string]$Repository = 'microsoft/aspire-skills' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $true + +$scriptDir = $PSScriptRoot +$repoRoot = (Resolve-Path (Join-Path $scriptDir '..\..')).Path +$embeddedDir = Join-Path $repoRoot 'src\Aspire.Cli\Agents\AspireSkills\Embedded' +$metadataPath = Join-Path $embeddedDir 'aspire-skills.metadata.json' + +if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { + throw "The GitHub CLI ('gh') is required to verify the embedded Aspire skills bundle." +} + +if (-not (Test-Path $metadataPath)) { + throw "Embedded Aspire skills metadata was not found at '$metadataPath'." +} + +$metadata = Get-Content -Raw -Path $metadataPath | ConvertFrom-Json + +if ($metadata.repository -ne $Repository) { + throw "Unexpected embedded bundle repository '$($metadata.repository)'. Expected '$Repository'." +} + +if ([string]::IsNullOrWhiteSpace($metadata.tag)) { + throw "Embedded Aspire skills metadata must specify a GitHub release tag." +} + +if ([string]::IsNullOrWhiteSpace($metadata.assetName)) { + throw "Embedded Aspire skills metadata must specify a release asset name." +} + +if ($metadata.assetName -ne [System.IO.Path]::GetFileName($metadata.assetName)) { + throw "Embedded Aspire skills asset name '$($metadata.assetName)' must not contain path separators." +} + +if ([string]::IsNullOrWhiteSpace($metadata.sha256)) { + throw "Embedded Aspire skills metadata must specify the release asset SHA-256 hash." +} + +$archivePath = Join-Path $embeddedDir $metadata.assetName +if (-not (Test-Path $archivePath)) { + throw "Embedded Aspire skills archive was not found at '$archivePath'." +} + +$actualHash = (Get-FileHash -Algorithm SHA256 $archivePath).Hash.ToLowerInvariant() +if ($actualHash -ne $metadata.sha256) { + throw "Embedded bundle SHA-256 mismatch. Expected '$($metadata.sha256)', got '$actualHash'." +} + +$certIdentity = "https://github.com/$($metadata.repository)/.github/workflows/publish.yml@refs/tags/$($metadata.tag)" +gh attestation verify $archivePath ` + --repo $metadata.repository ` + --cert-identity $certIdentity ` + --cert-oidc-issuer 'https://token.actions.githubusercontent.com' + +Write-Host "Embedded Aspire skills bundle '$($metadata.assetName)' verified against GitHub artifact attestation." diff --git a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs index dc47c324cce..49c726ea960 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs @@ -302,7 +302,7 @@ internal static string NormalizeRelativePath(string? relativePath) return Path.Combine(segments); } - private static string NormalizeSha256(string sha256) + internal static string NormalizeSha256(string sha256) { const string prefix = "sha256-"; return sha256.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) diff --git a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs index dbc2f1cfdd5..dec2cca8e41 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs @@ -6,6 +6,7 @@ using System.Globalization; using System.IO.Compression; using System.Net; +using System.Security.Cryptography; using System.Text.Json; using Aspire.Cli.Interaction; using Aspire.Cli.Resources; @@ -21,6 +22,7 @@ namespace Aspire.Cli.Agents.AspireSkills; internal sealed class AspireSkillsInstaller( IGitHubArtifactAttestationVerifier githubArtifactAttestationVerifier, IHttpClientFactory httpClientFactory, + IEmbeddedAspireSkillsBundleProvider embeddedBundleProvider, IInteractionService interactionService, CliExecutionContext executionContext, IConfiguration configuration, @@ -46,7 +48,6 @@ public Task InstallAsync(CancellationToken cancellati AgentCommandStrings.AspireSkillsInstaller_InstallingStatus, () => InstallCoreAsync(cancellationToken)); } - private async Task InstallCoreAsync(CancellationToken cancellationToken) { using var activity = telemetry.StartReportedActivity("AspireSkillsInstaller.Install"); @@ -80,12 +81,24 @@ private async Task InstallCoreAsync(CancellationToken if (githubResult.Status == AcquisitionStatus.Failed) { - activity?.SetStatus(ActivityStatusCode.Error, githubResult.Message); - return AspireSkillsInstallResult.Failed(githubResult.Message ?? AgentCommandStrings.AspireSkillsInstaller_InvalidBundle); + logger.LogDebug("Aspire skills GitHub acquisition failed for version {Version}; falling back to embedded snapshot. Failure: {Failure}", effectiveVersion, githubResult.Message); + } + + var embeddedResult = await InstallFromEmbeddedAsync(cacheRoot, effectiveVersion, activity, cancellationToken).ConfigureAwait(false); + if (embeddedResult.Status == AcquisitionStatus.Installed) + { + CleanupStaleCacheEntries(cacheRoot, effectiveVersion); + return AspireSkillsInstallResult.Installed(embeddedResult.Bundle!); } - activity?.SetStatus(ActivityStatusCode.Error, "GitHub acquisition is unavailable."); - return AspireSkillsInstallResult.Failed(AgentCommandStrings.AspireSkillsInstaller_GitHubUnavailable); + var failureMessage = embeddedResult.Status == AcquisitionStatus.Failed + ? embeddedResult.Message ?? AgentCommandStrings.AspireSkillsInstaller_GitHubUnavailable + : githubResult.Status == AcquisitionStatus.Failed + ? githubResult.Message ?? AgentCommandStrings.AspireSkillsInstaller_GitHubUnavailable + : AgentCommandStrings.AspireSkillsInstaller_GitHubUnavailable; + + activity?.SetStatus(ActivityStatusCode.Error, failureMessage); + return AspireSkillsInstallResult.Failed(failureMessage); } private async Task InstallFromGitHubAsync( @@ -167,6 +180,127 @@ private async Task InstallFromGitHubAsync( } } + private async Task InstallFromEmbeddedAsync( + string cacheRoot, + string version, + Activity? activity, + CancellationToken cancellationToken) + { + var metadata = embeddedBundleProvider.Metadata; + if (metadata is null) + { + logger.LogDebug("No embedded Aspire skills bundle metadata is available."); + return AcquisitionResult.Unavailable(); + } + + if (ValidateEmbeddedMetadata(metadata) is { } metadataError) + { + return AcquisitionResult.Failed($"Embedded Aspire skills bundle metadata is invalid: {metadataError}"); + } + + if (!string.Equals(metadata.Version, version, StringComparison.OrdinalIgnoreCase)) + { + logger.LogDebug( + "Embedded Aspire skills bundle version {EmbeddedVersion} does not match requested version {Version}.", + metadata.Version, + version); + return AcquisitionResult.Unavailable(); + } + + var tempDir = Path.Combine(cacheRoot, $".embedded-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + + try + { + var archivePath = Path.Combine(tempDir, GetSafeFileName(metadata.AssetName!)); + var archiveStream = embeddedBundleProvider.OpenArchive(); + if (archiveStream is null) + { + logger.LogDebug("Embedded Aspire skills archive is unavailable for version {Version}.", version); + return AcquisitionResult.Unavailable(); + } + + await using (archiveStream) + { + await using var fileStream = File.Create(archivePath); + await archiveStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); + } + + ValidateArchiveSha256(archivePath, metadata.Sha256!); + + try + { + var bundle = await CacheArchiveAsync(cacheRoot, archivePath, version, cancellationToken).ConfigureAwait(false); + activity?.SetTag("aspire.skills.source", "embedded"); + activity?.SetTag("aspire.skills.cache_hit", false); + return AcquisitionResult.Installed(bundle); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException) + { + logger.LogWarning(ex, "Embedded Aspire skills bundle {AssetName} is invalid.", metadata.AssetName); + return AcquisitionResult.Failed(string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.AspireSkillsInstaller_InvalidBundle, ex.Message)); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + logger.LogDebug(ex, "Embedded Aspire skills bundle could not be staged for version {Version}.", version); + return AcquisitionResult.Failed(string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.AspireSkillsInstaller_InvalidBundle, ex.Message)); + } + finally + { + TryDeleteDirectory(tempDir); + } + } + + private static string? ValidateEmbeddedMetadata(EmbeddedAspireSkillsBundleMetadata metadata) + { + if (string.IsNullOrWhiteSpace(metadata.Version)) + { + return "Embedded Aspire skills metadata must specify a version."; + } + + if (!string.Equals(metadata.Repository, GitHubRepository, StringComparison.OrdinalIgnoreCase)) + { + return string.Format(CultureInfo.InvariantCulture, "Embedded Aspire skills metadata repository '{0}' does not match expected repository '{1}'.", metadata.Repository, GitHubRepository); + } + + if (string.IsNullOrWhiteSpace(metadata.Tag)) + { + return "Embedded Aspire skills metadata must specify a GitHub release tag."; + } + + if (string.IsNullOrWhiteSpace(metadata.AssetName)) + { + return "Embedded Aspire skills metadata must specify a release asset name."; + } + + if (string.IsNullOrWhiteSpace(metadata.Sha256)) + { + return "Embedded Aspire skills metadata must specify the release asset SHA-256 hash."; + } + + return null; + } + + private static void ValidateArchiveSha256(string archivePath, string expectedSha256) + { + var expectedHash = AspireSkillsBundle.NormalizeSha256(expectedSha256); + string actualHash; + using (var stream = File.OpenRead(archivePath)) + { + actualHash = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + } + + if (!string.Equals(expectedHash, actualHash, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException(string.Format( + CultureInfo.InvariantCulture, + "Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.", + expectedHash, + actualHash)); + } + } + private async Task TryGetGitHubReleaseAsync(HttpClient httpClient, string version, CancellationToken cancellationToken) { foreach (var tag in GetGitHubTagCandidates(version)) diff --git a/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.1.tgz b/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.1.tgz new file mode 100644 index 00000000000..e25af3228df Binary files /dev/null and b/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.1.tgz differ diff --git a/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json b/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json new file mode 100644 index 00000000000..4b76e028392 --- /dev/null +++ b/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json @@ -0,0 +1,7 @@ +{ + "version": "0.0.1", + "repository": "microsoft/aspire-skills", + "tag": "v0.0.1", + "assetName": "aspire-skills-v0.0.1.tgz", + "sha256": "8f0aa535917bb6d2589acbf8f986c7b0d622ee7744e39c526bb7166c0664b53c" +} diff --git a/src/Aspire.Cli/Agents/AspireSkills/EmbeddedAspireSkillsBundleProvider.cs b/src/Aspire.Cli/Agents/AspireSkills/EmbeddedAspireSkillsBundleProvider.cs new file mode 100644 index 00000000000..3b7d70cadd1 --- /dev/null +++ b/src/Aspire.Cli/Agents/AspireSkills/EmbeddedAspireSkillsBundleProvider.cs @@ -0,0 +1,80 @@ +// 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 Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Agents.AspireSkills; + +/// +/// Provides access to the Aspire skills bundle snapshot embedded in the CLI assembly. +/// +internal interface IEmbeddedAspireSkillsBundleProvider +{ + /// + /// Gets metadata for the embedded Aspire skills bundle snapshot. + /// + EmbeddedAspireSkillsBundleMetadata? Metadata { get; } + + /// + /// Opens the embedded Aspire skills bundle archive. + /// + Stream? OpenArchive(); +} + +internal sealed class EmbeddedAspireSkillsBundleProvider : IEmbeddedAspireSkillsBundleProvider +{ + private const string ArchiveResourceName = "aspire-skills.bundle.tgz"; + private const string MetadataResourceName = "aspire-skills.metadata.json"; + + private readonly ILogger _logger; + private readonly Lazy _metadata; + + public EmbeddedAspireSkillsBundleProvider(ILogger logger) + { + _logger = logger; + _metadata = new Lazy(LoadMetadata); + } + + public EmbeddedAspireSkillsBundleMetadata? Metadata => _metadata.Value; + + public Stream? OpenArchive() + { + var stream = typeof(EmbeddedAspireSkillsBundleProvider).Assembly.GetManifestResourceStream(ArchiveResourceName); + if (stream is null) + { + _logger.LogDebug("Embedded Aspire skills archive resource {ResourceName} was not found.", ArchiveResourceName); + } + + return stream; + } + + private EmbeddedAspireSkillsBundleMetadata? LoadMetadata() + { + using var stream = typeof(EmbeddedAspireSkillsBundleProvider).Assembly.GetManifestResourceStream(MetadataResourceName); + if (stream is null) + { + _logger.LogDebug("Embedded Aspire skills metadata resource {ResourceName} was not found.", MetadataResourceName); + return null; + } + + try + { + var metadata = JsonSerializer.Deserialize( + stream, + AspireSkillsJsonSerializerContext.Default.EmbeddedAspireSkillsBundleMetadata); + + if (metadata is null) + { + _logger.LogDebug("Embedded Aspire skills metadata resource {ResourceName} was empty.", MetadataResourceName); + } + + return metadata; + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Embedded Aspire skills metadata resource {ResourceName} could not be parsed.", MetadataResourceName); + return null; + } + } +} diff --git a/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs b/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs index a41777944c1..5e48ed3f573 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs @@ -56,6 +56,22 @@ internal sealed class SkillBundleFile public string? Sha256 { get; init; } } +/// +/// Describes the Aspire skills bundle archive embedded in the CLI. +/// +internal sealed class EmbeddedAspireSkillsBundleMetadata +{ + public string? Version { get; init; } + + public string? Repository { get; init; } + + public string? Tag { get; init; } + + public string? AssetName { get; init; } + + public string? Sha256 { get; init; } +} + /// /// Source-generation context for Aspire skills bundle JSON. /// @@ -65,6 +81,7 @@ internal sealed class SkillBundleFile PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true)] [JsonSerializable(typeof(SkillBundleManifest))] +[JsonSerializable(typeof(EmbeddedAspireSkillsBundleMetadata))] internal sealed partial class AspireSkillsJsonSerializerContext : JsonSerializerContext { } diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 096c95408cf..afd4bd73f5e 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -297,6 +297,12 @@ + + false + + + false + diff --git a/src/Aspire.Cli/Commands/AgentInitCommand.cs b/src/Aspire.Cli/Commands/AgentInitCommand.cs index 5788f880841..02c50da172c 100644 --- a/src/Aspire.Cli/Commands/AgentInitCommand.cs +++ b/src/Aspire.Cli/Commands/AgentInitCommand.cs @@ -124,7 +124,7 @@ internal async Task PromptAndChainAsync( if (runAgentInit) { - return await ExecuteAgentInitAsync(workspaceRoot, parseResult: null, AgentInitErrorMode.BestEffort, cancellationToken); + return await ExecuteAgentInitAsync(workspaceRoot, parseResult: null, cancellationToken); } return new(CliExitCodes.Success, [], []); @@ -133,7 +133,7 @@ internal async Task PromptAndChainAsync( protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { var workspaceRoot = await PromptForWorkspaceRootAsync(parseResult, cancellationToken); - var result = await ExecuteAgentInitAsync(workspaceRoot, parseResult, AgentInitErrorMode.Strict, cancellationToken); + var result = await ExecuteAgentInitAsync(workspaceRoot, parseResult, cancellationToken); return CommandResult.FromExitCode(result.ExitCode); } @@ -167,7 +167,7 @@ private async Task PromptForWorkspaceRootAsync(ParseResult parseR return new DirectoryInfo(workspaceRootPath); } - private async Task ExecuteAgentInitAsync(DirectoryInfo workspaceRoot, ParseResult? parseResult, AgentInitErrorMode errorMode, CancellationToken cancellationToken) + private async Task ExecuteAgentInitAsync(DirectoryInfo workspaceRoot, ParseResult? parseResult, CancellationToken cancellationToken) { var context = new AgentEnvironmentScanContext { @@ -296,18 +296,10 @@ private async Task ExecuteAgentInitAsync(DirectoryInfo } else { - if (errorMode is AgentInitErrorMode.Strict) - { - _interactionService.DisplayError(result.Message!); - hasErrors = true; - } - else - { - _interactionService.DisplayMessage(KnownEmojis.Warning, result.Message!); - selectedSkills = selectedSkills - .Where(static skill => skill.SourceKind is not SkillSourceKind.AspireSkillsBundle) - .ToList(); - } + _interactionService.DisplayMessage(KnownEmojis.Warning, result.Message!); + selectedSkills = selectedSkills + .Where(static skill => skill.SourceKind is not SkillSourceKind.AspireSkillsBundle) + .ToList(); } } @@ -555,12 +547,6 @@ private static async Task> GetSkillFilesAsync(Skil throw new InvalidOperationException($"Skill '{skill.Name}' does not define installable files."); } - private enum AgentInitErrorMode - { - Strict, - BestEffort - } - private sealed record InstalledSkillSummaryItem(string SkillName, string DisplayLocation); private readonly record struct SkillInstallResult(bool Succeeded, InstalledSkillSummaryItem? UpdatedSkill); diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index dc8df361491..8d6cc53c443 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -464,6 +464,7 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu builder.Services.AddSingleton(); builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs index 21fe07396eb..6133c22ac5e 100644 --- a/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs @@ -376,7 +376,7 @@ internal static string AspireSkillsInstaller_InstallingStatus { } /// - /// Looks up a localized string similar to Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available.. + /// Looks up a localized string similar to Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available.. /// internal static string AspireSkillsInstaller_GitHubUnavailable { get { diff --git a/src/Aspire.Cli/Resources/AgentCommandStrings.resx b/src/Aspire.Cli/Resources/AgentCommandStrings.resx index 58200adddbc..2364ff91d8d 100644 --- a/src/Aspire.Cli/Resources/AgentCommandStrings.resx +++ b/src/Aspire.Cli/Resources/AgentCommandStrings.resx @@ -166,7 +166,7 @@ Installing Aspire skills... - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. The Aspire skills bundle is invalid: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf index 9d0d3f73197..7139b9c497a 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf index 18040330c2d..a09a22f0b5d 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf index 726777809ba..416a73644e6 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf index ec7eb479f9f..38117b59c88 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf index 2443bf1c751..670db6c9e77 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf index 92aa8ecd3d8..297b0f9eb54 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf index d95b6611cb4..05a8359420d 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf index 40716c99436..d87eccc448a 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf index 6885e7e33cf..66b4e8820f1 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf index 8ac8e6a9d96..76dc2665fa2 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf index 02725975345..65b85538393 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf index 799665a0009..f4c08201732 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf index 2b6bf955c01..62de972aa38 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. diff --git a/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs b/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs index f17750521e0..3b418f7cc99 100644 --- a/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs @@ -31,12 +31,14 @@ public async Task InstallAsync_WhenValidBundleIsCached_UsesCacheWithoutNetwork() var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); var cachedBundleDirectory = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills", AspireSkillsInstaller.Version); await CreateCachedBundleAsync(cachedBundleDirectory); - var installer = CreateInstaller(executionContext); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var installer = CreateInstaller(executionContext, embeddedBundleProvider: embeddedBundleProvider); var result = await installer.InstallAsync(CancellationToken.None); Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); Assert.NotNull(result.Bundle); + Assert.False(embeddedBundleProvider.OpenArchiveCalled); } finally { @@ -89,6 +91,47 @@ public async Task InstallAsync_WhenGitHubReleaseIsUnavailableAndNoCache_ReturnsF } } + [Fact] + public async Task InstallAsync_WhenGitHubReleaseIsUnavailableAndEmbeddedBundleMatches_UsesEmbeddedBundle() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var installer = CreateInstaller(executionContext, embeddedBundleProvider: embeddedBundleProvider); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.True(embeddedBundleProvider.OpenArchiveCalled); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public void EmbeddedAspireSkillsBundleProvider_OpensSnapshotResource() + { + var provider = new EmbeddedAspireSkillsBundleProvider(NullLogger.Instance); + + var metadata = Assert.IsType(provider.Metadata); + using var archiveStream = Assert.IsAssignableFrom(provider.OpenArchive()); + + Assert.Equal(AspireSkillsInstaller.Version, metadata.Version); + Assert.Equal(AspireSkillsInstaller.GitHubRepository, metadata.Repository); + Assert.Equal(metadata.Sha256, ComputeSha256(archiveStream)); + } + + private static string ComputeSha256(Stream stream) + { + return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + } + [Fact] public async Task InstallAsync_WhenGitHubReleaseAssetIsAvailable_UsesGitHub() { @@ -115,7 +158,12 @@ public async Task InstallAsync_WhenGitHubReleaseAssetIsAvailable_UsesGitHub() }); var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); var attestationVerifier = new TestGitHubArtifactAttestationVerifier(); - var installer = CreateInstaller(executionContext, httpMessageHandler: handler, githubArtifactAttestationVerifier: attestationVerifier); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var installer = CreateInstaller( + executionContext, + httpMessageHandler: handler, + githubArtifactAttestationVerifier: attestationVerifier, + embeddedBundleProvider: embeddedBundleProvider); var result = await installer.InstallAsync(CancellationToken.None); @@ -127,6 +175,7 @@ public async Task InstallAsync_WhenGitHubReleaseAssetIsAvailable_UsesGitHub() Assert.Equal(AspireSkillsInstaller.ExpectedWorkflowPath, attestationVerifier.ExpectedWorkflowPath); Assert.Equal(GitHubReleaseAssetBuildType, attestationVerifier.ExpectedBuildType); Assert.Equal(AspireSkillsInstaller.Version, attestationVerifier.ExpectedVersion); + Assert.False(embeddedBundleProvider.OpenArchiveCalled); Assert.NotNull(releaseRequestUri); Assert.NotNull(assetRequestUri); Assert.Contains("/microsoft/aspire-skills/releases/tags/v0.0.1", releaseRequestUri.AbsolutePath); @@ -139,7 +188,7 @@ public async Task InstallAsync_WhenGitHubReleaseAssetIsAvailable_UsesGitHub() } [Fact] - public async Task InstallAsync_WhenGitHubAttestationFails_ReturnsFailure() + public async Task InstallAsync_WhenGitHubAttestationFails_FallsBackToEmbeddedBundle() { var rootDirectory = CreateTempDirectory(); @@ -163,13 +212,85 @@ public async Task InstallAsync_WhenGitHubAttestationFails_ReturnsFailure() { Result = new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.WorkflowMismatch } }; - var installer = CreateInstaller(executionContext, httpMessageHandler: handler, githubArtifactAttestationVerifier: attestationVerifier); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var installer = CreateInstaller( + executionContext, + httpMessageHandler: handler, + githubArtifactAttestationVerifier: attestationVerifier, + embeddedBundleProvider: embeddedBundleProvider); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.True(attestationVerifier.VerifyCalled); + Assert.True(embeddedBundleProvider.OpenArchiveCalled); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenVersionOverrideDoesNotMatchEmbeddedBundle_DoesNotUseEmbeddedBundle() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [AspireSkillsInstaller.VersionOverrideKey] = "9.9.9" + }) + .Build(); + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var installer = CreateInstaller( + executionContext, + configuration: configuration, + embeddedBundleProvider: embeddedBundleProvider); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Failed, result.Status); + Assert.False(embeddedBundleProvider.OpenArchiveCalled); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenEmbeddedArchiveHashDoesNotMatch_ReturnsFailure() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + embeddedBundleProvider.Metadata = new EmbeddedAspireSkillsBundleMetadata + { + Version = AspireSkillsInstaller.Version, + Repository = AspireSkillsInstaller.GitHubRepository, + Tag = $"v{AspireSkillsInstaller.Version}", + AssetName = $"aspire-skills-v{AspireSkillsInstaller.Version}.tgz", + Sha256 = "0000000000000000000000000000000000000000000000000000000000000000" + }; + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var installer = CreateInstaller( + executionContext, + embeddedBundleProvider: embeddedBundleProvider); var result = await installer.InstallAsync(CancellationToken.None); Assert.Equal(AspireSkillsInstallStatus.Failed, result.Status); Assert.NotNull(result.Message); - Assert.Contains("Provenance", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("SHA-256", result.Message, StringComparison.Ordinal); + Assert.Contains("0000000000000000000000000000000000000000000000000000000000000000", result.Message, StringComparison.Ordinal); + Assert.True(embeddedBundleProvider.OpenArchiveCalled); } finally { @@ -180,14 +301,17 @@ public async Task InstallAsync_WhenGitHubAttestationFails_ReturnsFailure() private static AspireSkillsInstaller CreateInstaller( CliExecutionContext executionContext, HttpMessageHandler? httpMessageHandler = null, - TestGitHubArtifactAttestationVerifier? githubArtifactAttestationVerifier = null) + TestGitHubArtifactAttestationVerifier? githubArtifactAttestationVerifier = null, + IConfiguration? configuration = null, + IEmbeddedAspireSkillsBundleProvider? embeddedBundleProvider = null) { return new AspireSkillsInstaller( githubArtifactAttestationVerifier ?? new TestGitHubArtifactAttestationVerifier(), new MockHttpClientFactory(httpMessageHandler ?? new MockHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound))), + embeddedBundleProvider ?? new TestEmbeddedAspireSkillsBundleProvider(), new TestInteractionService(), executionContext, - new ConfigurationBuilder().Build(), + configuration ?? new ConfigurationBuilder().Build(), TestTelemetryHelper.CreateInitializedTelemetry(), NullLogger.Instance); } @@ -273,6 +397,28 @@ private static string ComputeSha256(string path) return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); } + private static string ComputeSha256(byte[] bytes) + { + return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + } + + private static async Task CreateEmbeddedBundleProviderAsync() + { + var archiveBytes = await CreateBundleArchiveBytesAsync(); + return new TestEmbeddedAspireSkillsBundleProvider + { + Metadata = new EmbeddedAspireSkillsBundleMetadata + { + Version = AspireSkillsInstaller.Version, + Repository = AspireSkillsInstaller.GitHubRepository, + Tag = $"v{AspireSkillsInstaller.Version}", + AssetName = $"aspire-skills-v{AspireSkillsInstaller.Version}.tgz", + Sha256 = ComputeSha256(archiveBytes) + }, + ArchiveBytes = archiveBytes + }; + } + private static HttpResponseMessage CreateJsonResponse(string json) { return new HttpResponseMessage(HttpStatusCode.OK) @@ -341,4 +487,18 @@ public Task VerifyAsync( } } + private sealed class TestEmbeddedAspireSkillsBundleProvider : IEmbeddedAspireSkillsBundleProvider + { + public EmbeddedAspireSkillsBundleMetadata? Metadata { get; set; } + + public byte[]? ArchiveBytes { get; init; } + + public bool OpenArchiveCalled { get; private set; } + + public Stream? OpenArchive() + { + OpenArchiveCalled = true; + return ArchiveBytes is null ? null : new MemoryStream(ArchiveBytes, writable: false); + } + } } diff --git a/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs index ace28bcd5c4..6bbd66cc744 100644 --- a/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs @@ -269,7 +269,7 @@ public async Task AgentInitCommand_NonInteractive_WithoutWorkspaceRoot_UsesWorki } [Fact] - public async Task AgentInitCommand_NonInteractive_WithUnavailableAspireSkillsBundle_Fails() + public async Task AgentInitCommand_NonInteractive_WithUnavailableAspireSkillsBundle_WarnsAndSucceedsWithoutSelectedAspireSkills() { using var workspace = TemporaryWorkspace.Create(outputHelper); const string installFailureMessage = "Aspire skills bundle is unavailable."; @@ -289,9 +289,12 @@ public async Task AgentInitCommand_NonInteractive_WithUnavailableAspireSkillsBun var exitCode = await result.InvokeAsync().DefaultTimeout(); - Assert.Equal(CliExitCodes.InvalidCommand, exitCode); - Assert.Contains(installFailureMessage, interactionService.DisplayedErrors); - Assert.Empty(interactionService.DisplayedSuccess); + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.DoesNotContain(installFailureMessage, interactionService.DisplayedErrors); + Assert.Contains( + interactionService.DisplayedMessages, + message => message.Emoji.Equals(KnownEmojis.Warning) && message.Message == installFailureMessage); + Assert.Contains(McpCommandStrings.InitCommand_ConfigurationComplete, interactionService.DisplayedSuccess); } [Fact]