diff --git a/.github/skills/pr-testing/SKILL.md b/.github/skills/pr-testing/SKILL.md index 366da92e12a..f9c3902af86 100644 --- a/.github/skills/pr-testing/SKILL.md +++ b/.github/skills/pr-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: pr-testing -description: Downloads and tests Aspire CLI from a PR build, verifies version, and runs test scenarios based on PR changes. Use this when asked to test a pull request. +description: Downloads and tests Aspire CLI from a PR build, preferably in the repo-local container runner under eng/scripts, verifies version, and runs test scenarios based on PR changes. Use this when asked to test a pull request. --- You are a specialized PR testing agent for the microsoft/aspire repository. Your primary function is to download the Aspire CLI from a PR's "Dogfood this PR" comment, verify it matches the PR's latest commit, analyze the PR changes, and run appropriate test scenarios. @@ -49,46 +49,141 @@ The comment typically contains instructions like: ``` Dogfood this PR with: -**Windows (PowerShell):** -irm https://aka.ms/install-aspire-cli.ps1 | iex -aspire config set preview.install.source https://... +curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 16093 -**Linux/macOS:** -curl -sSL https://aka.ms/install-aspire-cli.sh | bash -aspire config set preview.install.source https://... +Or in PowerShell: +iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 16093" ``` -### 3. Download and Install the CLI +### 3. Choose Execution Mode and Install the CLI -Create a temporary working directory and install the CLI: +Before installing the CLI, decide whether the testing should run **locally** or in the repo-local **container runner**. Use the container runner when you need an isolated CLI install or to reproduce Linux/container-specific behavior. Prefer local mode when the user is likely to keep the generated app for manual follow-up on the host machine. + +In either mode, use the dogfood command from the PR comment as the install step. Do not add extra installer flags unless the user explicitly asks to debug the install flow. + +The container runner lives at: + +```text +./eng/scripts/aspire-pr-container/ +``` + +Use the shell that matches the host: + +- **macOS/Linux/WSL:** `run-aspire-pr-container.sh` +- **Windows PowerShell:** `run-aspire-pr-container.ps1` + +#### Local mode + +Create a temporary working directory, point `HOME` at it, and run the bash dogfood command unchanged: + +```bash +testDir="$(mktemp -d -t aspire-pr-test-XXXXXX)" +homeDir="$testDir/home" +mkdir -p "$homeDir" + +HOME="$homeDir" bash -lc 'curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- '"$prNumber" + +cliPath="$homeDir/.aspire/bin/aspire" +hivePath="$homeDir/.aspire/hives/pr-$prNumber/packages" +cliVersion="$("$cliPath" --version)" +``` + +#### Container mode + +Run from the repository root so the repo-local scripts are available. Use a fresh host temp directory as the mounted workspace. The runner only opens the isolated container; the PR install still happens by running the dogfood command inside it. Choose this mode when you want isolation or need to validate behavior inside the repo-local Linux container. + +```bash +testDir="$(mktemp -d -t aspire-pr-test-XXXXXX)" +runner() { + ASPIRE_PR_WORKSPACE="$testDir" ASPIRE_CONTAINER_USER=0:0 \ + ./eng/scripts/aspire-pr-container/run-aspire-pr-container.sh "$@" +} + +runner bash -lc 'curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- '"$prNumber" +``` + +On Windows PowerShell hosts, use the PowerShell runner instead: ```powershell -# Create temp directory for testing -$testDir = Join-Path $env:TEMP "aspire-pr-test-$(Get-Random)" -New-Item -ItemType Directory -Path $testDir -Force -Set-Location $testDir +$testDir = Join-Path $env:TEMP "aspire-pr-test-$([guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Path $testDir -Force | Out-Null + +function runner { + & ./eng/scripts/aspire-pr-container/run-aspire-pr-container.ps1 @args +} + +$env:ASPIRE_PR_WORKSPACE = $testDir +$env:ASPIRE_CONTAINER_USER = "0:0" -# Install CLI using the dogfood instructions -# Follow the platform-specific instructions from the PR comment +runner bash -lc "curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- $prNumber" +``` + +For follow-up commands in the same mounted workspace, run: + +```bash +runner bash -lc '/workspace/.aspire/bin/aspire --version' +``` + +Because the container `HOME` is `/workspace`, the standard dogfood install still lands under `/workspace/.aspire`. The repo-local runner now backs `/workspace/.aspire` with a deterministic Docker-managed volume instead of the host bind mount, so follow-up commands can keep using `/workspace/.aspire/bin/aspire` and `/workspace/.aspire/hives/pr-/packages` without putting the AppHost RPC socket on the Docker Desktop workspace filesystem. + +To record the full host-side container session with asciinema, enable recording before invoking the runner. Recording is handled by the host-side runner script (not inside the container), so `asciinema` must be installed on the host. + +macOS/Linux/WSL example: + +```bash +export ASPIRE_PR_RECORD=1 +export ASPIRE_PR_RECORDING_PATH="$testDir/pr-test.cast" # optional +runner bash -lc 'curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- '"$prNumber" +``` + +Windows PowerShell example: + +```powershell +$env:ASPIRE_PR_RECORD = "1" +$env:ASPIRE_PR_RECORDING_PATH = Join-Path $testDir "pr-test.cast" # optional +runner bash -lc "curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- $prNumber" +``` + +#### Important template note + +When creating new projects from the PR build: + +- Prefer the downloaded PR hive explicitly instead of relying on channel resolution alone. +- In non-interactive runs, pass both `--name` and `--output`. +- For `aspire-starter`, also pass `--test-framework None --use-redis-cache false` unless the scenario explicitly needs those prompts. +- In TTY-attached runs, `aspire new` may ask `Would you like to configure AI agent environments for this project?`; answer explicitly (usually `n`) unless agent-init is part of the scenario. + +Example starter-app automation: + +```bash +projectName="PrSmoke" +appRoot="$testDir/$projectName" + +"$cliPath" new aspire-starter \ + --name "$projectName" \ + --output "$appRoot" \ + --source "$hivePath" \ + --version "$cliVersion" \ + --test-framework None \ + --use-redis-cache false ``` ### 4. Verify CLI Version Matches PR Commit Get the PR's head commit SHA and verify the installed CLI matches: -```powershell +```bash # Get PR head commit SHA -$prInfo = gh pr view $prNumber --repo microsoft/aspire --json headRefOid | ConvertFrom-Json -$expectedCommit = $prInfo.headRefOid +expectedCommit="$(gh pr view "$prNumber" --repo microsoft/aspire --json headRefOid --jq .headRefOid)" -# Get installed CLI version info -aspire --version +# Local mode: use the installed binary directly +"$cliPath" --version -# The version output should contain or reference the commit SHA -# Verify the commit matches +# Container mode: use the installed binary in the mounted workspace +runner bash -lc '/workspace/.aspire/bin/aspire --version' ``` -**Important:** The CLI version must match the PR's latest commit (headRefOid). If it doesn't match, stop and report the version mismatch. +**Important:** The installed binary path must be used for version checks (not bare `aspire`, which may resolve to some other install). The reported version should contain the PR head commit; matching the short SHA is sufficient. ### 5. Analyze PR Changes @@ -169,14 +264,18 @@ Based on analyzing the PR changes, I've identified the following test scenarios: 3. ... ``` -**Then use `ask_user` to get confirmation:** +**Then use `ask_user` to get confirmation and execution target:** + +Call the `ask_user` tool with a form that includes: +- **decision**: enum `["Proceed with these scenarios", "Add more scenarios", "Skip some scenarios", "Cancel testing"]` +- **executionTarget**: enum `["Run in the repo container runner", "Run locally in a temp directory"]` +- **additionalScenarios**: optional string for extra scenarios +- **scenariosToSkip**: optional string listing scenarios to skip -Call the `ask_user` tool with the following parameters: -- **question**: "Would you like me to proceed with these scenarios, or do you have additional scenarios to add?" -- **choices**: ["Proceed with these scenarios", "Add more scenarios", "Skip some scenarios", "Cancel testing"] +Default `executionTarget` based on the goal: choose **Run locally in a temp directory** when the user is likely to continue working with the generated app on the host, and choose **Run in the repo container runner** when isolation or Linux/container reproduction is the priority. If the user declines the form, use the same heuristic. **Handle user responses:** -- **Proceed**: Continue to step 8 (Execute Test Scenarios) +- **Proceed**: Continue to step 8 using the selected execution target - **Add more**: Ask user to describe additional scenarios, add them to the list, then proceed - **Skip some**: Ask which scenarios to skip, remove them, then proceed - **Cancel**: Stop testing and report cancellation @@ -189,24 +288,55 @@ This step ensures the user can: ### 8. Execute Test Scenarios -For each scenario, follow this pattern: +For each scenario, follow this pattern based on the chosen execution target. -```powershell -# Create a new project directory -$scenarioDir = Join-Path $testDir "scenario-$(Get-Random)" -New-Item -ItemType Directory -Path $scenarioDir -Force -Set-Location $scenarioDir +#### Local execution -# Create a new Aspire project -aspire new +```bash +scenarioDir="$testDir/scenario-$(date +%s%N)" +projectName="ScenarioApp" +appRoot="$scenarioDir/$projectName" +appHost="$appRoot/$projectName.AppHost/$projectName.AppHost.csproj" -# [Add any modifications based on the scenario] +mkdir -p "$scenarioDir" -# Run the application -aspire run +"$cliPath" new aspire-empty --name "$projectName" --output "$appRoot" --source "$hivePath" --version "$cliVersion" ... +"$cliPath" start --apphost "$appHost" ... +"$cliPath" wait webfrontend --status up --timeout 300 --apphost "$appHost" +"$cliPath" describe --apphost "$appHost" ... +"$cliPath" resource apiservice restart --apphost "$appHost" ... +"$cliPath" stop --apphost "$appHost" ... +``` -# Capture evidence (screenshots, logs) -# Verify expected behavior +#### Container execution + +Install the PR CLI once by running the bash dogfood command inside the container: + +```bash +runner bash -lc 'curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- '"$prNumber" +``` + +The repo-local runner uses ephemeral `docker run --rm` containers. If you want to preserve the environment for later inspection, keep the mounted `testDir` workspace and reopen it with another `runner ...` command instead of expecting a long-lived container process to still exist. + +Then execute each scenario inside the container with the repo-local runner: + +```bash +runner bash -lc ' + cliPath=/workspace/.aspire/bin/aspire + hivePath=/workspace/.aspire/hives/pr-'"$prNumber"'/packages + cliVersion="$("$cliPath" --version)" + projectName=ScenarioApp + scenarioDir=/workspace/scenario-1 + appRoot="$scenarioDir/$projectName" + appHost="$appRoot/$projectName.AppHost/$projectName.AppHost.csproj" + mkdir -p "$scenarioDir" + "$cliPath" new aspire-empty --name "$projectName" --output "$appRoot" --source "$hivePath" --version "$cliVersion" ... + "$cliPath" start --apphost "$appHost" ... + "$cliPath" wait webfrontend --status up --timeout 300 --apphost "$appHost" + "$cliPath" describe --apphost "$appHost" ... + "$cliPath" resource apiservice restart --apphost "$appHost" ... + "$cliPath" stop --apphost "$appHost" ... +' ``` ### 9. Capture Evidence @@ -225,8 +355,8 @@ For each test scenario, capture: **Commands and Output:** ```powershell -# Capture aspire version -aspire --version | Out-File "$scenarioDir\version.txt" +# Capture installed CLI version +& $cliPath --version | Out-File "$scenarioDir\version.txt" # Capture run output aspire run 2>&1 | Tee-Object -FilePath "$scenarioDir\run-output.txt" @@ -247,7 +377,7 @@ Create a comprehensive report with the following structure: ## CLI Version Verification - **Expected Commit:** abc123... -- **Installed Version:** [output of aspire --version] +- **Installed Version:** [output of the installed PR CLI binary] - **Status:** โœ… Verified / โŒ Mismatch ## Changes Analyzed @@ -333,6 +463,23 @@ The PR does not have a "Dogfood this PR with:" comment. **Recommendation:** Check the PR's CI status and wait for it to complete. ``` +### Bundle extraction or layout validation failure +If a fresh PR install fails with messages like `Bundle extraction failed` or `Bundle was extracted ... but layout validation failed`: + +1. Capture the exact error output and treat it as a CLI install or bundle failure. +2. Stop template-based scenarios and report the failure instead of adding repair steps that a normal user would not perform. +3. Only reach for deeper recovery or debugging steps if the user explicitly asks you to investigate the install or bundle failure itself. + +### Unexpected prompt during automation +If `aspire new` fails with `Failed to read input in non-interactive mode` or `Cannot show selection prompt since the current terminal isn't interactive`: + +1. Ensure the command includes both `--name` and `--output`. +2. For `aspire-starter`, add `--test-framework None --use-redis-cache false` unless the scenario is explicitly testing those options. +3. If the command is running in a TTY-attached session, answer the post-create agent-init prompt explicitly. + +### AppHost selection prompt / no running AppHosts found +If `wait`, `describe`, `resource`, or `stop` prompts to select an AppHost or reports that no running AppHosts were found in the current directory, pass `--apphost ` explicitly to those follow-up commands. + ### Test Scenario Failures Document failures with full context: ```markdown @@ -357,9 +504,45 @@ Document failures with full context: [How this affects users of the PR changes] ``` +### 11. Offer Container Inspection + +If testing ran in the repo container runner, use the `ask_user` tool before cleanup to ask whether the user wants to keep the mounted workspace around for inspection. + +Use a form with: +- **inspectionDecision**: enum `["Keep the container workspace for inspection", "Clean up the container workspace"]` + +Default to **Clean up the container workspace**. + +If the user chooses to keep it: +- Do **not** delete `testDir`. +- Report the workspace path. +- Include the exact command to reopen a shell in a fresh runner container against the same workspace, for example: + +```bash +runner bash +``` + +or, if you are no longer in the same shell context: + +```bash +ASPIRE_PR_WORKSPACE="$testDir" ASPIRE_CONTAINER_USER=0:0 \ + ./eng/scripts/aspire-pr-container/run-aspire-pr-container.sh bash +``` + +On Windows PowerShell hosts, the reopen command is: + +```powershell +$env:ASPIRE_PR_WORKSPACE = $testDir +$env:ASPIRE_CONTAINER_USER = "0:0" +./eng/scripts/aspire-pr-container/run-aspire-pr-container.ps1 bash +``` + +If the user chooses cleanup: +- Remove `testDir` as usual. + ## Cleanup -After testing completes, clean up temporary directories: +After testing completes, clean up temporary directories unless the user explicitly chose to keep the container workspace for inspection: ```powershell # Return to original directory @@ -389,6 +572,7 @@ After completing the task, provide: 1. **Brief Summary** - One-line result (Passed/Failed with key finding) 2. **Full Report** - The detailed markdown report as described above 3. **Artifacts** - List of captured screenshots and logs with their locations +4. **Cleanup / Inspection Status** - Whether the temp workspace was removed or retained for inspection, plus the reopen command when retained Example summary: ```markdown @@ -402,6 +586,7 @@ Dashboard correctly displays the new Redis resource type. ๐Ÿ“‹ **Full Report:** See detailed report below ๐Ÿ“ธ **Screenshots:** 4 captured (dashboard-main.png, redis-resource.png, ...) ๐Ÿ“ **Logs:** 3 captured (run-output.txt, version.txt, ...) +๐Ÿงช **Inspection:** Container workspace cleaned up ``` ## Important Constraints @@ -409,7 +594,14 @@ Dashboard correctly displays the new Redis resource type. - **Always use temp directories** - Never create test projects in the repository - **Verify version first** - Don't proceed with testing if CLI version doesn't match PR commit - **Capture evidence** - Every scenario needs screenshots and/or logs -- **Clean up after** - Remove temp directories when done +- **Clean up after** - Remove temp directories when done, unless the user explicitly asked to keep the container workspace for inspection - **Document everything** - Detailed reports help PR authors understand results - **Test actual changes** - Focus scenarios on what the PR modified - **Fresh projects** - Always use `aspire new` for each scenario, don't reuse projects +- **Container mode** - Prefer the repo-local `./eng/scripts/aspire-pr-container` scripts and a fresh temp workspace when Docker is available +- **Ask before container cleanup** - At the end of a container-mode run, ask whether to keep the mounted workspace around for inspection +- **Bundle failures** - If template-based commands fail with bundle extraction or layout validation errors after install, capture and report the failure instead of adding non-standard repair steps +- **Non-interactive project creation** - Pass both `--name` and `--output`; for `aspire-starter`, also pass `--test-framework None --use-redis-cache false` unless intentionally testing those prompts +- **TTY project creation** - In TTY-attached runs, `aspire new` may ask about configuring AI agent environments; answer explicitly or keep stdin non-interactive +- **Explicit AppHost path** - Prefer `--apphost ` for scripted `wait`, `describe`, `resource`, and `stop` commands +- **PR hive for templates** - Prefer `--source ` and `--version ` when creating projects from the PR build diff --git a/eng/scripts/aspire-pr-container/Dockerfile b/eng/scripts/aspire-pr-container/Dockerfile new file mode 100644 index 00000000000..051a77d02d3 --- /dev/null +++ b/eng/scripts/aspire-pr-container/Dockerfile @@ -0,0 +1,19 @@ +FROM ubuntu:24.04 + +ARG DEBIAN_FRONTEND=noninteractive + +ENV PATH=/workspace/.aspire/bin:$PATH + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + docker.io \ + gh \ + git \ + libicu74 \ + unzip \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace +CMD ["bash"] diff --git a/eng/scripts/aspire-pr-container/run-aspire-pr-container.ps1 b/eng/scripts/aspire-pr-container/run-aspire-pr-container.ps1 new file mode 100644 index 00000000000..62673ec3dd1 --- /dev/null +++ b/eng/scripts/aspire-pr-container/run-aspire-pr-container.ps1 @@ -0,0 +1,319 @@ +[CmdletBinding()] +param( + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$RemainingArgs +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Show-Usage { + @' +Usage: + run-aspire-pr-container.ps1 [command [args...]] + +Environment: + ASPIRE_PR_IMAGE Docker image name to build/run (default: aspire-pr-runner) + ASPIRE_PR_WORKSPACE Host directory to mount as /workspace (default: current directory) + ASPIRE_PR_STATE_VOLUME Docker named volume mounted at /workspace/.aspire + (default: deterministic name derived from the workspace path) + ASPIRE_DOCKER_SOCKET Host Docker socket path to mount into /var/run/docker.sock + (default: /var/run/docker.sock) + ASPIRE_CONTAINER_USER Container user for docker run + Default: 0:0 on Windows, current uid:gid elsewhere when available + ASPIRE_PR_RECORD Set to 1/true to record the full host-side session with asciinema + ASPIRE_PR_RECORDING_PATH Output path for the .cast file + (default: /recordings/-.cast) + ASPIRE_PR_RECORDING_TITLE + Optional title stored in the recording metadata + GH_TOKEN/GITHUB_TOKEN GitHub token passed into the container + +Default command: + bash +'@ | Write-Host +} + +function Test-Truthy { + param([string]$Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return $false + } + + switch ($Value.Trim().ToLowerInvariant()) { + '1' { return $true } + 'true' { return $true } + 'yes' { return $true } + 'on' { return $true } + default { return $false } + } +} + +function Get-RecordingStem { + param([string]$Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return 'session' + } + + $stem = $Value + if ($stem -match '^\d+$') { + $stem = "pr-$stem" + } + + $stem = [System.Text.RegularExpressions.Regex]::Replace($stem.ToLowerInvariant(), '[^a-z0-9._-]+', '-').Trim('-') + if ([string]::IsNullOrWhiteSpace($stem)) { + return 'session' + } + + return $stem +} + +function Test-InteractiveConsole { + try { + return -not [Console]::IsInputRedirected -and -not [Console]::IsOutputRedirected + } + catch { + return $false + } +} + +function Get-CurrentUidGid { + try { + $uid = (& id -u 2>$null).Trim() + $gid = (& id -g 2>$null).Trim() + if ($LASTEXITCODE -eq 0 -and $uid -and $gid) { + return "$uid`:$gid" + } + } + catch { + } + + return $null +} + +function Ensure-GitHubToken { + if ($env:GH_TOKEN) { + return + } + + if ($env:GITHUB_TOKEN) { + $env:GH_TOKEN = $env:GITHUB_TOKEN + return + } + + $gh = Get-Command gh -ErrorAction SilentlyContinue + if ($null -eq $gh) { + Write-Error "GitHub CLI 'gh' is required when GH_TOKEN/GITHUB_TOKEN is not set. Run 'gh auth login' or set GH_TOKEN." + exit 1 + } + + $token = (& $gh.Source auth token 2>$null).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($token)) { + Write-Error "Failed to get a GitHub token from 'gh auth token'. Run 'gh auth login' or set GH_TOKEN/GITHUB_TOKEN." + exit 1 + } + + $env:GH_TOKEN = $token +} + +function Get-StateVolumeName { + param([string]$Workspace) + + if ($env:ASPIRE_PR_STATE_VOLUME) { + return $env:ASPIRE_PR_STATE_VOLUME + } + + try { + $resolvedWorkspace = (Resolve-Path -LiteralPath $Workspace).Path + } + catch { + $resolvedWorkspace = [System.IO.Path]::GetFullPath($Workspace) + } + + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + $hashBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($resolvedWorkspace)) + } + finally { + $sha256.Dispose() + } + + $hash = -join ($hashBytes[0..5] | ForEach-Object { $_.ToString('x2') }) + return "aspire-pr-state-$hash" +} + +function Initialize-StateVolume { + param( + [string]$ImageName, + [string]$StateVolume, + [string]$ContainerUser + ) + + & docker volume create $StateVolume | Out-Null + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + + $initArgs = @( + 'run' + '--rm' + '-u' + '0:0' + '-e' + "TARGET_USER=$ContainerUser" + '-v' + "${StateVolume}:/state" + $ImageName + 'bash' + '-lc' + 'set -euo pipefail; mkdir -p /state; if [ -n "${TARGET_USER:-}" ] && [ "${TARGET_USER}" != "0:0" ] && [ "${TARGET_USER}" != "0" ]; then chown -R "${TARGET_USER}" /state; fi' + ) + + & docker @initArgs + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } +} + +if (-not $RemainingArgs -or $RemainingArgs.Count -lt 1) { + $RemainingArgs = @('bash') +} + +if ($RemainingArgs[0] -in @('-h', '--help')) { + Show-Usage + exit 0 +} + +$scriptDir = Split-Path -Parent $PSCommandPath +$imageName = if ($env:ASPIRE_PR_IMAGE) { $env:ASPIRE_PR_IMAGE } else { 'aspire-pr-runner' } +$workspace = if ($env:ASPIRE_PR_WORKSPACE) { $env:ASPIRE_PR_WORKSPACE } else { (Get-Location).Path } +$stateVolume = Get-StateVolumeName $workspace +$dockerSocketPath = if ($env:ASPIRE_DOCKER_SOCKET) { $env:ASPIRE_DOCKER_SOCKET } else { '/var/run/docker.sock' } +$isWindows = $env:OS -eq 'Windows_NT' + +$containerUser = $env:ASPIRE_CONTAINER_USER +if ([string]::IsNullOrWhiteSpace($containerUser)) { + if ($isWindows) { + $containerUser = '0:0' + } + else { + $containerUser = Get-CurrentUidGid + } +} + +if (-not (Test-Path -LiteralPath $workspace -PathType Container)) { + Write-Error "Workspace directory does not exist: $workspace" + exit 1 +} + +if (-not $env:ASPIRE_PR_RECORDING_ACTIVE -and (Test-Truthy $env:ASPIRE_PR_RECORD)) { + $asciinema = Get-Command asciinema -ErrorAction SilentlyContinue + if ($null -eq $asciinema) { + Write-Error 'asciinema is required when ASPIRE_PR_RECORD is enabled.' + exit 1 + } + + $recordingPath = if ($env:ASPIRE_PR_RECORDING_PATH) { + $env:ASPIRE_PR_RECORDING_PATH + } + else { + $timestamp = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ') + Join-Path $workspace "recordings/$timestamp-$(Get-RecordingStem $RemainingArgs[0]).cast" + } + + $recordingDir = Split-Path -Parent $recordingPath + if (-not [string]::IsNullOrWhiteSpace($recordingDir)) { + New-Item -ItemType Directory -Force -Path $recordingDir | Out-Null + } + + $argLiterals = @( + foreach ($arg in $RemainingArgs) { + "'{0}'" -f ($arg -replace "'", "''") + } + ) + + $encodedCommandText = "& '{0}' @({1}); exit `$LASTEXITCODE" -f ($PSCommandPath -replace "'", "''"), ($argLiterals -join ', ') + $encodedCommandBytes = [System.Text.Encoding]::Unicode.GetBytes($encodedCommandText) + $encodedCommand = [Convert]::ToBase64String($encodedCommandBytes) + $powerShellCommand = if ($PSVersionTable.PSEdition -eq 'Core') { + "pwsh -NoProfile -EncodedCommand $encodedCommand" + } + else { + "powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand $encodedCommand" + } + + $env:ASPIRE_PR_RECORDING_ACTIVE = '1' + Write-Host "Recording session to $recordingPath" + + $recordArgs = @( + 'record' + '--return' + '--command' + $powerShellCommand + ) + + if ($env:ASPIRE_PR_RECORDING_TITLE) { + $recordArgs += @('--title', $env:ASPIRE_PR_RECORDING_TITLE) + } + + $recordArgs += $recordingPath + + & $asciinema.Source @recordArgs + exit $LASTEXITCODE +} + +Ensure-GitHubToken + +$ttyArgs = @() +if (Test-InteractiveConsole) { + $ttyArgs = @('-it') +} + +$runArgs = @( + 'run' + '--rm' + '-e' + 'GH_TOKEN' + '-e' + 'ASPIRE_REPO' + '-e' + 'HOME=/workspace' + '-v' + "${workspace}:/workspace" + '-v' + "${stateVolume}:/workspace/.aspire" + '-w' + '/workspace' +) + +if (-not [string]::IsNullOrWhiteSpace($containerUser)) { + $runArgs += @('-u', $containerUser) +} + +if ($ttyArgs.Count -gt 0) { + $runArgs += $ttyArgs +} + +if (-not [string]::IsNullOrWhiteSpace($dockerSocketPath)) { + if ($dockerSocketPath -eq '/var/run/docker.sock') { + $runArgs += @('-v', '/var/run/docker.sock:/var/run/docker.sock') + } + elseif (Test-Path -LiteralPath $dockerSocketPath) { + $resolvedDockerSocketPath = (Resolve-Path -LiteralPath $dockerSocketPath).Path + $runArgs += @('-v', "${resolvedDockerSocketPath}:/var/run/docker.sock") + } +} + +& docker build -t $imageName $scriptDir +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +Initialize-StateVolume -ImageName $imageName -StateVolume $stateVolume -ContainerUser $containerUser + +$runArgs += $imageName +$runArgs += $RemainingArgs + +& docker @runArgs +exit $LASTEXITCODE diff --git a/eng/scripts/aspire-pr-container/run-aspire-pr-container.sh b/eng/scripts/aspire-pr-container/run-aspire-pr-container.sh new file mode 100755 index 00000000000..d1d1b4dd209 --- /dev/null +++ b/eng/scripts/aspire-pr-container/run-aspire-pr-container.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + run-aspire-pr-container.sh [command [args...]] + +Environment: + ASPIRE_PR_IMAGE Docker image name to build/run (default: aspire-pr-runner) + ASPIRE_PR_WORKSPACE Host directory to mount as /workspace (default: current directory) + ASPIRE_PR_STATE_VOLUME Docker named volume mounted at /workspace/.aspire + (default: deterministic name derived from the workspace path) + ASPIRE_DOCKER_SOCKET Docker socket path on the host (default: /var/run/docker.sock) + ASPIRE_CONTAINER_USER Container user for docker run (default: current uid:gid) + Set to 0:0 when the container needs direct Docker socket access. + ASPIRE_PR_RECORD Set to 1/true to record the full host-side session with asciinema + ASPIRE_PR_RECORDING_PATH + Output path for the .cast file + (default: /recordings/-.cast) + ASPIRE_PR_RECORDING_TITLE + Optional title stored in the recording metadata + GH_TOKEN/GITHUB_TOKEN GitHub token passed into the container + +Default command: + bash +EOF +} + +is_truthy() { + local value + value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" + + case "$value" in + 1|true|yes|on) + return 0 + ;; + *) + return 1 + ;; + esac +} + +get_recording_stem() { + local stem="${1:-session}" + + if [[ "$stem" =~ ^[0-9]+$ ]]; then + stem="pr-$stem" + fi + + stem="$(printf '%s' "$stem" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9._-]+/-/g; s/^-+//; s/-+$//')" + + if [[ -z "$stem" ]]; then + stem="session" + fi + + printf '%s' "$stem" +} + +compute_workspace_hash() { + local value="$1" + local digest + + if command -v sha256sum >/dev/null 2>&1; then + digest="$(printf '%s' "$value" | sha256sum | awk '{print $1}')" + elif command -v shasum >/dev/null 2>&1; then + digest="$(printf '%s' "$value" | shasum -a 256 | awk '{print $1}')" + else + digest="$(printf '%s' "$value" | cksum | awk '{print $1}')" + fi + + printf '%s' "${digest:0:12}" +} + +get_state_volume_name() { + local workspace="$1" + local resolved_workspace + + if [[ -n "${ASPIRE_PR_STATE_VOLUME:-}" ]]; then + printf '%s' "$ASPIRE_PR_STATE_VOLUME" + return + fi + + resolved_workspace="$(cd "$workspace" && pwd -P)" + printf 'aspire-pr-state-%s' "$(compute_workspace_hash "$resolved_workspace")" +} + +resolve_host_path() { + local path="$1" + + if command -v realpath >/dev/null 2>&1; then + realpath "$path" 2>/dev/null && return 0 + fi + + if command -v readlink >/dev/null 2>&1 && readlink -f / >/dev/null 2>&1; then + readlink -f "$path" 2>/dev/null && return 0 + fi + + printf '%s\n' "$path" + return 1 +} + +ensure_github_token() { + local token + + if [[ -n "${GH_TOKEN:-}" ]]; then + export GH_TOKEN + return + fi + + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + GH_TOKEN="$GITHUB_TOKEN" + export GH_TOKEN + return + fi + + if ! command -v gh >/dev/null 2>&1; then + echo "GitHub CLI 'gh' is required when GH_TOKEN/GITHUB_TOKEN is not set. Run 'gh auth login' or export GH_TOKEN." >&2 + exit 1 + fi + + if ! token="$(gh auth token 2>/dev/null)"; then + echo "Failed to get a GitHub token from 'gh auth token'. Run 'gh auth login' or export GH_TOKEN/GITHUB_TOKEN." >&2 + exit 1 + fi + + if [[ -z "$token" ]]; then + echo "GitHub CLI returned an empty token. Run 'gh auth login' or export GH_TOKEN/GITHUB_TOKEN." >&2 + exit 1 + fi + + GH_TOKEN="$token" + export GH_TOKEN +} + +prepare_state_volume() { + local image_name="$1" + local state_volume="$2" + local container_user="$3" + + docker volume create "$state_volume" >/dev/null + + docker run --rm \ + -u 0:0 \ + -e TARGET_USER="$container_user" \ + -v "$state_volume:/state" \ + "$image_name" \ + bash -lc 'set -euo pipefail; mkdir -p /state; if [[ -n "${TARGET_USER:-}" && "${TARGET_USER}" != "0:0" && "${TARGET_USER}" != "0" ]]; then chown -R "$TARGET_USER" /state; fi' +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT_PATH="$SCRIPT_DIR/$(basename "${BASH_SOURCE[0]}")" +IMAGE_NAME="${ASPIRE_PR_IMAGE:-aspire-pr-runner}" +WORKSPACE="${ASPIRE_PR_WORKSPACE:-$PWD}" +DOCKER_SOCKET_PATH="${ASPIRE_DOCKER_SOCKET:-/var/run/docker.sock}" +CONTAINER_USER="${ASPIRE_CONTAINER_USER:-$(id -u):$(id -g)}" +STATE_VOLUME="$(get_state_volume_name "$WORKSPACE")" + +if [[ $# -eq 0 ]]; then + set -- bash +fi + +case "$1" in + -h|--help) + usage + exit 0 + ;; +esac + +if [[ ! -d "$WORKSPACE" ]]; then + echo "Workspace directory does not exist: $WORKSPACE" >&2 + exit 1 +fi + +if [[ -z "${ASPIRE_PR_RECORDING_ACTIVE:-}" ]] && [[ -n "${ASPIRE_PR_RECORD:-}" ]] && is_truthy "${ASPIRE_PR_RECORD}"; then + if ! command -v asciinema >/dev/null 2>&1; then + echo "asciinema is required when ASPIRE_PR_RECORD is enabled." >&2 + exit 1 + fi + + recording_path="${ASPIRE_PR_RECORDING_PATH:-$WORKSPACE/recordings/$(date -u +%Y%m%dT%H%M%SZ)-$(get_recording_stem "$1").cast}" + mkdir -p "$(dirname "$recording_path")" + + recording_command=("$SCRIPT_PATH" "$@") + recording_command_string="$(printf '%q ' "${recording_command[@]}")" + recording_command_string="${recording_command_string% }" + + export ASPIRE_PR_RECORDING_ACTIVE=1 + echo "Recording session to $recording_path" >&2 + + recording_args=( + record + --return + --command "$recording_command_string" + ) + + if [[ -n "${ASPIRE_PR_RECORDING_TITLE:-}" ]]; then + recording_args+=(--title "$ASPIRE_PR_RECORDING_TITLE") + fi + + recording_args+=("$recording_path") + + asciinema "${recording_args[@]}" + exit $? +fi + +ensure_github_token + +tty_args=() +if [[ -t 0 && -t 1 ]]; then + tty_args=(-it) +fi + +run_args=( + --rm + -e GH_TOKEN + -e ASPIRE_REPO + -e HOME=/workspace + -u "$CONTAINER_USER" + -v "$WORKSPACE:/workspace" + -v "$STATE_VOLUME:/workspace/.aspire" + -w /workspace +) + +if [[ ${#tty_args[@]} -gt 0 ]]; then + run_args+=("${tty_args[@]}") +fi + +if [[ -e "$DOCKER_SOCKET_PATH" ]]; then + DOCKER_SOCKET_REALPATH="$DOCKER_SOCKET_PATH" + if ! DOCKER_SOCKET_REALPATH="$(resolve_host_path "$DOCKER_SOCKET_PATH")"; then + echo "Warning: Unable to resolve Docker socket path '$DOCKER_SOCKET_PATH' with realpath or readlink -f; using the original path." >&2 + DOCKER_SOCKET_REALPATH="$DOCKER_SOCKET_PATH" + fi + + if [[ -S "$DOCKER_SOCKET_REALPATH" ]]; then + run_args+=(-v "$DOCKER_SOCKET_REALPATH:/var/run/docker.sock") + elif [[ "$DOCKER_SOCKET_REALPATH" != "$DOCKER_SOCKET_PATH" && -S "$DOCKER_SOCKET_PATH" ]]; then + run_args+=(-v "$DOCKER_SOCKET_PATH:/var/run/docker.sock") + fi +fi + +docker build -t "$IMAGE_NAME" "$SCRIPT_DIR" +prepare_state_volume "$IMAGE_NAME" "$STATE_VOLUME" "$CONTAINER_USER" +docker run "${run_args[@]}" "$IMAGE_NAME" "$@"