From 3c255b37e791d9cbecc136cee83e06a9e98a991e Mon Sep 17 00:00:00 2001 From: David Pine Date: Tue, 31 Mar 2026 08:28:28 -0500 Subject: [PATCH 1/5] Restore per-PR preview host stack This reapplies the per-PR preview implementation that was reverted by PR #642, restoring the PreviewHost, preview AppHost, workflow automation, UI, and performance hardening work onto the redraft branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/apphost-build.yml | 26 +- .github/workflows/ci.yml | 3 + .github/workflows/frontend-build.yml | 20 +- .github/workflows/pr-preview-cleanup.yml | 54 + .github/workflows/pr-preview-register.yml | 149 ++ Aspire.Dev.slnx | 2 + .../Aspire.Dev.Preview.AppHost/AppHost.cs | 35 + .../Aspire.Dev.Preview.AppHost.csproj | 19 + .../Properties/launchSettings.json | 29 + .../appsettings.Development.json | 8 + .../appsettings.json | 9 + src/frontend/astro.config.mjs | 7 + src/statichost/PreviewHost/.dockerignore | 2 + src/statichost/PreviewHost/Dockerfile | 25 + src/statichost/PreviewHost/PreviewHost.csproj | 15 + .../Previews/CommandLineExtractionSupport.cs | 77 + .../Previews/GitHubArtifactClient.cs | 800 +++++++++++ .../Previews/PreviewBufferSettings.cs | 92 ++ .../Previews/PreviewCoordinator.cs | 997 +++++++++++++ .../PreviewHost/Previews/PreviewModels.cs | 284 ++++ .../Previews/PreviewRequestDispatcher.cs | 1244 +++++++++++++++++ .../PreviewHost/Previews/PreviewStateStore.cs | 653 +++++++++ src/statichost/PreviewHost/Program.cs | 441 ++++++ .../Properties/launchSettings.json | 25 + .../PreviewHost/appsettings.Development.json | 8 + src/statichost/PreviewHost/appsettings.json | 11 + .../wwwroot/_preview/aspire-mark.svg | 8 + .../wwwroot/_preview/github-mark.svg | 3 + .../PreviewHost/wwwroot/_preview/index.html | 110 ++ .../PreviewHost/wwwroot/_preview/index.js | 710 ++++++++++ .../wwwroot/_preview/left-arrow.svg | 3 + .../PreviewHost/wwwroot/_preview/preview.css | 1014 ++++++++++++++ .../wwwroot/_preview/retry-arrow.svg | 3 + .../PreviewHost/wwwroot/_preview/status.html | 115 ++ .../PreviewHost/wwwroot/_preview/status.js | 591 ++++++++ 35 files changed, 7583 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/pr-preview-cleanup.yml create mode 100644 .github/workflows/pr-preview-register.yml create mode 100644 src/apphost/Aspire.Dev.Preview.AppHost/AppHost.cs create mode 100644 src/apphost/Aspire.Dev.Preview.AppHost/Aspire.Dev.Preview.AppHost.csproj create mode 100644 src/apphost/Aspire.Dev.Preview.AppHost/Properties/launchSettings.json create mode 100644 src/apphost/Aspire.Dev.Preview.AppHost/appsettings.Development.json create mode 100644 src/apphost/Aspire.Dev.Preview.AppHost/appsettings.json create mode 100644 src/statichost/PreviewHost/.dockerignore create mode 100644 src/statichost/PreviewHost/Dockerfile create mode 100644 src/statichost/PreviewHost/PreviewHost.csproj create mode 100644 src/statichost/PreviewHost/Previews/CommandLineExtractionSupport.cs create mode 100644 src/statichost/PreviewHost/Previews/GitHubArtifactClient.cs create mode 100644 src/statichost/PreviewHost/Previews/PreviewBufferSettings.cs create mode 100644 src/statichost/PreviewHost/Previews/PreviewCoordinator.cs create mode 100644 src/statichost/PreviewHost/Previews/PreviewModels.cs create mode 100644 src/statichost/PreviewHost/Previews/PreviewRequestDispatcher.cs create mode 100644 src/statichost/PreviewHost/Previews/PreviewStateStore.cs create mode 100644 src/statichost/PreviewHost/Program.cs create mode 100644 src/statichost/PreviewHost/Properties/launchSettings.json create mode 100644 src/statichost/PreviewHost/appsettings.Development.json create mode 100644 src/statichost/PreviewHost/appsettings.json create mode 100644 src/statichost/PreviewHost/wwwroot/_preview/aspire-mark.svg create mode 100644 src/statichost/PreviewHost/wwwroot/_preview/github-mark.svg create mode 100644 src/statichost/PreviewHost/wwwroot/_preview/index.html create mode 100644 src/statichost/PreviewHost/wwwroot/_preview/index.js create mode 100644 src/statichost/PreviewHost/wwwroot/_preview/left-arrow.svg create mode 100644 src/statichost/PreviewHost/wwwroot/_preview/preview.css create mode 100644 src/statichost/PreviewHost/wwwroot/_preview/retry-arrow.svg create mode 100644 src/statichost/PreviewHost/wwwroot/_preview/status.html create mode 100644 src/statichost/PreviewHost/wwwroot/_preview/status.js diff --git a/.github/workflows/apphost-build.yml b/.github/workflows/apphost-build.yml index 60ca62522..f7bb6f4a2 100644 --- a/.github/workflows/apphost-build.yml +++ b/.github/workflows/apphost-build.yml @@ -8,8 +8,20 @@ permissions: jobs: build: - name: AppHost Build + name: ${{ matrix.apphost.name }} Build runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + apphost: + - name: AppHost + project_name: Aspire.Dev.AppHost + project_path: src/apphost/Aspire.Dev.AppHost + artifact_name: apphost-release + - name: Preview AppHost + project_name: Aspire.Dev.Preview.AppHost + project_path: src/apphost/Aspire.Dev.Preview.AppHost + artifact_name: preview-apphost-release steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -18,17 +30,17 @@ jobs: global-json-file: global.json - name: Restore - run: cd src/apphost/Aspire.Dev.AppHost && dotnet restore + run: cd ${{ matrix.apphost.project_path }} && dotnet restore - name: Build - run: cd src/apphost/Aspire.Dev.AppHost && dotnet build --no-restore --configuration Release + run: cd ${{ matrix.apphost.project_path }} && dotnet build --no-restore --configuration Release - name: Verify output run: | - APPHOST_DLL=$(ls -1 src/apphost/Aspire.Dev.AppHost/bin/Release/*/Aspire.Dev.AppHost.dll 2>/dev/null | head -n 1) + APPHOST_DLL=$(ls -1 ${{ matrix.apphost.project_path }}/bin/Release/*/${{ matrix.apphost.project_name }}.dll 2>/dev/null | head -n 1) if [ -z "$APPHOST_DLL" ]; then echo "AppHost build failed - output assembly not found" - ls -R src/apphost/Aspire.Dev.AppHost/bin/Release || true + ls -R ${{ matrix.apphost.project_path }}/bin/Release || true exit 1 fi echo "Found $APPHOST_DLL" @@ -37,7 +49,7 @@ jobs: if: ${{ always() }} uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: - name: apphost-release - path: src/apphost/Aspire.Dev.AppHost/bin/Release/*/ + name: ${{ matrix.apphost.artifact_name }} + path: ${{ matrix.apphost.project_path }}/bin/Release/*/ if-no-files-found: warn retention-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5e1870ca..9368b36a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,9 @@ jobs: uses: ./.github/workflows/frontend-build.yml with: node_version: '24.x' + artifact_name: ${{ github.event_name == 'pull_request' && format('frontend-dist-pr-{0}', github.event.pull_request.number) || 'frontend-dist' }} + artifact_retention_days: ${{ github.event_name == 'pull_request' && 30 || 7 }} + site_base_path: ${{ github.event_name == 'pull_request' && format('/prs/{0}', github.event.pull_request.number) || '/' }} apphost-build: needs: changes diff --git a/.github/workflows/frontend-build.yml b/.github/workflows/frontend-build.yml index 580c2ed6c..6827bbfc2 100644 --- a/.github/workflows/frontend-build.yml +++ b/.github/workflows/frontend-build.yml @@ -8,6 +8,21 @@ on: required: true default: "24.x" type: string + artifact_name: + description: Artifact name to publish + required: false + default: "frontend-dist" + type: string + artifact_retention_days: + description: Number of days to keep the uploaded artifact + required: false + default: 7 + type: number + site_base_path: + description: Path base to use for the Astro build + required: false + default: "/" + type: string permissions: contents: read @@ -41,6 +56,7 @@ jobs: env: MODE: production ASTRO_TELEMETRY_DISABLED: 1 + ASTRO_BASE_PATH: ${{ inputs.site_base_path }} run: pnpm build:production - name: Check dist @@ -61,7 +77,7 @@ jobs: if: ${{ always() }} uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: - name: frontend-dist + name: ${{ inputs.artifact_name }} path: src/frontend/dist if-no-files-found: warn - retention-days: 7 + retention-days: ${{ inputs.artifact_retention_days }} diff --git a/.github/workflows/pr-preview-cleanup.yml b/.github/workflows/pr-preview-cleanup.yml new file mode 100644 index 000000000..eb8109d3a --- /dev/null +++ b/.github/workflows/pr-preview-cleanup.yml @@ -0,0 +1,54 @@ +name: PR Preview Cleanup + +on: + pull_request: + types: [ closed ] + +permissions: + pull-requests: write + +jobs: + cleanup-preview: + if: ${{ vars.PR_PREVIEW_BASE_URL != '' }} + runs-on: ubuntu-latest + steps: + - name: Check preview configuration + id: config + env: + PREVIEW_REGISTRATION_TOKEN: ${{ secrets.PR_PREVIEW_REGISTRATION_TOKEN }} + run: | + if [ -z "$PREVIEW_REGISTRATION_TOKEN" ]; then + echo "configured=false" >> "$GITHUB_OUTPUT" + else + echo "configured=true" >> "$GITHUB_OUTPUT" + fi + + - name: Remove preview registration + if: ${{ steps.config.outputs.configured == 'true' }} + env: + PREVIEW_BASE_URL: ${{ vars.PR_PREVIEW_BASE_URL }} + PREVIEW_REGISTRATION_TOKEN: ${{ secrets.PR_PREVIEW_REGISTRATION_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + curl --fail-with-body \ + --request DELETE \ + --url "${PREVIEW_BASE_URL%/}/api/previews/${PR_NUMBER}" \ + --header "Authorization: Bearer $PREVIEW_REGISTRATION_TOKEN" + + - name: Remove preview block from PR description + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const markerStart = ''; + const markerEnd = ''; + const pullRequest = context.payload.pull_request; + const existingBody = pullRequest.body ?? ''; + const blockExpression = new RegExp(`\\n?${markerStart}[\\s\\S]*?${markerEnd}\\n?`, 'm'); + const updatedBody = existingBody.replace(blockExpression, '\n').trimEnd(); + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pullRequest.number, + body: updatedBody, + }); diff --git a/.github/workflows/pr-preview-register.yml b/.github/workflows/pr-preview-register.yml new file mode 100644 index 000000000..39eec65c2 --- /dev/null +++ b/.github/workflows/pr-preview-register.yml @@ -0,0 +1,149 @@ +name: PR Preview Register + +on: + workflow_run: + workflows: [ CI ] + types: [ completed ] + +permissions: + actions: read + contents: read + pull-requests: write + +jobs: + register-preview: + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' && vars.PR_PREVIEW_BASE_URL != '' }} + runs-on: ubuntu-latest + steps: + - name: Check preview configuration + id: config + env: + PREVIEW_REGISTRATION_TOKEN: ${{ secrets.PR_PREVIEW_REGISTRATION_TOKEN }} + run: | + if [ -z "$PREVIEW_REGISTRATION_TOKEN" ]; then + echo "configured=false" >> "$GITHUB_OUTPUT" + else + echo "configured=true" >> "$GITHUB_OUTPUT" + fi + + - name: Resolve preview metadata + if: ${{ steps.config.outputs.configured == 'true' }} + id: metadata + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const run = context.payload.workflow_run; + + if (!run.pull_requests || run.pull_requests.length === 0) { + core.setOutput('should_register', 'false'); + return; + } + + const pullRequest = run.pull_requests[0]; + const artifactName = `frontend-dist-pr-${pullRequest.number}`; + + const artifacts = await github.paginate( + github.rest.actions.listWorkflowRunArtifacts, + { + owner: context.repo.owner, + repo: context.repo.repo, + run_id: run.id, + per_page: 100, + }); + + const previewArtifact = artifacts.find(artifact => artifact.name === artifactName && !artifact.expired); + if (!previewArtifact) { + core.info(`No preview artifact named ${artifactName} was found on run ${run.id}.`); + core.setOutput('should_register', 'false'); + return; + } + + core.setOutput('should_register', 'true'); + core.setOutput('pr_number', String(pullRequest.number)); + core.setOutput('artifact_name', artifactName); + core.setOutput('head_sha', run.head_sha); + core.setOutput('run_id', String(run.id)); + core.setOutput('run_attempt', String(run.run_attempt)); + core.setOutput('completed_at', run.updated_at ?? run.created_at); + + - name: Register preview build + if: ${{ steps.config.outputs.configured == 'true' && steps.metadata.outputs.should_register == 'true' }} + env: + PREVIEW_BASE_URL: ${{ vars.PR_PREVIEW_BASE_URL }} + PREVIEW_REGISTRATION_TOKEN: ${{ secrets.PR_PREVIEW_REGISTRATION_TOKEN }} + PR_NUMBER: ${{ steps.metadata.outputs.pr_number }} + ARTIFACT_NAME: ${{ steps.metadata.outputs.artifact_name }} + HEAD_SHA: ${{ steps.metadata.outputs.head_sha }} + RUN_ID: ${{ steps.metadata.outputs.run_id }} + RUN_ATTEMPT: ${{ steps.metadata.outputs.run_attempt }} + COMPLETED_AT: ${{ steps.metadata.outputs.completed_at }} + REPOSITORY_OWNER: ${{ github.repository_owner }} + REPOSITORY_NAME: ${{ github.event.repository.name }} + run: | + payload=$(jq -n \ + --arg repositoryOwner "$REPOSITORY_OWNER" \ + --arg repositoryName "$REPOSITORY_NAME" \ + --arg headSha "$HEAD_SHA" \ + --arg artifactName "$ARTIFACT_NAME" \ + --arg completedAtUtc "$COMPLETED_AT" \ + --argjson pullRequestNumber "$PR_NUMBER" \ + --argjson runId "$RUN_ID" \ + --argjson runAttempt "$RUN_ATTEMPT" \ + '{ + repositoryOwner: $repositoryOwner, + repositoryName: $repositoryName, + pullRequestNumber: $pullRequestNumber, + headSha: $headSha, + runId: $runId, + runAttempt: $runAttempt, + artifactName: $artifactName, + completedAtUtc: $completedAtUtc + }') + + curl --fail-with-body \ + --request POST \ + --url "${PREVIEW_BASE_URL%/}/api/previews/registrations" \ + --header "Authorization: Bearer $PREVIEW_REGISTRATION_TOKEN" \ + --header "Content-Type: application/json" \ + --data "$payload" + + - name: Update PR description preview block + if: ${{ steps.config.outputs.configured == 'true' && steps.metadata.outputs.should_register == 'true' }} + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + PREVIEW_BASE_URL: ${{ vars.PR_PREVIEW_BASE_URL }} + PR_NUMBER: ${{ steps.metadata.outputs.pr_number }} + with: + script: | + const markerStart = ''; + const markerEnd = ''; + const prNumber = Number(process.env.PR_NUMBER); + const previewUrl = new URL(`/prs/${prNumber}/`, process.env.PREVIEW_BASE_URL).toString(); + const badgeUrl = `https://img.shields.io/badge/aspire.dev-${encodeURIComponent(`PR #${prNumber}`)}-512BD4?style=for-the-badge&labelColor=512BD4&color=555555`; + + const pullRequest = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + + const existingBody = pullRequest.data.body ?? ''; + const previewBlock = [ + markerStart, + '## 🌐 Preview', + '', + `[![](${badgeUrl})](${previewUrl})`, + markerEnd, + ].join('\n'); + + const blockExpression = new RegExp(`${markerStart}[\\s\\S]*?${markerEnd}`, 'm'); + const updatedBody = blockExpression.test(existingBody) + ? existingBody.replace(blockExpression, previewBlock) + : `${existingBody.trimEnd()}${existingBody.trim() ? '\n\n' : ''}${previewBlock}`; + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + body: updatedBody, + }); diff --git a/Aspire.Dev.slnx b/Aspire.Dev.slnx index de7801a77..1369bd7d0 100644 --- a/Aspire.Dev.slnx +++ b/Aspire.Dev.slnx @@ -1,9 +1,11 @@ + + diff --git a/src/apphost/Aspire.Dev.Preview.AppHost/AppHost.cs b/src/apphost/Aspire.Dev.Preview.AppHost/AppHost.cs new file mode 100644 index 000000000..6d5872b2d --- /dev/null +++ b/src/apphost/Aspire.Dev.Preview.AppHost/AppHost.cs @@ -0,0 +1,35 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var registrationToken = builder.AddParameter("registration-token", secret: true); +var extractionMode = builder.AddParameter("extraction-mode", value: "command-line"); + +var previewHost = builder.AddProject("previewhost") + .PublishAsDockerFile() + .WithExternalHttpEndpoints() + .WithEnvironment("PreviewHost__RegistrationToken", registrationToken) + .WithEnvironment("PreviewHost__ExtractionMode", extractionMode); + +if (!string.IsNullOrWhiteSpace(builder.Configuration["PreviewHost:GitHubToken"])) +{ + var githubToken = builder.AddParameterFromConfiguration("github-token", "PreviewHost:GitHubToken", secret: true); + previewHost.WithEnvironment("PreviewHost__GitHubToken", githubToken); +} +else +{ + var githubAppId = builder.AddParameter("github-app-id"); + var githubAppInstallationId = builder.AddParameter("github-app-installation-id"); + var githubAppPrivateKey = builder.AddParameter("github-app-private-key", secret: true); + + previewHost + .WithEnvironment("PreviewHost__GitHubAppId", githubAppId) + .WithEnvironment("PreviewHost__GitHubAppInstallationId", githubAppInstallationId) + .WithEnvironment("PreviewHost__GitHubAppPrivateKey", githubAppPrivateKey); +} + +if (!builder.ExecutionContext.IsRunMode) +{ + previewHost.WithEnvironment("PreviewHost__ContentRoot", "/tmp/pr-preview-data"); + builder.AddAzureAppServiceEnvironment("preview"); +} + +builder.Build().Run(); diff --git a/src/apphost/Aspire.Dev.Preview.AppHost/Aspire.Dev.Preview.AppHost.csproj b/src/apphost/Aspire.Dev.Preview.AppHost/Aspire.Dev.Preview.AppHost.csproj new file mode 100644 index 000000000..18b2edc0b --- /dev/null +++ b/src/apphost/Aspire.Dev.Preview.AppHost/Aspire.Dev.Preview.AppHost.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + enable + enable + cde7765d-59fe-4ea8-a20a-c1eb1ced5cbf + + + + + + + + + + + diff --git a/src/apphost/Aspire.Dev.Preview.AppHost/Properties/launchSettings.json b/src/apphost/Aspire.Dev.Preview.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..43294c9d1 --- /dev/null +++ b/src/apphost/Aspire.Dev.Preview.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17156;http://localhost:15082", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21055", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22207" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15082", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19290", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20046" + } + } + } +} diff --git a/src/apphost/Aspire.Dev.Preview.AppHost/appsettings.Development.json b/src/apphost/Aspire.Dev.Preview.AppHost/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/src/apphost/Aspire.Dev.Preview.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/apphost/Aspire.Dev.Preview.AppHost/appsettings.json b/src/apphost/Aspire.Dev.Preview.AppHost/appsettings.json new file mode 100644 index 000000000..31c092aa4 --- /dev/null +++ b/src/apphost/Aspire.Dev.Preview.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/frontend/astro.config.mjs b/src/frontend/astro.config.mjs index 98cf1de7c..b5f462eb4 100644 --- a/src/frontend/astro.config.mjs +++ b/src/frontend/astro.config.mjs @@ -21,9 +21,16 @@ import starlightSidebarTopics from 'starlight-sidebar-topics'; import starlightPageActions from 'starlight-page-actions'; import jopSoftwarecookieconsent from '@jop-software/astro-cookieconsent'; +const configuredBasePath = process.env.ASTRO_BASE_PATH ?? '/'; +const normalizedBasePath = + configuredBasePath === '/' + ? '/' + : `/${configuredBasePath.replace(/^\/+|\/+$/g, '')}`; + // https://astro.build/config export default defineConfig({ prefetch: true, + base: normalizedBasePath, site: 'https://aspire.dev', trailingSlash: 'always', redirects: redirects, diff --git a/src/statichost/PreviewHost/.dockerignore b/src/statichost/PreviewHost/.dockerignore new file mode 100644 index 000000000..cd42ee34e --- /dev/null +++ b/src/statichost/PreviewHost/.dockerignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/src/statichost/PreviewHost/Dockerfile b/src/statichost/PreviewHost/Dockerfile new file mode 100644 index 000000000..e1aaecb3c --- /dev/null +++ b/src/statichost/PreviewHost/Dockerfile @@ -0,0 +1,25 @@ +# syntax=docker/dockerfile:1.7 + +FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS build +WORKDIR /src + +COPY . ./ + +RUN dotnet publish ./PreviewHost.csproj -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble AS unzip-tools +RUN apt-get update \ + && apt-get install -y --no-install-recommends unzip \ + && rm -rf /var/lib/apt/lists/* + +FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS final +WORKDIR /app + +COPY --from=unzip-tools /usr/bin/unzip /usr/bin/unzip +COPY --from=unzip-tools /lib/x86_64-linux-gnu/libbz2.so.1.0* /lib/x86_64-linux-gnu/ +COPY --from=build /app/publish/ ./ + +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 + +ENTRYPOINT ["dotnet", "PreviewHost.dll"] diff --git a/src/statichost/PreviewHost/PreviewHost.csproj b/src/statichost/PreviewHost/PreviewHost.csproj new file mode 100644 index 000000000..95e47648c --- /dev/null +++ b/src/statichost/PreviewHost/PreviewHost.csproj @@ -0,0 +1,15 @@ + + + net10.0 + enable + enable + false + false + true + noble-chiseled + + + + + + diff --git a/src/statichost/PreviewHost/Previews/CommandLineExtractionSupport.cs b/src/statichost/PreviewHost/Previews/CommandLineExtractionSupport.cs new file mode 100644 index 000000000..bb0f96298 --- /dev/null +++ b/src/statichost/PreviewHost/Previews/CommandLineExtractionSupport.cs @@ -0,0 +1,77 @@ +namespace PreviewHost.Previews; + +internal static class CommandLineExtractionSupport +{ + public static bool IsConfigurationSupported(PreviewHostOptions options) => + !options.UseCommandLineExtraction || TryResolveConfiguredTool(options, out _); + + public static string GetConfigurationValidationMessage() => + OperatingSystem.IsWindows() + ? $"The '{PreviewHostOptions.SectionName}:ExtractionMode' setting is 'command-line', but 'tar.exe' is not available on PATH. Switch back to 'managed' or deploy PreviewHost on a Windows image that includes tar.exe." + : $"The '{PreviewHostOptions.SectionName}:ExtractionMode' setting is 'command-line', but 'unzip' is not available on PATH. Chiseled .NET runtime images do not include unzip by default; switch back to 'managed' or deploy PreviewHost from a custom image with unzip installed."; + + public static bool TryResolveConfiguredTool(PreviewHostOptions options, out string? resolvedPath) + { + resolvedPath = null; + + if (!options.UseCommandLineExtraction) + { + return true; + } + + return TryResolveCommand(options.CommandLineExtractionCommandName!, out resolvedPath); + } + + private static bool TryResolveCommand(string commandName, out string? resolvedPath) + { + resolvedPath = null; + + if (Path.IsPathRooted(commandName) && File.Exists(commandName)) + { + resolvedPath = commandName; + return true; + } + + var pathValue = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrWhiteSpace(pathValue)) + { + return false; + } + + var searchNames = GetSearchNames(commandName); + foreach (var directory in pathValue.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + foreach (var searchName in searchNames) + { + var candidate = Path.Combine(directory, searchName); + if (File.Exists(candidate)) + { + resolvedPath = candidate; + return true; + } + } + } + + return false; + } + + private static IEnumerable GetSearchNames(string commandName) + { + yield return commandName; + + if (!OperatingSystem.IsWindows() || Path.HasExtension(commandName)) + { + yield break; + } + + var pathExt = Environment.GetEnvironmentVariable("PATHEXT"); + var extensions = string.IsNullOrWhiteSpace(pathExt) + ? [".exe", ".cmd", ".bat"] + : pathExt.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var extension in extensions) + { + yield return $"{commandName}{extension}"; + } + } +} diff --git a/src/statichost/PreviewHost/Previews/GitHubArtifactClient.cs b/src/statichost/PreviewHost/Previews/GitHubArtifactClient.cs new file mode 100644 index 000000000..42e712895 --- /dev/null +++ b/src/statichost/PreviewHost/Previews/GitHubArtifactClient.cs @@ -0,0 +1,800 @@ +using System.Buffers; +using System.Collections.Concurrent; +using System.Diagnostics; +using GitHubJwt; +using Microsoft.Extensions.Options; +using Octokit; + +namespace PreviewHost.Previews; + +internal sealed class GitHubArtifactClient(IOptions options, ILogger logger) +{ + private static readonly ApiOptions SinglePageApiOptions = new() + { + StartPage = 1, + PageCount = 1, + PageSize = 100 + }; + + private static readonly TimeSpan DownloadProgressPublishInterval = TimeSpan.FromSeconds(1); + private static readonly TimeSpan OpenPullRequestCatalogCacheDuration = TimeSpan.FromMinutes(2); + private const string CiWorkflowPath = ".github/workflows/ci.yml"; + private const string DefaultPreviewArtifactName = "frontend-dist"; + private readonly SemaphoreSlim _installationTokenGate = new(1, 1); + private readonly SemaphoreSlim _pullRequestCatalogGate = new(1, 1); + private readonly PreviewHostOptions _options = options.Value; + private readonly ILogger _logger = logger; + private AccessToken? _cachedInstallationToken; + private long? _cachedInstallationId; + private IReadOnlyList? _cachedOpenPullRequests; + private DateTimeOffset _cachedOpenPullRequestsExpiresAtUtc; + private Task>? _pullRequestCatalogRefreshTask; + + public async Task TryResolveLatestPreviewRegistrationAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + EnsureCredentialsConfigured(); + EnsureRepositoryConfigured(); + + var repositoryClient = await CreateRepositoryClientAsync( + _options.RepositoryOwner, + _options.RepositoryName, + cancellationToken); + + PullRequest? pullRequest; + try + { + pullRequest = await repositoryClient.PullRequest.Get( + _options.RepositoryOwner, + _options.RepositoryName, + pullRequestNumber); + } + catch (NotFoundException) + { + return null; + } + + if (pullRequest is null || string.IsNullOrWhiteSpace(pullRequest.Head?.Sha)) + { + return null; + } + + var workflowRun = await GetLatestSuccessfulPreviewRunAsync( + repositoryClient, + _options.RepositoryOwner, + _options.RepositoryName, + pullRequest.Head.Sha, + cancellationToken); + + if (workflowRun is null) + { + return null; + } + + var artifacts = await GetArtifactsAsync( + repositoryClient, + _options.RepositoryOwner, + _options.RepositoryName, + workflowRun.Id, + cancellationToken); + + var previewArtifact = ResolvePreviewArtifact(artifacts.Artifacts, pullRequestNumber); + if (previewArtifact is null) + { + return null; + } + + return new PreviewRegistrationRequest + { + RepositoryOwner = _options.RepositoryOwner, + RepositoryName = _options.RepositoryName, + PullRequestNumber = pullRequestNumber, + HeadSha = pullRequest.Head.Sha, + RunId = workflowRun.Id, + RunAttempt = checked((int)workflowRun.RunAttempt), + ArtifactName = previewArtifact.Name, + CompletedAtUtc = workflowRun.UpdatedAt + }; + } + + public async Task> ListOpenPullRequestsAsync(CancellationToken cancellationToken) + { + EnsureCredentialsConfigured(); + EnsureRepositoryConfigured(); + + IReadOnlyList? cachedOpenPullRequests = null; + Task>? refreshTask = null; + + await _pullRequestCatalogGate.WaitAsync(cancellationToken); + try + { + if (_cachedOpenPullRequests is not null + && _cachedOpenPullRequestsExpiresAtUtc > DateTimeOffset.UtcNow) + { + return _cachedOpenPullRequests; + } + + cachedOpenPullRequests = _cachedOpenPullRequests; + + if (_pullRequestCatalogRefreshTask is null || _pullRequestCatalogRefreshTask.IsCompleted) + { + _pullRequestCatalogRefreshTask = StartPullRequestCatalogRefreshTask(); + } + + refreshTask = _pullRequestCatalogRefreshTask; + } + finally + { + _pullRequestCatalogGate.Release(); + } + + if (cachedOpenPullRequests is not null) + { + _logger.LogDebug("Serving stale open PR catalog while GitHub previewability data refreshes in the background."); + return cachedOpenPullRequests; + } + + return await refreshTask!.WaitAsync(cancellationToken); + } + + private Task> StartPullRequestCatalogRefreshTask() + { + Task>? refreshTask = null; + + refreshTask = Task.Run( + async () => + { + try + { + return await RefreshOpenPullRequestsAsync(CancellationToken.None); + } + finally + { + await _pullRequestCatalogGate.WaitAsync(CancellationToken.None); + try + { + if (ReferenceEquals(_pullRequestCatalogRefreshTask, refreshTask)) + { + _pullRequestCatalogRefreshTask = null; + } + } + finally + { + _pullRequestCatalogGate.Release(); + } + } + }, + CancellationToken.None); + + _ = refreshTask.ContinueWith( + task => + { + if (task.Exception is null) + { + return; + } + + _logger.LogWarning( + task.Exception.GetBaseException(), + "Failed to refresh the open PR catalog. Continuing to serve the last cached catalog until the next refresh succeeds."); + }, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted, + TaskScheduler.Default); + + return refreshTask; + } + + private async Task> RefreshOpenPullRequestsAsync(CancellationToken cancellationToken) + { + var refreshedPullRequests = await QueryOpenPullRequestsAsync(cancellationToken); + + await _pullRequestCatalogGate.WaitAsync(CancellationToken.None); + try + { + _cachedOpenPullRequests = refreshedPullRequests; + _cachedOpenPullRequestsExpiresAtUtc = DateTimeOffset.UtcNow.Add(OpenPullRequestCatalogCacheDuration); + return _cachedOpenPullRequests; + } + finally + { + _pullRequestCatalogGate.Release(); + } + } + + private async Task> QueryOpenPullRequestsAsync(CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + + var repositoryClient = await CreateRepositoryClientAsync( + _options.RepositoryOwner, + _options.RepositoryName, + cancellationToken); + + var request = new PullRequestRequest + { + State = ItemStateFilter.Open, + SortProperty = PullRequestSort.Created, + SortDirection = SortDirection.Descending + }; + + var pullRequests = new List(); + + for (var page = 1; ; page++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var pageItems = await repositoryClient.PullRequest.GetAllForRepository( + _options.RepositoryOwner, + _options.RepositoryName, + request, + new ApiOptions + { + StartPage = page, + PageCount = 1, + PageSize = 100 + }); + + if (pageItems.Count == 0) + { + break; + } + + pullRequests.AddRange(pageItems); + + if (pageItems.Count < 100) + { + break; + } + } + + var previewablePullRequests = await GetPullRequestNumbersWithSuccessfulPreviewBuildsAsync( + repositoryClient, + pullRequests, + cancellationToken); + + IReadOnlyList summaries = [.. pullRequests + .OrderByDescending(static pullRequest => pullRequest.CreatedAt) + .ThenByDescending(static pullRequest => pullRequest.Number) + .Select(pullRequest => new GitHubPullRequestSummary( + pullRequest.Number, + pullRequest.Title, + pullRequest.HtmlUrl, + pullRequest.Head?.Sha ?? string.Empty, + pullRequest.User?.Login, + pullRequest.Draft, + pullRequest.CreatedAt, + pullRequest.UpdatedAt, + previewablePullRequests.Contains(pullRequest.Number)))]; + + stopwatch.Stop(); + _logger.LogInformation( + "Refreshed the open PR catalog with {PullRequestCount} pull requests in {ElapsedMilliseconds} ms", + summaries.Count, + stopwatch.ElapsedMilliseconds); + + return summaries; + } + + public async Task GetArtifactDescriptorAsync(PreviewWorkItem workItem, CancellationToken cancellationToken) + { + EnsureCredentialsConfigured(); + + var repositoryClient = await CreateRepositoryClientAsync( + workItem.RepositoryOwner, + workItem.RepositoryName, + cancellationToken); + + var payload = await GetArtifactsAsync( + repositoryClient, + workItem.RepositoryOwner, + workItem.RepositoryName, + workItem.RunId, + cancellationToken); + + var artifact = payload.Artifacts.FirstOrDefault(candidate => + !candidate.Expired + && string.Equals(candidate.Name, workItem.ArtifactName, StringComparison.Ordinal)); + + if (artifact is null) + { + throw new InvalidOperationException( + $"Could not find a non-expired GitHub Actions artifact named '{workItem.ArtifactName}' on run {workItem.RunId}."); + } + + return new GitHubArtifactDescriptor( + workItem.RepositoryOwner, + workItem.RepositoryName, + artifact.Id, + artifact.Name, + artifact.ExpiresAt == default ? DateTimeOffset.UtcNow : new DateTimeOffset(artifact.ExpiresAt, TimeSpan.Zero), + artifact.SizeInBytes > 0 ? artifact.SizeInBytes : null); + } + + public async Task DownloadArtifactAsync( + GitHubArtifactDescriptor artifact, + string destinationZipPath, + Func progressCallback, + CancellationToken cancellationToken) + { + EnsureCredentialsConfigured(); + Directory.CreateDirectory(Path.GetDirectoryName(destinationZipPath)!); + var bufferSettings = PreviewBufferSettings.Resolve(); + + var repositoryClient = await CreateRepositoryClientAsync( + artifact.RepositoryOwner, + artifact.RepositoryName, + cancellationToken); + + await using var sourceStream = await repositoryClient.Actions.Artifacts.DownloadArtifact( + artifact.RepositoryOwner, + artifact.RepositoryName, + artifact.ArtifactId, + "zip"); + + var totalBytes = artifact.SizeInBytes; + if (totalBytes is null && sourceStream.CanSeek) + { + totalBytes = sourceStream.Length; + } + + await using var destinationStream = new FileStream( + destinationZipPath, + System.IO.FileMode.Create, + System.IO.FileAccess.Write, + System.IO.FileShare.None, + bufferSize: bufferSettings.DownloadFileBufferSize, + options: System.IO.FileOptions.Asynchronous); + + _logger.LogInformation( + "Downloading preview artifact {ArtifactName} with adaptive buffers: copy {CopyBufferSizeMiB} MiB, file {FileBufferSizeMiB} MiB (~{AvailableMemoryMiB} MiB headroom)", + artifact.ArtifactName, + bufferSettings.DownloadCopyBufferMiB, + bufferSettings.DownloadFileBufferMiB, + bufferSettings.AvailableMemoryMiB); + + await using var progressPublisher = new DownloadProgressPublisher( + totalBytes, + progressCallback, + cancellationToken); + + var buffer = ArrayPool.Shared.Rent(bufferSettings.DownloadCopyBufferSize); + long downloadedBytes = 0; + + try + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var read = await sourceStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken); + if (read == 0) + { + break; + } + + await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + downloadedBytes += read; + progressPublisher.Report(downloadedBytes); + } + + await destinationStream.FlushAsync(cancellationToken); + await progressPublisher.CompleteAsync(downloadedBytes); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private static async Task GetArtifactsAsync( + GitHubClient repositoryClient, + string repositoryOwner, + string repositoryName, + long runId, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + return await repositoryClient.Actions.Artifacts.ListWorkflowArtifacts( + repositoryOwner, + repositoryName, + runId, + new ListArtifactsRequest + { + Page = 1, + PerPage = 100 + }); + } + + private static async Task GetLatestSuccessfulPreviewRunAsync( + GitHubClient repositoryClient, + string repositoryOwner, + string repositoryName, + string headSha, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var request = new WorkflowRunsRequest + { + Event = "pull_request", + HeadSha = headSha, + Status = CheckRunStatusFilter.Completed + }; + + var payload = await repositoryClient.Actions.Workflows.Runs.List( + repositoryOwner, + repositoryName, + request, + SinglePageApiOptions); + + return payload.WorkflowRuns + .Where(IsSuccessfulPreviewRun) + .OrderByDescending(static run => run.RunAttempt) + .ThenByDescending(static run => run.UpdatedAt) + .ThenByDescending(static run => run.Id) + .FirstOrDefault(); + } + + private async Task> GetPullRequestNumbersWithSuccessfulPreviewBuildsAsync( + GitHubClient repositoryClient, + IReadOnlyList pullRequests, + CancellationToken cancellationToken) + { + var headShas = pullRequests + .Select(static pullRequest => pullRequest.Head?.Sha) + .OfType() + .Distinct(StringComparer.Ordinal) + .ToArray(); + + if (headShas.Length == 0) + { + return []; + } + + var latestRunsByHeadSha = await GetLatestSuccessfulPreviewRunsByHeadShaAsync( + repositoryClient, + _options.RepositoryOwner, + _options.RepositoryName, + headShas, + cancellationToken); + + if (latestRunsByHeadSha.Count == 0) + { + return []; + } + + var previewablePullRequests = new ConcurrentDictionary(); + + await Parallel.ForEachAsync( + pullRequests, + new ParallelOptions + { + CancellationToken = cancellationToken, + MaxDegreeOfParallelism = 4 + }, + async (pullRequest, ct) => + { + var headSha = pullRequest.Head?.Sha; + if (string.IsNullOrWhiteSpace(headSha) + || !latestRunsByHeadSha.TryGetValue(headSha, out var workflowRun)) + { + return; + } + + var artifacts = await GetArtifactsAsync( + repositoryClient, + _options.RepositoryOwner, + _options.RepositoryName, + workflowRun.Id, + ct); + + if (ResolvePreviewArtifact(artifacts.Artifacts, pullRequest.Number) is not null) + { + previewablePullRequests.TryAdd(pullRequest.Number, 0); + } + }); + + return [.. previewablePullRequests.Keys]; + } + + private static async Task> GetLatestSuccessfulPreviewRunsByHeadShaAsync( + GitHubClient repositoryClient, + string repositoryOwner, + string repositoryName, + IReadOnlyCollection headShas, + CancellationToken cancellationToken) + { + var remainingHeadShas = headShas + .Where(static headSha => !string.IsNullOrWhiteSpace(headSha)) + .ToHashSet(StringComparer.Ordinal); + var workflowRunsByHeadSha = new Dictionary(StringComparer.Ordinal); + + if (remainingHeadShas.Count == 0) + { + return workflowRunsByHeadSha; + } + + for (var page = 1; remainingHeadShas.Count > 0; page++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var payload = await repositoryClient.Actions.Workflows.Runs.List( + repositoryOwner, + repositoryName, + new WorkflowRunsRequest + { + Event = "pull_request", + Status = CheckRunStatusFilter.Completed + }, + new ApiOptions + { + StartPage = page, + PageCount = 1, + PageSize = 100 + }); + + if (payload.WorkflowRuns.Count == 0) + { + break; + } + + foreach (var workflowRun in payload.WorkflowRuns.Where(IsSuccessfulPreviewRun)) + { + if (string.IsNullOrWhiteSpace(workflowRun.HeadSha) + || !remainingHeadShas.Remove(workflowRun.HeadSha)) + { + continue; + } + + workflowRunsByHeadSha[workflowRun.HeadSha] = workflowRun; + } + + if (payload.WorkflowRuns.Count < 100) + { + break; + } + } + + return workflowRunsByHeadSha; + } + + private async Task CreateRepositoryClientAsync( + string repositoryOwner, + string repositoryName, + CancellationToken cancellationToken) + { + if (_options.HasGitHubToken) + { + return CreateClient(new Credentials(_options.GitHubToken)); + } + + var installationToken = await GetInstallationTokenAsync(repositoryOwner, repositoryName, cancellationToken); + return CreateClient(new Credentials(installationToken.Token)); + } + + private async Task GetInstallationTokenAsync( + string repositoryOwner, + string repositoryName, + CancellationToken cancellationToken) + { + EnsureGitHubAppConfigured(); + + await _installationTokenGate.WaitAsync(cancellationToken); + try + { + if (_cachedInstallationToken is not null + && _cachedInstallationToken.ExpiresAt > DateTimeOffset.UtcNow.AddMinutes(2)) + { + return _cachedInstallationToken; + } + + var appClient = CreateGitHubAppClient(); + var installationId = _options.GitHubAppInstallationId > 0 + ? _options.GitHubAppInstallationId + : _cachedInstallationId.GetValueOrDefault(); + + if (installationId <= 0) + { + cancellationToken.ThrowIfCancellationRequested(); + installationId = (await appClient.GitHubApps.GetRepositoryInstallationForCurrent( + repositoryOwner, + repositoryName)).Id; + } + + _cachedInstallationId = installationId; + cancellationToken.ThrowIfCancellationRequested(); + _cachedInstallationToken = await appClient.GitHubApps.CreateInstallationToken(installationId); + return _cachedInstallationToken; + } + finally + { + _installationTokenGate.Release(); + } + } + + private GitHubClient CreateGitHubAppClient() + { + EnsureGitHubAppConfigured(); + + var jwtFactory = new GitHubJwtFactory( + new StringPrivateKeySource(NormalizePrivateKey(_options.GitHubAppPrivateKey)), + new GitHubJwtFactoryOptions + { + AppIntegrationId = _options.GitHubAppId, + ExpirationSeconds = 540 + }); + + var jwt = jwtFactory.CreateEncodedJwtToken(TimeSpan.FromMinutes(9)); + return CreateClient(new Credentials(jwt, AuthenticationType.Bearer)); + } + + private GitHubClient CreateClient(Credentials credentials) + { + var client = new GitHubClient( + new ProductHeaderValue("aspire-dev-preview-host"), + new Uri(_options.GitHubApiBaseUrl)) + { + Credentials = credentials + }; + + return client; + } + + private void EnsureCredentialsConfigured() + { + if (_options.HasGitHubToken) + { + return; + } + + EnsureGitHubAppConfigured(); + } + + private void EnsureGitHubAppConfigured() + { + if (!_options.HasGitHubAppConfiguration) + { + throw new InvalidOperationException( + $"Either '{PreviewHostOptions.SectionName}:GitHubToken' or both '{PreviewHostOptions.SectionName}:GitHubAppId' and '{PreviewHostOptions.SectionName}:GitHubAppPrivateKey' must be configured before GitHub authentication can be used."); + } + } + + private void EnsureRepositoryConfigured() + { + if (string.IsNullOrWhiteSpace(_options.RepositoryOwner) || string.IsNullOrWhiteSpace(_options.RepositoryName)) + { + throw new InvalidOperationException( + $"The '{PreviewHostOptions.SectionName}:RepositoryOwner' and '{PreviewHostOptions.SectionName}:RepositoryName' settings must be configured before previews can be discovered."); + } + } + + private static Artifact? ResolvePreviewArtifact(IEnumerable artifacts, int pullRequestNumber) + { + var preferredNames = new[] + { + $"frontend-dist-pr-{pullRequestNumber}", + DefaultPreviewArtifactName + }; + + foreach (var artifactName in preferredNames) + { + var artifact = artifacts.FirstOrDefault(candidate => + !candidate.Expired + && string.Equals(candidate.Name, artifactName, StringComparison.Ordinal)); + + if (artifact is not null) + { + return artifact; + } + } + + return null; + } + + private static bool IsSuccessfulPreviewRun(WorkflowRun run) => + string.Equals(run.Conclusion?.StringValue, "success", StringComparison.OrdinalIgnoreCase) + && (string.Equals(run.Path, CiWorkflowPath, StringComparison.Ordinal) + || string.Equals(run.Name, "CI", StringComparison.Ordinal)); + + private sealed class DownloadProgressPublisher : IAsyncDisposable + { + private readonly Func _progressCallback; + private readonly CancellationTokenSource _publisherCancellationSource; + private readonly SemaphoreSlim _publishGate = new(1, 1); + private readonly Task _publisherTask; + private readonly long? _totalBytes; + private long _latestBytes; + private long _publishedBytes = -1; + private int _completed; + + public DownloadProgressPublisher( + long? totalBytes, + Func progressCallback, + CancellationToken cancellationToken) + { + _totalBytes = totalBytes; + _progressCallback = progressCallback; + _publisherCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _publisherTask = Task.Run(() => PublishLoopAsync(_publisherCancellationSource.Token), CancellationToken.None); + } + + public void Report(long downloadedBytes) => Interlocked.Exchange(ref _latestBytes, downloadedBytes); + + public async Task CompleteAsync(long downloadedBytes) + { + Report(downloadedBytes); + + if (Interlocked.Exchange(ref _completed, 1) != 0) + { + return; + } + + await PublishLatestAsync(CancellationToken.None); + await StopAsync(); + } + + public async ValueTask DisposeAsync() + { + await StopAsync(); + _publishGate.Dispose(); + _publisherCancellationSource.Dispose(); + } + + private async Task PublishLoopAsync(CancellationToken cancellationToken) + { + using var timer = new PeriodicTimer(DownloadProgressPublishInterval); + + try + { + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + await PublishLatestAsync(cancellationToken); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + + private async Task PublishLatestAsync(CancellationToken cancellationToken) + { + await _publishGate.WaitAsync(cancellationToken); + try + { + var latestBytes = Interlocked.Read(ref _latestBytes); + if (latestBytes == _publishedBytes) + { + return; + } + + await _progressCallback(new PreviewDownloadProgress(latestBytes, _totalBytes), cancellationToken); + _publishedBytes = latestBytes; + } + finally + { + _publishGate.Release(); + } + } + + private async Task StopAsync() + { + _publisherCancellationSource.Cancel(); + + try + { + await _publisherTask; + } + catch (OperationCanceledException) when (_publisherCancellationSource.IsCancellationRequested) + { + } + } + } + + private static string NormalizePrivateKey(string privateKey) + { + var normalized = privateKey.Trim(); + normalized = normalized.Replace("\\r\\n", "\n", StringComparison.Ordinal); + normalized = normalized.Replace("\\n", "\n", StringComparison.Ordinal); + return normalized.Replace("\r\n", "\n", StringComparison.Ordinal); + } +} diff --git a/src/statichost/PreviewHost/Previews/PreviewBufferSettings.cs b/src/statichost/PreviewHost/Previews/PreviewBufferSettings.cs new file mode 100644 index 000000000..b049fc85b --- /dev/null +++ b/src/statichost/PreviewHost/Previews/PreviewBufferSettings.cs @@ -0,0 +1,92 @@ +namespace PreviewHost.Previews; + +internal readonly record struct PreviewBufferSettings( + int DownloadCopyBufferSize, + int DownloadFileBufferSize, + int ManagedZipReadBufferSize, + long AvailableMemoryBytes) +{ + private const int OneMiB = 1024 * 1024; + private const long DefaultAvailableMemoryBytes = 2L * 1024 * OneMiB; + + public static PreviewBufferSettings Resolve() + { + var availableMemoryBytes = GetAvailableMemoryBytes(); + + return availableMemoryBytes switch + { + <= 768L * OneMiB => new( + DownloadCopyBufferSize: 4 * OneMiB, + DownloadFileBufferSize: 1 * OneMiB, + ManagedZipReadBufferSize: 4 * OneMiB, + AvailableMemoryBytes: availableMemoryBytes), + + <= 1536L * OneMiB => new( + DownloadCopyBufferSize: 8 * OneMiB, + DownloadFileBufferSize: 2 * OneMiB, + ManagedZipReadBufferSize: 8 * OneMiB, + AvailableMemoryBytes: availableMemoryBytes), + + <= 3072L * OneMiB => new( + DownloadCopyBufferSize: 16 * OneMiB, + DownloadFileBufferSize: 4 * OneMiB, + ManagedZipReadBufferSize: 16 * OneMiB, + AvailableMemoryBytes: availableMemoryBytes), + + <= 6144L * OneMiB => new( + DownloadCopyBufferSize: 32 * OneMiB, + DownloadFileBufferSize: 8 * OneMiB, + ManagedZipReadBufferSize: 24 * OneMiB, + AvailableMemoryBytes: availableMemoryBytes), + + _ => new( + DownloadCopyBufferSize: 64 * OneMiB, + DownloadFileBufferSize: 16 * OneMiB, + ManagedZipReadBufferSize: 32 * OneMiB, + AvailableMemoryBytes: availableMemoryBytes) + }; + } + + public long AvailableMemoryMiB => AvailableMemoryBytes / OneMiB; + + public int DownloadCopyBufferMiB => DownloadCopyBufferSize / OneMiB; + + public int DownloadFileBufferMiB => DownloadFileBufferSize / OneMiB; + + public int ManagedZipReadBufferMiB => ManagedZipReadBufferSize / OneMiB; + + private static long GetAvailableMemoryBytes() + { + var gcInfo = GC.GetGCMemoryInfo(); + var gcBudgetBytes = NormalizePositive(gcInfo.TotalAvailableMemoryBytes); + var managedHeadroomBytes = gcBudgetBytes > 0 + ? Math.Max(gcBudgetBytes - GC.GetTotalMemory(forceFullCollection: false), 0) + : 0; + var loadHeadroomBytes = gcInfo.HighMemoryLoadThresholdBytes > 0 && gcInfo.MemoryLoadBytes > 0 + ? Math.Max(gcInfo.HighMemoryLoadThresholdBytes - gcInfo.MemoryLoadBytes, 0) + : 0; + + long availableMemoryBytes = 0; + + foreach (var candidate in new[] { managedHeadroomBytes, loadHeadroomBytes, gcBudgetBytes }) + { + if (candidate <= 0) + { + continue; + } + + availableMemoryBytes = availableMemoryBytes == 0 + ? candidate + : Math.Min(availableMemoryBytes, candidate); + } + + return availableMemoryBytes > 0 + ? availableMemoryBytes + : DefaultAvailableMemoryBytes; + } + + private static long NormalizePositive(long value) => + value > 0 && value < long.MaxValue + ? value + : 0; +} diff --git a/src/statichost/PreviewHost/Previews/PreviewCoordinator.cs b/src/statichost/PreviewHost/Previews/PreviewCoordinator.cs new file mode 100644 index 000000000..29e5b6584 --- /dev/null +++ b/src/statichost/PreviewHost/Previews/PreviewCoordinator.cs @@ -0,0 +1,997 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO.Compression; +using System.Net; +using Microsoft.Extensions.Options; + +namespace PreviewHost.Previews; + +internal sealed class PreviewCoordinator( + PreviewStateStore stateStore, + GitHubArtifactClient artifactClient, + IOptions options, + ILogger logger) +{ + private static readonly TimeSpan ProgressUpdateInterval = TimeSpan.FromSeconds(1); + private static readonly TimeSpan ExtractionProgressReconciliationInterval = TimeSpan.FromSeconds(15); + private const long DownloadProgressByteStride = 32L * 1024 * 1024; + private const int ExtractionProgressUnitStride = 100; + private const int PreparingWeight = 6; + private const int DownloadingWeight = 52; + private const int ExtractingWeight = 38; + private const int ValidatingWeight = 2; + private const int ActivatingWeight = 2; + private const int PreparingStart = 0; + private const int DownloadingStart = PreparingStart + PreparingWeight; + private const int ExtractingStart = DownloadingStart + DownloadingWeight; + private const int ValidatingStart = ExtractingStart + ExtractingWeight; + private const int ActivatingStart = ValidatingStart + ValidatingWeight; + private readonly ConcurrentDictionary> _activeDiscovery = []; + private readonly ConcurrentDictionary _activeLoadCancellations = []; + private readonly ConcurrentDictionary _activeLoads = []; + private readonly PreviewStateStore _stateStore = stateStore; + private readonly GitHubArtifactClient _artifactClient = artifactClient; + private readonly ILogger _logger = logger; + private readonly PreviewHostOptions _options = options.Value; + + public void EnsureLoading(int pullRequestNumber) + { + _activeLoads.GetOrAdd( + pullRequestNumber, + static (prNumber, state) => + { + var cancellationSource = new CancellationTokenSource(); + state._activeLoadCancellations[prNumber] = cancellationSource; + return Task.Run(() => state.LoadAsync(prNumber, cancellationSource.Token), CancellationToken.None); + }, + this); + } + + public async Task BootstrapAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + var snapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); + if (snapshot is null) + { + var discovery = await EnsureRegisteredAsync(pullRequestNumber, cancellationToken); + snapshot = discovery.Snapshot; + if (snapshot is null) + { + return discovery; + } + } + + if (snapshot.State is PreviewLoadState.Cancelled or PreviewLoadState.Failed or PreviewLoadState.Evicted) + { + snapshot = await _stateStore.RequeueAsync( + pullRequestNumber, + "Retrying preview preparation.", + cancellationToken) ?? snapshot; + } + + if (!snapshot.IsReady) + { + EnsureLoading(pullRequestNumber); + snapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken) ?? snapshot; + } + + return new PreviewDiscoveryResult(snapshot); + } + + public async Task RetryAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + var snapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); + if (snapshot is null) + { + return await BootstrapAsync(pullRequestNumber, cancellationToken); + } + + if (!snapshot.IsReady) + { + snapshot = await _stateStore.RequeueAsync( + pullRequestNumber, + "Retrying preview preparation.", + cancellationToken) ?? snapshot; + } + + EnsureLoading(pullRequestNumber); + snapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken) ?? snapshot; + return new PreviewDiscoveryResult(snapshot); + } + + public async Task CancelAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + var snapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); + if (snapshot is null) + { + return null; + } + + if (_activeLoadCancellations.TryGetValue(pullRequestNumber, out var cancellationSource)) + { + cancellationSource.Cancel(); + } + + if (!snapshot.IsReady) + { + await _stateStore.MarkCancelledAsync(pullRequestNumber, "Preview preparation was cancelled.", CancellationToken.None); + return await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); + } + + return snapshot; + } + + public async Task ResetAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + var snapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); + if (snapshot is null) + { + return false; + } + + if (_activeLoadCancellations.TryGetValue(pullRequestNumber, out var cancellationSource)) + { + cancellationSource.Cancel(); + } + + if (_activeLoads.TryGetValue(pullRequestNumber, out var activeLoad)) + { + try + { + await activeLoad.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + _logger.LogDebug(exception, "Preview load task ended while resetting PR #{PullRequestNumber}", pullRequestNumber); + } + } + + await _stateStore.RemoveAsync(pullRequestNumber, cancellationToken); + _logger.LogInformation("Reset preview for PR #{PullRequestNumber}", pullRequestNumber); + return true; + } + + public async Task EnsureRegisteredAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + var existingSnapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); + if (existingSnapshot is not null) + { + return new PreviewDiscoveryResult(existingSnapshot); + } + + var discoveryTask = _activeDiscovery.GetOrAdd( + pullRequestNumber, + static (prNumber, state) => Task.Run(() => state.DiscoverAsync(prNumber), CancellationToken.None), + this); + + return await discoveryTask.WaitAsync(cancellationToken); + } + + private async Task DiscoverAsync(int pullRequestNumber) + { + try + { + var existingSnapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, CancellationToken.None); + if (existingSnapshot is not null) + { + return new PreviewDiscoveryResult(existingSnapshot); + } + + var registrationRequest = await _artifactClient.TryResolveLatestPreviewRegistrationAsync(pullRequestNumber, CancellationToken.None); + if (registrationRequest is null) + { + return new PreviewDiscoveryResult( + Snapshot: null, + FailureMessage: "The preview host could not find a successful frontend artifact for this pull request yet."); + } + + var registrationResult = await _stateStore.RegisterAsync(registrationRequest, CancellationToken.None); + return new PreviewDiscoveryResult(registrationResult.Snapshot); + } + catch (Exception exception) + { + _logger.LogError(exception, "Failed to discover preview metadata for PR #{PullRequestNumber}", pullRequestNumber); + return new PreviewDiscoveryResult( + Snapshot: null, + FailureMessage: "The preview host could not look up the latest successful build for this pull request."); + } + finally + { + _activeDiscovery.TryRemove(pullRequestNumber, out _); + } + } + + private async Task LoadAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + string? temporaryZipPath = null; + string? stagingDirectoryPath = null; + var downloadProgress = new ProgressThrottle(); + + try + { + var workItem = await _stateStore.GetWorkItemAsync(pullRequestNumber, cancellationToken); + if (workItem is null) + { + return; + } + + var currentSnapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); + if (currentSnapshot?.State == PreviewLoadState.Ready) + { + return; + } + + await _stateStore.MarkLoadingAsync( + pullRequestNumber, + stage: "Preparing", + message: "Preparing the preview workspace.", + percent: CalculateWeightedPercent(PreparingStart, PreparingWeight, 15), + stagePercent: 15, + cancellationToken: cancellationToken); + + await EnsureCapacityAsync(pullRequestNumber, cancellationToken); + + temporaryZipPath = _stateStore.GetTemporaryZipPath(workItem); + stagingDirectoryPath = _stateStore.GetStagingDirectoryPath(workItem); + + if (File.Exists(temporaryZipPath)) + { + File.Delete(temporaryZipPath); + } + + if (Directory.Exists(stagingDirectoryPath)) + { + Directory.Delete(stagingDirectoryPath, recursive: true); + } + + Directory.CreateDirectory(Path.GetDirectoryName(temporaryZipPath)!); + Directory.CreateDirectory(stagingDirectoryPath); + + var artifact = await _artifactClient.GetArtifactDescriptorAsync(workItem, cancellationToken); + await _stateStore.UpdateProgressAsync( + pullRequestNumber, + stage: "Downloading", + message: $"Downloading {artifact.ArtifactName} from GitHub Actions.", + percent: CalculateWeightedPercent(DownloadingStart, DownloadingWeight, 0), + stagePercent: 0, + bytesDownloaded: 0, + bytesTotal: null, + itemsCompleted: null, + itemsTotal: null, + itemsLabel: null, + cancellationToken: cancellationToken); + + await _artifactClient.DownloadArtifactAsync( + artifact, + temporaryZipPath, + async (progress, progressCancellationToken) => + { + var stagePercent = CalculateDownloadStagePercent(progress); + var percent = CalculateWeightedPercent(DownloadingStart, DownloadingWeight, stagePercent); + var isComplete = progress.BytesTotal is > 0 && progress.BytesDownloaded >= progress.BytesTotal.Value; + if (!downloadProgress.ShouldPublish( + stage: "Downloading", + percent: percent, + bytesDownloaded: progress.BytesDownloaded, + isTerminal: isComplete)) + { + return; + } + + await _stateStore.UpdateProgressAsync( + pullRequestNumber, + stage: "Downloading", + message: "Downloading the latest preview artifact.", + percent: percent, + stagePercent: stagePercent, + bytesDownloaded: progress.BytesDownloaded, + bytesTotal: progress.BytesTotal, + itemsCompleted: null, + itemsTotal: null, + itemsLabel: null, + progressCancellationToken); + }, + cancellationToken); + + if (!await _stateStore.MatchesBuildAsync(pullRequestNumber, workItem, cancellationToken)) + { + return; + } + + var extractedFileCount = await ExtractArchiveAsync(workItem, temporaryZipPath, stagingDirectoryPath, cancellationToken); + + var activationSourceDirectory = ResolveActivationSourceDirectory(stagingDirectoryPath); + var entryIndexPath = Path.Combine(activationSourceDirectory, "index.html"); + if (!File.Exists(entryIndexPath)) + { + throw new InvalidOperationException("The downloaded preview artifact did not contain an index.html entry."); + } + + if (!await _stateStore.MatchesBuildAsync(pullRequestNumber, workItem, cancellationToken)) + { + return; + } + + await _stateStore.UpdateProgressAsync( + pullRequestNumber, + stage: "Activating", + message: "Activating the preview and switching traffic to the new build.", + percent: CalculateWeightedPercent(ActivatingStart, ActivatingWeight, 50), + stagePercent: 100, + bytesDownloaded: null, + bytesTotal: null, + itemsCompleted: extractedFileCount, + itemsTotal: extractedFileCount, + itemsLabel: "files", + cancellationToken); + + var activeDirectoryPath = _stateStore.GetActiveDirectoryPath(pullRequestNumber); + if (Directory.Exists(activeDirectoryPath)) + { + Directory.Delete(activeDirectoryPath, recursive: true); + } + + Directory.CreateDirectory(Path.GetDirectoryName(activeDirectoryPath)!); + Directory.Move(activationSourceDirectory, activeDirectoryPath); + stagingDirectoryPath = null; + + await _stateStore.MarkReadyAsync(pullRequestNumber, activeDirectoryPath, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _logger.LogInformation("Cancelled preview preparation for PR #{PullRequestNumber}", pullRequestNumber); + await _stateStore.MarkCancelledAsync(pullRequestNumber, "Preview preparation was cancelled.", CancellationToken.None); + } + catch (Exception exception) + { + _logger.LogError(exception, "Failed to prepare preview for PR #{PullRequestNumber}", pullRequestNumber); + await _stateStore.MarkFailedAsync(pullRequestNumber, BuildFriendlyErrorMessage(exception), CancellationToken.None); + } + finally + { + if (_activeLoadCancellations.TryRemove(pullRequestNumber, out var cancellationSource)) + { + cancellationSource.Dispose(); + } + + _activeLoads.TryRemove(pullRequestNumber, out _); + + DeleteFileIfPresent(temporaryZipPath); + DeleteDirectoryIfPresent(stagingDirectoryPath); + } + } + + private async Task EnsureCapacityAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + if (_options.MaxActivePreviews <= 0) + { + return; + } + + var readyCandidates = await _stateStore.ListReadyCandidatesAsync(pullRequestNumber, cancellationToken); + var excessCount = readyCandidates.Count - (_options.MaxActivePreviews - 1); + + if (excessCount <= 0) + { + return; + } + + foreach (var candidate in readyCandidates.Take(excessCount)) + { + await _stateStore.EvictAsync( + candidate.PullRequestNumber, + reason: "Evicted to make room for a more recently requested preview.", + cancellationToken: cancellationToken); + } + } + + private async Task ExtractArchiveAsync( + PreviewWorkItem workItem, + string zipPath, + string destinationDirectory, + CancellationToken cancellationToken) + { + var extractionToolDescription = _options.ExtractionToolDescription; + var extractionStopwatch = Stopwatch.StartNew(); + var extractionMessage = $"Extracting preview files with {extractionToolDescription}."; + var totalFileCount = CountArchiveFileEntries(zipPath); + var bufferSettings = PreviewBufferSettings.Resolve(); + using var extractionProgressState = new ExtractionProgressState(totalFileCount); + + await _stateStore.UpdateProgressAsync( + workItem.PullRequestNumber, + stage: "Extracting", + message: extractionMessage, + percent: CalculateWeightedPercent(ExtractingStart, ExtractingWeight, 0), + stagePercent: 0, + bytesDownloaded: null, + bytesTotal: null, + itemsCompleted: 0, + itemsTotal: totalFileCount, + itemsLabel: "files", + cancellationToken); + + Directory.CreateDirectory(destinationDirectory); + + using var extractionWatcher = CreateExtractionFileWatcher(destinationDirectory, extractionProgressState); + using var extractionReportingCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var extractionReportingTask = ReportExtractionProgressAsync( + workItem, + extractionMessage, + destinationDirectory, + extractionProgressState, + extractionReportingCancellation.Token); + + try + { + if (_options.UseCommandLineExtraction) + { + await ExtractArchiveWithCommandLineAsync(zipPath, destinationDirectory, cancellationToken); + } + else + { + _logger.LogInformation( + "Extracting preview artifact with adaptive managed buffer: {ManagedZipReadBufferMiB} MiB (~{AvailableMemoryMiB} MiB headroom)", + bufferSettings.ManagedZipReadBufferMiB, + bufferSettings.AvailableMemoryMiB); + + using var zipStream = new FileStream( + zipPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: bufferSettings.ManagedZipReadBufferSize, + options: FileOptions.Asynchronous | FileOptions.SequentialScan); + using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: false); + await archive.ExtractToDirectoryAsync(destinationDirectory, overwriteFiles: true, cancellationToken); + } + } + finally + { + extractionProgressState.ReconcileWithFilesystem(destinationDirectory, force: true); + extractionReportingCancellation.Cancel(); + await AwaitBackgroundWorkAsync(extractionReportingTask); + } + + extractionStopwatch.Stop(); + _logger.LogInformation( + "Extracted preview artifact for PR #{PullRequestNumber} using {ExtractionTool} in {ElapsedMilliseconds} ms", + workItem.PullRequestNumber, + extractionToolDescription, + extractionStopwatch.ElapsedMilliseconds); + + await _stateStore.UpdateProgressAsync( + workItem.PullRequestNumber, + stage: "Extracting", + message: "Preview artifact extracted.", + percent: CalculateWeightedPercent(ExtractingStart, ExtractingWeight, 100), + stagePercent: 100, + bytesDownloaded: null, + bytesTotal: null, + itemsCompleted: totalFileCount, + itemsTotal: totalFileCount, + itemsLabel: "files", + cancellationToken); + + await _stateStore.UpdateProgressAsync( + workItem.PullRequestNumber, + stage: "Validating", + message: "Validating the extracted preview output.", + percent: CalculateWeightedPercent(ValidatingStart, ValidatingWeight, 100), + stagePercent: 100, + bytesDownloaded: null, + bytesTotal: null, + itemsCompleted: totalFileCount, + itemsTotal: totalFileCount, + itemsLabel: "files", + cancellationToken); + + return totalFileCount; + } + + private async Task ExtractArchiveWithCommandLineAsync( + string zipPath, + string destinationDirectory, + CancellationToken cancellationToken) + { + var toolDescription = _options.ExtractionToolDescription; + _logger.LogInformation("Extracting preview artifact via {ExtractionTool}", toolDescription); + + if (OperatingSystem.IsWindows()) + { + await RunExtractionProcessAsync( + "tar.exe", + toolDescription, + cancellationToken, + "-xf", + zipPath, + "-C", + destinationDirectory); + return; + } + + await RunExtractionProcessAsync( + "unzip", + toolDescription, + cancellationToken, + "-o", + "-qq", + zipPath, + "-d", + destinationDirectory); + } + + private async Task RunExtractionProcessAsync( + string fileName, + string toolDescription, + CancellationToken cancellationToken, + params string[] arguments) + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = fileName, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + } + }; + + foreach (var argument in arguments) + { + process.StartInfo.ArgumentList.Add(argument); + } + + try + { + if (!process.Start()) + { + throw new InvalidOperationException($"The preview host could not start {toolDescription} for command-line extraction."); + } + } + catch (Exception exception) when (exception is FileNotFoundException or System.ComponentModel.Win32Exception) + { + throw new InvalidOperationException( + $"The preview host is configured for command-line extraction, but {toolDescription} is not available on this machine.", + exception); + } + + var commandDisplay = BuildCommandDisplayString(fileName, arguments); + var outputTail = new ProcessOutputTail(maxLines: 32); + _logger.LogInformation("Starting extraction command for {ExtractionTool}: {Command}", toolDescription, commandDisplay); + + var standardOutputTask = PumpProcessStreamAsync( + process.StandardOutput, + toolDescription, + "stdout", + LogLevel.Information, + outputTail, + emitLiveLogs: false); + var standardErrorTask = PumpProcessStreamAsync( + process.StandardError, + toolDescription, + "stderr", + LogLevel.Warning, + outputTail, + emitLiveLogs: true); + using var cancellationRegistration = cancellationToken.Register(static state => TryKillProcess((Process)state!), process); + + try + { + await process.WaitForExitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + TryKillProcess(process); + await process.WaitForExitAsync(CancellationToken.None); + throw; + } + + await Task.WhenAll(standardOutputTask, standardErrorTask); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"The preview host could not extract the artifact using {toolDescription}. Exit code {process.ExitCode}. {BuildProcessFailureOutput(outputTail.BuildSummary())}"); + } + + _logger.LogInformation("Extraction command completed successfully via {ExtractionTool}", toolDescription); + } + + private async Task PumpProcessStreamAsync( + StreamReader reader, + string toolDescription, + string streamName, + LogLevel logLevel, + ProcessOutputTail outputTail, + bool emitLiveLogs) + { + while (await reader.ReadLineAsync() is { } line) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + outputTail.Add(streamName, line); + if (emitLiveLogs) + { + _logger.Log(logLevel, "{ExtractionTool} {StreamName}: {Line}", toolDescription, streamName, line); + } + } + } + + private static string BuildProcessFailureOutput(string detail) + { + var normalizedDetail = string.IsNullOrWhiteSpace(detail) + ? "The extraction tool did not emit any additional output." + : detail.Trim(); + + const int maxLength = 600; + return normalizedDetail.Length <= maxLength + ? normalizedDetail + : $"{normalizedDetail[..maxLength]}..."; + } + + private async Task ReportExtractionProgressAsync( + PreviewWorkItem workItem, + string extractionMessage, + string destinationDirectory, + ExtractionProgressState extractionProgressState, + CancellationToken cancellationToken) + { + using var timer = new PeriodicTimer(ProgressUpdateInterval); + var progressThrottle = new ProgressThrottle(); + + try + { + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + extractionProgressState.ReconcileWithFilesystem(destinationDirectory, force: false); + var completedFiles = extractionProgressState.GetCompletedFiles(); + var stagePercent = CalculateExtractionStagePercent(completedFiles, extractionProgressState.TotalFiles); + var percent = CalculateWeightedPercent(ExtractingStart, ExtractingWeight, stagePercent); + var isComplete = completedFiles >= extractionProgressState.TotalFiles; + + if (!progressThrottle.ShouldPublish( + stage: "Extracting", + percent: percent, + isTerminal: isComplete, + unitsCompleted: completedFiles, + unitStride: ExtractionProgressUnitStride)) + { + continue; + } + + await _stateStore.UpdateProgressAsync( + workItem.PullRequestNumber, + stage: "Extracting", + message: extractionMessage, + percent: percent, + stagePercent: stagePercent, + bytesDownloaded: null, + bytesTotal: null, + itemsCompleted: completedFiles, + itemsTotal: extractionProgressState.TotalFiles, + itemsLabel: "files", + cancellationToken); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + + private static int CountArchiveFileEntries(string zipPath) + { + using var archive = ZipFile.OpenRead(zipPath); + return archive.Entries.Count(static entry => !string.IsNullOrEmpty(entry.Name)); + } + + private static FileSystemWatcher CreateExtractionFileWatcher(string destinationDirectory, ExtractionProgressState extractionProgressState) + { + var watcher = new FileSystemWatcher(destinationDirectory) + { + IncludeSubdirectories = true, + NotifyFilter = NotifyFilters.FileName, + InternalBufferSize = 64 * 1024 + }; + + watcher.Created += (_, eventArgs) => extractionProgressState.TryTrackFile(eventArgs.FullPath); + watcher.Renamed += (_, eventArgs) => extractionProgressState.TryTrackFile(eventArgs.FullPath); + watcher.Error += (_, _) => extractionProgressState.MarkNeedsReconciliation(); + watcher.EnableRaisingEvents = true; + return watcher; + } + + private static string BuildCommandDisplayString(string fileName, IEnumerable arguments) => + string.Join( + ' ', + new[] { QuoteCommandSegment(fileName) }.Concat(arguments.Select(QuoteCommandSegment))); + + private static string QuoteCommandSegment(string value) => + string.IsNullOrWhiteSpace(value) || value.IndexOfAny([' ', '\t', '"']) >= 0 + ? $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\"" + : value; + + private static int CalculateExtractionStagePercent(int completedFiles, int totalFiles) + { + if (totalFiles <= 0) + { + return 0; + } + + var rawPercent = (double)completedFiles / totalFiles; + return Math.Clamp((int)Math.Round(rawPercent * 100d), 0, 100); + } + + private static int CalculateDownloadStagePercent(PreviewDownloadProgress progress) + { + if (progress.BytesTotal is > 0) + { + var rawPercent = (double)progress.BytesDownloaded / progress.BytesTotal.Value; + return Math.Clamp((int)Math.Round(rawPercent * 100d), 0, 100); + } + + return 0; + } + + private static int CalculateWeightedPercent(int stageStart, int stageWeight, int stagePercent) + { + var clampedStagePercent = Math.Clamp(stagePercent, 0, 100); + var weightedProgress = (int)Math.Round(stageWeight * (clampedStagePercent / 100d)); + return Math.Clamp(stageStart + weightedProgress, 0, 99); + } + + private static string BuildFriendlyErrorMessage(Exception exception) + { + if (exception is InvalidOperationException invalidOperationException) + { + if (invalidOperationException.Message.Contains("GitHubToken", StringComparison.Ordinal) + || invalidOperationException.Message.Contains("GitHubAppId", StringComparison.Ordinal) + || invalidOperationException.Message.Contains("GitHubAppPrivateKey", StringComparison.Ordinal)) + { + return "The preview host is missing its GitHub artifact-read credential."; + } + + if (invalidOperationException.Message.Contains("RepositoryOwner", StringComparison.Ordinal) + || invalidOperationException.Message.Contains("RepositoryName", StringComparison.Ordinal)) + { + return "The preview host is missing its GitHub repository configuration."; + } + + if (invalidOperationException.Message.Contains("artifact", StringComparison.OrdinalIgnoreCase)) + { + return "The requested preview artifact could not be found or is no longer available."; + } + + if (invalidOperationException.Message.Contains("tar.exe", StringComparison.OrdinalIgnoreCase) + || invalidOperationException.Message.Contains("unzip", StringComparison.OrdinalIgnoreCase) + || invalidOperationException.Message.Contains("command-line extraction", StringComparison.OrdinalIgnoreCase)) + { + return "The preview host could not extract the artifact using its configured extraction tool."; + } + } + + if (exception is HttpRequestException httpRequestException && httpRequestException.StatusCode == HttpStatusCode.Unauthorized) + { + return "The preview host could not authenticate with GitHub to download this artifact."; + } + + if (exception is HttpRequestException { StatusCode: HttpStatusCode.NotFound or HttpStatusCode.Gone }) + { + return "The requested preview artifact could not be found or has already expired."; + } + + return "The preview could not be prepared. Check the preview host logs for more details."; + } + + private static string ResolveActivationSourceDirectory(string extractedRoot) + { + var topLevelIndexPath = Path.Combine(extractedRoot, "index.html"); + if (File.Exists(topLevelIndexPath)) + { + return extractedRoot; + } + + var nestedDirectory = Directory.EnumerateDirectories(extractedRoot) + .Select(static directory => new + { + Directory = directory, + IndexPath = Path.Combine(directory, "index.html") + }) + .FirstOrDefault(static candidate => File.Exists(candidate.IndexPath)); + + return nestedDirectory?.Directory ?? extractedRoot; + } + + private static void DeleteFileIfPresent(string? path) + { + if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) + { + File.Delete(path); + } + } + + private static void DeleteDirectoryIfPresent(string? path) + { + if (!string.IsNullOrWhiteSpace(path) && Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + + private static void TryKillProcess(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch (InvalidOperationException) + { + } + } + + private static async Task AwaitBackgroundWorkAsync(Task task) + { + try + { + await task; + } + catch (OperationCanceledException) + { + } + } + + private sealed class ProcessOutputTail(int maxLines) + { + private readonly object _gate = new(); + private readonly Queue _lines = new(); + private readonly int _maxLines = maxLines; + + public void Add(string streamName, string line) + { + lock (_gate) + { + _lines.Enqueue($"{streamName}: {line}"); + while (_lines.Count > _maxLines) + { + _lines.Dequeue(); + } + } + } + + public string BuildSummary() + { + lock (_gate) + { + return string.Join(Environment.NewLine, _lines); + } + } + } + + private sealed class ExtractionProgressState(int totalFiles) : IDisposable + { + private readonly object _gate = new(); + private readonly HashSet _seenFiles = new(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + private int _needsReconciliation; + private DateTimeOffset _lastReconciledAtUtc = DateTimeOffset.MinValue; + + public int TotalFiles { get; } = totalFiles; + + public int GetCompletedFiles() + { + lock (_gate) + { + return _seenFiles.Count; + } + } + + public void TryTrackFile(string path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + return; + } + + lock (_gate) + { + _seenFiles.Add(path); + } + } + + public void MarkNeedsReconciliation() => Interlocked.Exchange(ref _needsReconciliation, 1); + + public void ReconcileWithFilesystem(string destinationDirectory, bool force) + { + var needsReconciliation = Interlocked.Exchange(ref _needsReconciliation, 0) != 0; + var now = DateTimeOffset.UtcNow; + + if (!force + && !needsReconciliation + && now - _lastReconciledAtUtc < ExtractionProgressReconciliationInterval) + { + return; + } + + if (!force && GetCompletedFiles() >= TotalFiles) + { + return; + } + + var files = Directory.Exists(destinationDirectory) + ? Directory.EnumerateFiles(destinationDirectory, "*", SearchOption.AllDirectories) + : []; + + lock (_gate) + { + _seenFiles.Clear(); + foreach (var file in files) + { + _seenFiles.Add(file); + } + } + + _lastReconciledAtUtc = now; + } + + public void Dispose() + { + } + } + + private sealed class ProgressThrottle + { + private string? _lastStage; + private int _lastPercent = -1; + private long _lastBytesDownloaded = -1; + private int _lastUnitsCompleted; + private DateTimeOffset _lastPublishedAtUtc = DateTimeOffset.MinValue; + + public bool ShouldPublish( + string stage, + int percent, + bool isTerminal, + long? bytesDownloaded = null, + int unitsCompleted = 0, + int unitStride = 0) + { + var now = DateTimeOffset.UtcNow; + if (_lastPublishedAtUtc == DateTimeOffset.MinValue) + { + Publish(stage, percent, bytesDownloaded, unitsCompleted, now); + return true; + } + + if (!string.Equals(stage, _lastStage, StringComparison.Ordinal) + || isTerminal + || percent > _lastPercent + || now - _lastPublishedAtUtc >= ProgressUpdateInterval + || bytesDownloaded is >= 0 && bytesDownloaded.Value - _lastBytesDownloaded >= DownloadProgressByteStride + || unitStride > 0 && unitsCompleted - _lastUnitsCompleted >= unitStride) + { + Publish(stage, percent, bytesDownloaded, unitsCompleted, now); + return true; + } + + return false; + } + + private void Publish(string stage, int percent, long? bytesDownloaded, int unitsCompleted, DateTimeOffset publishedAtUtc) + { + _lastStage = stage; + _lastPercent = percent; + _lastBytesDownloaded = bytesDownloaded ?? _lastBytesDownloaded; + _lastUnitsCompleted = unitsCompleted; + _lastPublishedAtUtc = publishedAtUtc; + } + } +} diff --git a/src/statichost/PreviewHost/Previews/PreviewModels.cs b/src/statichost/PreviewHost/Previews/PreviewModels.cs new file mode 100644 index 000000000..0336c9364 --- /dev/null +++ b/src/statichost/PreviewHost/Previews/PreviewModels.cs @@ -0,0 +1,284 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace PreviewHost.Previews; + +internal sealed class PreviewHostOptions +{ + public const string SectionName = "PreviewHost"; + + public string? DataRoot { get; set; } + + public string? ContentRoot { get; set; } + + public string RepositoryOwner { get; set; } = "microsoft"; + + public string RepositoryName { get; set; } = "aspire.dev"; + + public int MaxActivePreviews { get; set; } = 10; + + public string RegistrationToken { get; set; } = string.Empty; + + public string GitHubToken { get; set; } = string.Empty; + + public int GitHubAppId { get; set; } + + public long GitHubAppInstallationId { get; set; } + + public string GitHubAppPrivateKey { get; set; } = string.Empty; + + public string GitHubApiBaseUrl { get; set; } = "https://api.github.com/"; + + public string ExtractionMode { get; set; } = "managed"; + + [JsonIgnore] + public bool HasGitHubToken => !string.IsNullOrWhiteSpace(GitHubToken); + + [JsonIgnore] + public bool HasGitHubAppConfiguration => + GitHubAppId > 0 + && !string.IsNullOrWhiteSpace(GitHubAppPrivateKey); + + [JsonIgnore] + public bool HasValidExtractionMode => + string.Equals(ExtractionMode, "managed", StringComparison.OrdinalIgnoreCase) + || string.Equals(ExtractionMode, "command-line", StringComparison.OrdinalIgnoreCase); + + [JsonIgnore] + public bool UseCommandLineExtraction => + string.Equals(ExtractionMode, "command-line", StringComparison.OrdinalIgnoreCase); + + [JsonIgnore] + public string ExtractionToolDescription => + UseCommandLineExtraction + ? OperatingSystem.IsWindows() + ? "tar.exe" + : "unzip" + : "ZipArchive.ExtractToDirectoryAsync"; + + [JsonIgnore] + public string? CommandLineExtractionCommandName => + UseCommandLineExtraction + ? OperatingSystem.IsWindows() + ? "tar.exe" + : "unzip" + : null; + + public string GetGitHubAuthenticationMode() => + HasGitHubToken + ? "personal-access-token" + : HasGitHubAppConfiguration + ? "github-app" + : "unconfigured"; + + public string GetExtractionModeDescription() => + UseCommandLineExtraction + ? $"command-line ({ExtractionToolDescription})" + : $"managed ({ExtractionToolDescription})"; +} + +internal sealed class PreviewRegistrationRequest +{ + [Required] + public string RepositoryOwner { get; set; } = string.Empty; + + [Required] + public string RepositoryName { get; set; } = string.Empty; + + [Range(1, int.MaxValue)] + public int PullRequestNumber { get; set; } + + [Required] + public string HeadSha { get; set; } = string.Empty; + + [Range(1, long.MaxValue)] + public long RunId { get; set; } + + [Range(1, int.MaxValue)] + public int RunAttempt { get; set; } + + [Required] + public string ArtifactName { get; set; } = string.Empty; + + public DateTimeOffset CompletedAtUtc { get; set; } +} + +internal enum PreviewLoadState +{ + Registered, + Loading, + Ready, + Failed, + Evicted, + Cancelled +} + +internal sealed class PreviewProgress +{ + public long Version { get; set; } + + public string Stage { get; set; } = "Registered"; + + public string Message { get; set; } = "A preview has been registered and will be prepared on demand."; + + public int Percent { get; set; } + + public int StagePercent { get; set; } + + public long? BytesDownloaded { get; set; } + + public long? BytesTotal { get; set; } + + public int? ItemsCompleted { get; set; } + + public int? ItemsTotal { get; set; } + + public string? ItemsLabel { get; set; } + + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +internal sealed class PreviewRecord +{ + public string RepositoryOwner { get; set; } = string.Empty; + + public string RepositoryName { get; set; } = string.Empty; + + public int PullRequestNumber { get; set; } + + public string HeadSha { get; set; } = string.Empty; + + public long RunId { get; set; } + + public int RunAttempt { get; set; } + + public string ArtifactName { get; set; } = string.Empty; + + public DateTimeOffset CompletedAtUtc { get; set; } + + public PreviewLoadState State { get; set; } = PreviewLoadState.Registered; + + public DateTimeOffset RegisteredAtUtc { get; set; } = DateTimeOffset.UtcNow; + + public DateTimeOffset LastUpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + + public DateTimeOffset? LastAccessedAtUtc { get; set; } + + public DateTimeOffset? ReadyAtUtc { get; set; } + + public string? ActiveDirectoryPath { get; set; } + + public string? LastError { get; set; } + + public PreviewProgress Progress { get; set; } = new(); + + [JsonIgnore] + public string PreviewPath => PreviewRoute.BuildPath(PullRequestNumber); + + public PreviewStatusSnapshot ToSnapshot() => + new() + { + RepositoryOwner = RepositoryOwner, + RepositoryName = RepositoryName, + PullRequestNumber = PullRequestNumber, + HeadSha = HeadSha, + State = State, + Stage = Progress.Stage, + Message = Progress.Message, + Percent = Progress.Percent, + StagePercent = Progress.StagePercent, + Version = Progress.Version, + BytesDownloaded = Progress.BytesDownloaded, + BytesTotal = Progress.BytesTotal, + ItemsCompleted = Progress.ItemsCompleted, + ItemsTotal = Progress.ItemsTotal, + ItemsLabel = Progress.ItemsLabel, + Error = LastError, + UpdatedAtUtc = Progress.UpdatedAtUtc, + PreviewPath = PreviewPath, + ActiveDirectoryPath = ActiveDirectoryPath + }; +} + +internal sealed class PreviewStatusSnapshot +{ + public string RepositoryOwner { get; init; } = string.Empty; + + public string RepositoryName { get; init; } = string.Empty; + + public int PullRequestNumber { get; init; } + + public string HeadSha { get; init; } = string.Empty; + + public PreviewLoadState State { get; init; } + + public string Stage { get; init; } = string.Empty; + + public string Message { get; init; } = string.Empty; + + public int Percent { get; init; } + + public int StagePercent { get; init; } + + public long Version { get; init; } + + public long? BytesDownloaded { get; init; } + + public long? BytesTotal { get; init; } + + public int? ItemsCompleted { get; init; } + + public int? ItemsTotal { get; init; } + + public string? ItemsLabel { get; init; } + + public string? Error { get; init; } + + public DateTimeOffset UpdatedAtUtc { get; init; } + + public string PreviewPath { get; init; } = string.Empty; + + public string? ActiveDirectoryPath { get; init; } + + public bool IsReady => State == PreviewLoadState.Ready; +} + +internal sealed record PreviewRegistrationResult(bool Accepted, PreviewStatusSnapshot Snapshot); + +internal sealed record PreviewWorkItem( + string RepositoryOwner, + string RepositoryName, + int PullRequestNumber, + string HeadSha, + long RunId, + int RunAttempt, + string ArtifactName, + DateTimeOffset CompletedAtUtc); + +internal sealed record ReadyPreviewCandidate( + int PullRequestNumber, + string ActiveDirectoryPath, + DateTimeOffset SortKey); + +internal sealed record PreviewDownloadProgress(long BytesDownloaded, long? BytesTotal); + +internal sealed record GitHubArtifactDescriptor( + string RepositoryOwner, + string RepositoryName, + long ArtifactId, + string ArtifactName, + DateTimeOffset ExpiresAtUtc, + long? SizeInBytes = null); + +internal sealed record PreviewDiscoveryResult(PreviewStatusSnapshot? Snapshot, string? FailureMessage = null); + +internal sealed record GitHubPullRequestSummary( + int PullRequestNumber, + string Title, + string HtmlUrl, + string HeadSha, + string? AuthorLogin, + bool IsDraft, + DateTimeOffset CreatedAtUtc, + DateTimeOffset UpdatedAtUtc, + bool HasSuccessfulPreviewBuild); diff --git a/src/statichost/PreviewHost/Previews/PreviewRequestDispatcher.cs b/src/statichost/PreviewHost/Previews/PreviewRequestDispatcher.cs new file mode 100644 index 000000000..744b121ea --- /dev/null +++ b/src/statichost/PreviewHost/Previews/PreviewRequestDispatcher.cs @@ -0,0 +1,1244 @@ +using System.Net; +using System.Globalization; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace PreviewHost.Previews; + +internal sealed class PreviewRequestDispatcher( + PreviewStateStore stateStore, + IWebHostEnvironment environment, + IOptions options) +{ + private static readonly JsonSerializerOptions WebJsonOptions = CreateJsonOptions(); + private static readonly string AspireFaviconDataUri = $"data:image/svg+xml,{Uri.EscapeDataString(AspireLogoSvg)}"; + private readonly PreviewHostOptions _options = options.Value; + private readonly string _previewShellRoot = Path.Combine( + string.IsNullOrWhiteSpace(environment.WebRootPath) + ? Path.Combine(AppContext.BaseDirectory, "wwwroot") + : environment.WebRootPath, + "_preview"); + + public async Task DispatchIndexAsync(HttpContext context, CancellationToken cancellationToken) + { + await WritePreviewShellAsync(context, "index.html", cancellationToken); + } + + public async Task DispatchAsync(HttpContext context, int pullRequestNumber, string relativePath, CancellationToken cancellationToken) + { + var snapshot = await stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); + + if (snapshot is null || !snapshot.IsReady || string.IsNullOrWhiteSpace(snapshot.ActiveDirectoryPath)) + { + if (!string.IsNullOrEmpty(relativePath)) + { + context.Response.Redirect(PreviewRoute.BuildPath(pullRequestNumber)); + return; + } + + await WritePreviewShellAsync(context, "status.html", cancellationToken); + return; + } + + await stateStore.TouchAsync(pullRequestNumber, cancellationToken); + + var resolvedFile = ResolvePreviewFile(snapshot.ActiveDirectoryPath, relativePath); + if (resolvedFile is null) + { + var fallback404Path = Path.Combine(snapshot.ActiveDirectoryPath, "404.html"); + if (File.Exists(fallback404Path)) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + await ServeFileAsync(context, fallback404Path, "text/html; charset=utf-8", "no-cache, no-store, must-revalidate", cancellationToken); + return; + } + + context.Response.StatusCode = StatusCodes.Status404NotFound; + await WriteHtmlAsync(context, BuildInfoPage( + title: $"PR #{pullRequestNumber} file not found", + body: "The requested page was not found in the prepared preview artifact."), cancellationToken); + return; + } + + await ServeResolvedFileAsync(context, snapshot, resolvedFile, cancellationToken); + } + + private static async Task ServeFileAsync( + HttpContext context, + string filePath, + string contentType, + string cacheControl, + CancellationToken cancellationToken) + { + context.Response.Headers.CacheControl = cacheControl; + context.Response.ContentType = contentType; + await context.Response.SendFileAsync(filePath, cancellationToken); + } + + private async Task ServeResolvedFileAsync( + HttpContext context, + PreviewStatusSnapshot snapshot, + ResolvedPreviewFile resolvedFile, + CancellationToken cancellationToken) + { + if (!ShouldRewritePreviewContent(resolvedFile.ContentType, snapshot.PreviewPath)) + { + await ServeFileAsync(context, resolvedFile.FilePath, resolvedFile.ContentType, resolvedFile.CacheControl, cancellationToken); + return; + } + + var originalContent = await File.ReadAllTextAsync(resolvedFile.FilePath, cancellationToken); + var rewrittenContent = RewritePreviewContent(originalContent, resolvedFile.ContentType, snapshot.PreviewPath); + + context.Response.Headers.CacheControl = resolvedFile.CacheControl; + context.Response.ContentType = resolvedFile.ContentType; + await context.Response.WriteAsync(rewrittenContent, cancellationToken); + } + + private static ResolvedPreviewFile? ResolvePreviewFile(string previewRoot, string relativePath) + { + var normalizedPath = string.IsNullOrWhiteSpace(relativePath) + ? "index.html" + : relativePath.Replace('\\', '/').TrimStart('/'); + + if (string.IsNullOrWhiteSpace(normalizedPath)) + { + normalizedPath = "index.html"; + } + + if (normalizedPath.EndsWith("/", StringComparison.Ordinal)) + { + normalizedPath = $"{normalizedPath}index.html"; + } + + if (normalizedPath.Contains("..", StringComparison.Ordinal)) + { + return null; + } + + var candidatePath = Path.GetFullPath(Path.Combine(previewRoot, normalizedPath.Replace('/', Path.DirectorySeparatorChar))); + var fullPreviewRoot = Path.GetFullPath(previewRoot + Path.DirectorySeparatorChar); + + if (!candidatePath.StartsWith(fullPreviewRoot, StringComparison.Ordinal)) + { + return null; + } + + if (!File.Exists(candidatePath) && !Path.HasExtension(candidatePath)) + { + var directoryIndexPath = Path.Combine(candidatePath, "index.html"); + if (File.Exists(directoryIndexPath)) + { + candidatePath = directoryIndexPath; + } + } + + if (!File.Exists(candidatePath)) + { + return null; + } + + var contentTypeProvider = new FileExtensionContentTypeProvider(); + if (!contentTypeProvider.TryGetContentType(candidatePath, out var contentType)) + { + contentType = "application/octet-stream"; + } + + var relativeFilePath = Path.GetRelativePath(previewRoot, candidatePath).Replace('\\', '/'); + var cacheControl = candidatePath.EndsWith(".html", StringComparison.OrdinalIgnoreCase) + ? "no-cache, no-store, must-revalidate" + : relativeFilePath.StartsWith("_astro/", StringComparison.Ordinal) + ? "max-age=31536000, public, immutable" + : "public, max-age=300"; + + return new ResolvedPreviewFile(candidatePath, contentType, cacheControl); + } + + private static async Task WriteHtmlAsync(HttpContext context, string html, CancellationToken cancellationToken) + { + context.Response.ContentType = "text/html; charset=utf-8"; + context.Response.Headers.CacheControl = "no-cache, no-store, must-revalidate"; + await context.Response.WriteAsync(html, cancellationToken); + } + + private async Task WritePreviewShellAsync(HttpContext context, string fileName, CancellationToken cancellationToken) + { + var filePath = Path.Combine(_previewShellRoot, fileName); + if (!File.Exists(filePath)) + { + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + await WriteHtmlAsync( + context, + BuildInfoPage( + title: "Preview shell is unavailable", + body: $"The preview host could not find the '{fileName}' shell asset under '/_preview/'."), + cancellationToken); + return; + } + + await ServeFileAsync( + context, + filePath, + contentType: "text/html; charset=utf-8", + cacheControl: "no-cache, no-store, must-revalidate", + cancellationToken); + } + + private static string BuildLoaderPage(PreviewStatusSnapshot snapshot) + { + var serializedSnapshot = JsonSerializer.Serialize(snapshot, WebJsonOptions); + var escapedMessage = HtmlEncoder.Default.Encode(snapshot.Message); + var escapedStage = HtmlEncoder.Default.Encode(snapshot.Stage); + var escapedPreviewPath = HtmlEncoder.Default.Encode(snapshot.PreviewPath); + var escapedHint = HtmlEncoder.Default.Encode(BuildHintText(snapshot)); + var escapedPullRequestUrl = HtmlEncoder.Default.Encode(BuildPullRequestUrl(snapshot)); + var cancelButtonHidden = snapshot.State is PreviewLoadState.Ready or PreviewLoadState.Failed or PreviewLoadState.Cancelled ? "hidden" : string.Empty; + + return $$""" + + + + + + + Aspire PR #{{snapshot.PullRequestNumber}} preview + + + +
+
+
+ +
+
Aspire PR Preview
+

Preparing PR #{{snapshot.PullRequestNumber}}

+
+
+ +
+

{{escapedMessage}}

+
+
+
+ Overall progress + {{snapshot.Percent}}% +
+ +
+
+
+ {{escapedStage}} progress + {{snapshot.StagePercent}}% +
+ +
+
+
+
+
Stage
+
{{escapedStage}}
+
+
+
Overall
+
{{snapshot.Percent}}%
+
+
+
Preview path
+
{{escapedPreviewPath}}
+
+
+
Updated
+
{{snapshot.UpdatedAtUtc:O}}
+
+
+

{{escapedHint}}

+
+ + + + """; + } + + private static string BuildIndexPage(IReadOnlyList snapshots, int maxActivePreviews) + { + var previewCards = snapshots.Count == 0 + ? """ +
+

No recent PR previews yet

+

Open a route like /prs/{number}/ to resolve a PR, load its latest frontend artifact, and add it to this window.

+
+ """ + : string.Join(Environment.NewLine, snapshots.Select(BuildPreviewCard)); + + return $$""" + + + + + + + Aspire PR previews + + + +
+
+
+
+ +
+
Aspire PR Preview
+

Recent preview window

+
+
+

These are the most recent preview routes the host is currently tracking. Open any route like /prs/{number}/ to start or resume that PR's preview flow.

+
+

+ Up to {{maxActivePreviews.ToString("N0", CultureInfo.InvariantCulture)}} PRs + Current entries: {{snapshots.Count.ToString("N0", CultureInfo.InvariantCulture)}} +

+
+
+ {{previewCards}} +
+
+ + + + """; + } + + private static string BuildPreviewCard(PreviewStatusSnapshot snapshot) + { + var escapedPreviewPath = HtmlEncoder.Default.Encode(snapshot.PreviewPath); + var escapedState = HtmlEncoder.Default.Encode(snapshot.State.ToString()); + var escapedStage = HtmlEncoder.Default.Encode(snapshot.Stage); + var escapedSummary = HtmlEncoder.Default.Encode(BuildPreviewSummary(snapshot)); + + return $$""" +
+
+
+
PR #{{snapshot.PullRequestNumber}}
+ {{escapedPreviewPath}} +
+ {{escapedState}} +
+

{{escapedSummary}}

+
+
+
Stage
+
{{escapedStage}}
+
+
+
Overall
+
{{snapshot.Percent}}%
+
+
+
Updated
+
+
+
+
Status
+
{{BuildPreviewStatusText(snapshot)}}
+
+
+
+ """; + } + + private static string BuildPreviewSummary(PreviewStatusSnapshot snapshot) => + snapshot.State switch + { + PreviewLoadState.Ready => "The preview is ready to serve.", + PreviewLoadState.Loading => $"{snapshot.Stage} is in progress for this preview.", + PreviewLoadState.Registered => "A fresh preview was registered and will start loading on demand.", + PreviewLoadState.Cancelled => "The last preparation run was cancelled. Opening the route starts it again.", + PreviewLoadState.Failed => snapshot.Error ?? snapshot.Message, + PreviewLoadState.Evicted => "This preview was evicted from the active window and will reload on the next visit.", + _ => snapshot.Message + }; + + private static string BuildPreviewStatusText(PreviewStatusSnapshot snapshot) => + snapshot.State switch + { + PreviewLoadState.Ready => "Ready", + PreviewLoadState.Loading => $"{snapshot.Percent}% overall", + PreviewLoadState.Registered => "Queued", + PreviewLoadState.Cancelled => "Cancelled", + PreviewLoadState.Failed => "Attention needed", + PreviewLoadState.Evicted => "Will reload", + _ => snapshot.State.ToString() + }; + + private static string BuildStateClass(PreviewLoadState state) => + state switch + { + PreviewLoadState.Ready => "state-ready", + PreviewLoadState.Loading => "state-loading", + PreviewLoadState.Registered => "state-registered", + PreviewLoadState.Failed => "state-failed", + PreviewLoadState.Cancelled => "state-cancelled", + PreviewLoadState.Evicted => "state-evicted", + _ => string.Empty + }; + + private static string BuildInfoPage(string title, string body) + { + var safeTitle = WebUtility.HtmlEncode(title); + var safeBody = WebUtility.HtmlEncode(body); + + return $$""" + + + + + + + {{safeTitle}} + + + +
+

{{safeTitle}}

+

{{safeBody}}

+
+ + + """; + } + + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + options.Converters.Add(new JsonStringEnumConverter()); + return options; + } + + private static string BuildPullRequestUrl(PreviewStatusSnapshot snapshot) + { + if (string.IsNullOrWhiteSpace(snapshot.RepositoryOwner) || string.IsNullOrWhiteSpace(snapshot.RepositoryName)) + { + return "#"; + } + + return $"https://github.com/{snapshot.RepositoryOwner}/{snapshot.RepositoryName}/pull/{snapshot.PullRequestNumber}"; + } + + private static bool ShouldRewritePreviewContent(string contentType, string previewPath) + { + if (string.IsNullOrWhiteSpace(previewPath) || string.Equals(previewPath, "/", StringComparison.Ordinal)) + { + return false; + } + + return contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase) + || contentType.StartsWith("text/css", StringComparison.OrdinalIgnoreCase); + } + + private static string RewritePreviewContent(string content, string contentType, string previewPath) + { + var previewPrefix = NormalizePreviewPrefix(previewPath); + if (string.IsNullOrEmpty(previewPrefix)) + { + return content; + } + + var previewPrefixPattern = Regex.Escape(previewPrefix.TrimStart('/')); + var cssRootPathPattern = $@"(?\burl\(([""']?))/(?!(?:/|{previewPrefixPattern})(?:/|$))"; + content = Regex.Replace( + content, + cssRootPathPattern, + "${prefix}" + previewPrefix + "/", + RegexOptions.IgnoreCase); + + var cssImportPattern = $@"(?@import\s+([""']))/(?!(?:/|{previewPrefixPattern})(?:/|$))"; + content = Regex.Replace( + content, + cssImportPattern, + "${prefix}" + previewPrefix + "/", + RegexOptions.IgnoreCase); + + if (!contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase)) + { + return content; + } + + var htmlRootPathPattern = $@"(?\b(?:href|src|action|poster|content|data|imagesrcset)=[""'])/(?!(?:/|{previewPrefixPattern})(?:/|$))"; + content = Regex.Replace( + content, + htmlRootPathPattern, + "${attr}" + previewPrefix + "/", + RegexOptions.IgnoreCase); + + return InjectPreviewCompatibilityScript(content, previewPrefix); + } + + private static string InjectPreviewCompatibilityScript(string html, string previewPrefix) + { + const string Marker = "data-aspire-preview-bootstrap"; + if (html.Contains(Marker, StringComparison.Ordinal)) + { + return html; + } + + var bootstrapScript = $$""" + + """; + + return html.Replace("", $"{bootstrapScript}", StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizePreviewPrefix(string previewPath) + { + if (string.IsNullOrWhiteSpace(previewPath) || string.Equals(previewPath, "/", StringComparison.Ordinal)) + { + return string.Empty; + } + + return previewPath.EndsWith("/", StringComparison.Ordinal) + ? previewPath[..^1] + : previewPath; + } + + private static string BuildHintText(PreviewStatusSnapshot snapshot) => + snapshot.State == PreviewLoadState.Failed + ? "The preview host could not finish preparing this build. Fix the backing configuration or register a newer successful build, then refresh." + : snapshot.State == PreviewLoadState.Cancelled + ? "Preview preparation was cancelled. Refresh this page to start again." + : "The preview will automatically open as soon as the artifact download and extraction finish."; + + private const string AspireLogoSvg = """ + + + + + + + + + """; +} + +internal static class PreviewRoute +{ + public const string CollectionPath = "/prs/"; + private const string Prefix = "/prs/"; + private const string LegacyPrefix = "/pr/"; + + public static string BuildPath(int pullRequestNumber, string? relativePath = null) + { + var path = $"{Prefix}{pullRequestNumber}/"; + if (string.IsNullOrWhiteSpace(relativePath)) + { + return path; + } + + return $"{path}{relativePath.TrimStart('/')}"; + } + + public static bool IsCollectionPath(PathString path) => + string.Equals(path.Value, CollectionPath, StringComparison.OrdinalIgnoreCase) + || string.Equals(path.Value, CollectionPath.TrimEnd('/'), StringComparison.OrdinalIgnoreCase); + + public static bool TryParse(PathString path, out int pullRequestNumber, out string relativePath) + => TryParse(path.Value, Prefix, out pullRequestNumber, out relativePath); + + public static bool TryParse(string? path, out int pullRequestNumber, out string relativePath) + => TryParse(path, Prefix, out pullRequestNumber, out relativePath); + + public static bool TryParseLegacy(PathString path, out int pullRequestNumber, out string relativePath) + => TryParse(path.Value, LegacyPrefix, out pullRequestNumber, out relativePath); + + public static bool TryParseLegacy(string? path, out int pullRequestNumber, out string relativePath) + => TryParse(path, LegacyPrefix, out pullRequestNumber, out relativePath); + + private static bool TryParse(string? pathValue, string prefix, out int pullRequestNumber, out string relativePath) + { + pullRequestNumber = default; + relativePath = string.Empty; + + if (string.IsNullOrWhiteSpace(pathValue) || !pathValue.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var remaining = pathValue[prefix.Length..]; + var nextSlashIndex = remaining.IndexOf('/'); + var pullRequestSegment = nextSlashIndex >= 0 + ? remaining[..nextSlashIndex] + : remaining; + + if (!int.TryParse(pullRequestSegment, out pullRequestNumber)) + { + return false; + } + + if (nextSlashIndex < 0) + { + return true; + } + + relativePath = remaining[(nextSlashIndex + 1)..]; + if (pathValue.EndsWith('/') && !string.IsNullOrEmpty(relativePath) && !relativePath.EndsWith('/')) + { + relativePath = $"{relativePath}/"; + } + + return true; + } +} + +internal sealed record ResolvedPreviewFile(string FilePath, string ContentType, string CacheControl); diff --git a/src/statichost/PreviewHost/Previews/PreviewStateStore.cs b/src/statichost/PreviewHost/Previews/PreviewStateStore.cs new file mode 100644 index 000000000..beb7e3960 --- /dev/null +++ b/src/statichost/PreviewHost/Previews/PreviewStateStore.cs @@ -0,0 +1,653 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Options; + +namespace PreviewHost.Previews; + +internal sealed class PreviewStateStore +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + Converters = { new JsonStringEnumConverter() } + }; + + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly Dictionary _records = []; + private readonly ILogger _logger; + private readonly string _registryPath; + + public PreviewStateStore(IOptions options, ILogger logger) + { + _logger = logger; + + var configuredRoot = options.Value.DataRoot; + StateRoot = string.IsNullOrWhiteSpace(configuredRoot) + ? ResolveDefaultStateRoot() + : configuredRoot; + var configuredContentRoot = options.Value.ContentRoot; + ContentRoot = string.IsNullOrWhiteSpace(configuredContentRoot) + ? ResolveDefaultContentRoot(StateRoot) + : configuredContentRoot; + + _registryPath = Path.Combine(StateRoot, "registry.json"); + } + + public string StateRoot { get; } + + public string ContentRoot { get; } + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + Directory.CreateDirectory(StateRoot); + Directory.CreateDirectory(ContentRoot); + + if (!File.Exists(_registryPath)) + { + return; + } + + await _gate.WaitAsync(cancellationToken); + try + { + await using var stream = File.OpenRead(_registryPath); + var records = await JsonSerializer.DeserializeAsync>(stream, JsonOptions, cancellationToken); + _records.Clear(); + + if (records is null) + { + return; + } + + var requiresSave = false; + foreach (var pair in records) + { + requiresSave |= NormalizeLoadedRecord(pair.Value); + _records[pair.Key] = pair.Value; + } + + if (requiresSave) + { + await SaveLockedAsync(cancellationToken); + } + } + finally + { + _gate.Release(); + } + } + + public async Task RegisterAsync(PreviewRegistrationRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var completedAtUtc = request.CompletedAtUtc == default + ? DateTimeOffset.UtcNow + : request.CompletedAtUtc; + + string? directoryToDelete = null; + PreviewStatusSnapshot snapshot; + var accepted = false; + + await _gate.WaitAsync(cancellationToken); + try + { + if (_records.TryGetValue(request.PullRequestNumber, out var existing) && !IsNewer(existing, request, completedAtUtc)) + { + return new PreviewRegistrationResult(false, existing.ToSnapshot()); + } + + if (existing?.ActiveDirectoryPath is { Length: > 0 } activeDirectoryPath) + { + directoryToDelete = activeDirectoryPath; + } + + var record = existing ?? new PreviewRecord + { + PullRequestNumber = request.PullRequestNumber, + RegisteredAtUtc = DateTimeOffset.UtcNow + }; + + record.RepositoryOwner = request.RepositoryOwner; + record.RepositoryName = request.RepositoryName; + record.HeadSha = request.HeadSha; + record.RunId = request.RunId; + record.RunAttempt = request.RunAttempt; + record.ArtifactName = request.ArtifactName; + record.CompletedAtUtc = completedAtUtc; + record.State = PreviewLoadState.Registered; + record.ActiveDirectoryPath = null; + record.LastError = null; + record.ReadyAtUtc = null; + record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; + record.Progress = NextProgress( + record, + stage: "Registered", + message: "A fresh preview build has been registered. Loading will start on the next request.", + percent: 0, + stagePercent: 0, + bytesDownloaded: null, + bytesTotal: null, + itemsCompleted: null, + itemsTotal: null, + itemsLabel: null); + + _records[request.PullRequestNumber] = record; + await SaveLockedAsync(cancellationToken); + snapshot = record.ToSnapshot(); + accepted = true; + } + finally + { + _gate.Release(); + } + + if (!string.IsNullOrWhiteSpace(directoryToDelete)) + { + DeleteDirectoryIfPresent(directoryToDelete); + } + + return new PreviewRegistrationResult(accepted, snapshot); + } + + public async Task GetSnapshotAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken); + try + { + return _records.TryGetValue(pullRequestNumber, out var record) + ? record.ToSnapshot() + : null; + } + finally + { + _gate.Release(); + } + } + + public async Task> ListRecentSnapshotsAsync(int limit, CancellationToken cancellationToken) + { + if (limit <= 0) + { + return []; + } + + await _gate.WaitAsync(cancellationToken); + try + { + return [.. _records.Values + .OrderByDescending(static record => record.LastAccessedAtUtc ?? record.ReadyAtUtc ?? record.LastUpdatedAtUtc) + .ThenByDescending(static record => record.PullRequestNumber) + .Take(limit) + .Select(static record => record.ToSnapshot())]; + } + finally + { + _gate.Release(); + } + } + + public async Task> ListSnapshotsAsync(CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken); + try + { + return _records.Values + .Select(static record => record.ToSnapshot()) + .ToDictionary(static snapshot => snapshot.PullRequestNumber); + } + finally + { + _gate.Release(); + } + } + + public async Task GetWorkItemAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken); + try + { + return _records.TryGetValue(pullRequestNumber, out var record) + ? new PreviewWorkItem( + record.RepositoryOwner, + record.RepositoryName, + record.PullRequestNumber, + record.HeadSha, + record.RunId, + record.RunAttempt, + record.ArtifactName, + record.CompletedAtUtc) + : null; + } + finally + { + _gate.Release(); + } + } + + public async Task MatchesBuildAsync(int pullRequestNumber, PreviewWorkItem workItem, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken); + try + { + if (!_records.TryGetValue(pullRequestNumber, out var record)) + { + return false; + } + + return record.RunId == workItem.RunId + && record.RunAttempt == workItem.RunAttempt + && string.Equals(record.HeadSha, workItem.HeadSha, StringComparison.Ordinal); + } + finally + { + _gate.Release(); + } + } + + public async Task MarkLoadingAsync(int pullRequestNumber, string stage, string message, int percent, int stagePercent, CancellationToken cancellationToken) + { + await UpdateRecordAsync( + pullRequestNumber, + record => + { + record.State = PreviewLoadState.Loading; + record.LastError = null; + record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; + record.Progress = NextProgress(record, stage, message, percent, stagePercent, null, null, null, null, null); + }, + cancellationToken, + persist: false); + } + + public async Task UpdateProgressAsync( + int pullRequestNumber, + string stage, + string message, + int percent, + int stagePercent, + long? bytesDownloaded, + long? bytesTotal, + int? itemsCompleted, + int? itemsTotal, + string? itemsLabel, + CancellationToken cancellationToken) + { + await UpdateRecordAsync( + pullRequestNumber, + record => + { + record.State = PreviewLoadState.Loading; + record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; + record.Progress = NextProgress(record, stage, message, percent, stagePercent, bytesDownloaded, bytesTotal, itemsCompleted, itemsTotal, itemsLabel); + }, + cancellationToken, + persist: false); + } + + public async Task MarkReadyAsync(int pullRequestNumber, string activeDirectoryPath, CancellationToken cancellationToken) + { + await UpdateRecordAsync( + pullRequestNumber, + record => + { + record.State = PreviewLoadState.Ready; + record.ActiveDirectoryPath = activeDirectoryPath; + record.LastError = null; + record.ReadyAtUtc = DateTimeOffset.UtcNow; + record.LastAccessedAtUtc = record.ReadyAtUtc; + record.LastUpdatedAtUtc = record.ReadyAtUtc.Value; + record.Progress = NextProgress( + record, + stage: "Ready", + message: "The preview is ready to serve.", + percent: 100, + stagePercent: 100, + bytesDownloaded: record.Progress.BytesDownloaded, + bytesTotal: record.Progress.BytesTotal, + itemsCompleted: record.Progress.ItemsCompleted, + itemsTotal: record.Progress.ItemsTotal, + itemsLabel: record.Progress.ItemsLabel); + }, + cancellationToken); + } + + public async Task MarkFailedAsync(int pullRequestNumber, string error, CancellationToken cancellationToken) + { + await UpdateRecordAsync( + pullRequestNumber, + record => + { + record.State = PreviewLoadState.Failed; + record.LastError = error; + record.ActiveDirectoryPath = null; + record.ReadyAtUtc = null; + record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; + record.Progress = NextProgress(record, "Failed", error, record.Progress.Percent, record.Progress.StagePercent, record.Progress.BytesDownloaded, record.Progress.BytesTotal, record.Progress.ItemsCompleted, record.Progress.ItemsTotal, record.Progress.ItemsLabel); + }, + cancellationToken); + } + + public async Task MarkCancelledAsync(int pullRequestNumber, string message, CancellationToken cancellationToken) + { + await UpdateRecordAsync( + pullRequestNumber, + record => + { + record.State = PreviewLoadState.Cancelled; + record.LastError = null; + record.ActiveDirectoryPath = null; + record.ReadyAtUtc = null; + record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; + record.Progress = NextProgress( + record, + stage: "Cancelled", + message: message, + percent: record.Progress.Percent, + stagePercent: record.Progress.StagePercent, + bytesDownloaded: record.Progress.BytesDownloaded, + bytesTotal: record.Progress.BytesTotal, + itemsCompleted: record.Progress.ItemsCompleted, + itemsTotal: record.Progress.ItemsTotal, + itemsLabel: record.Progress.ItemsLabel); + }, + cancellationToken); + } + + public async Task RequeueAsync(int pullRequestNumber, string message, CancellationToken cancellationToken) + { + PreviewStatusSnapshot? snapshot = null; + + await UpdateRecordAsync( + pullRequestNumber, + record => + { + record.State = PreviewLoadState.Registered; + record.LastError = null; + record.ActiveDirectoryPath = null; + record.ReadyAtUtc = null; + record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; + record.Progress = NextProgress( + record, + stage: "Registered", + message: message, + percent: 0, + stagePercent: 0, + bytesDownloaded: null, + bytesTotal: null, + itemsCompleted: null, + itemsTotal: null, + itemsLabel: null); + snapshot = record.ToSnapshot(); + }, + cancellationToken); + + return snapshot; + } + + public async Task TouchAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + await UpdateRecordAsync( + pullRequestNumber, + record => + { + record.LastAccessedAtUtc = DateTimeOffset.UtcNow; + record.LastUpdatedAtUtc = record.LastAccessedAtUtc.Value; + }, + cancellationToken, + persist: false); + } + + public async Task> ListReadyCandidatesAsync(int excludedPullRequestNumber, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken); + try + { + return [.. _records.Values + .Where(static record => record.State == PreviewLoadState.Ready && !string.IsNullOrWhiteSpace(record.ActiveDirectoryPath)) + .Where(record => record.PullRequestNumber != excludedPullRequestNumber) + .Select(record => new ReadyPreviewCandidate( + record.PullRequestNumber, + record.ActiveDirectoryPath!, + record.LastAccessedAtUtc ?? record.ReadyAtUtc ?? record.RegisteredAtUtc)) + .OrderBy(static candidate => candidate.SortKey)]; + } + finally + { + _gate.Release(); + } + } + + public async Task EvictAsync(int pullRequestNumber, string reason, CancellationToken cancellationToken) + { + string? directoryToDelete = null; + + await _gate.WaitAsync(cancellationToken); + try + { + if (!_records.TryGetValue(pullRequestNumber, out var record)) + { + return; + } + + directoryToDelete = record.ActiveDirectoryPath; + record.State = PreviewLoadState.Evicted; + record.ActiveDirectoryPath = null; + record.ReadyAtUtc = null; + record.LastError = null; + record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; + record.Progress = NextProgress(record, "Evicted", reason, 0, 0, null, null, null, null, null); + await SaveLockedAsync(cancellationToken); + } + finally + { + _gate.Release(); + } + + if (!string.IsNullOrWhiteSpace(directoryToDelete)) + { + DeleteDirectoryIfPresent(directoryToDelete); + } + } + + public async Task RemoveAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + string? directoryToDelete = null; + + await _gate.WaitAsync(cancellationToken); + try + { + if (_records.Remove(pullRequestNumber, out var record)) + { + directoryToDelete = record.ActiveDirectoryPath; + await SaveLockedAsync(cancellationToken); + } + } + finally + { + _gate.Release(); + } + + if (!string.IsNullOrWhiteSpace(directoryToDelete)) + { + DeleteDirectoryIfPresent(directoryToDelete); + } + } + + public string GetActiveDirectoryPath(int pullRequestNumber) => Path.Combine(ContentRoot, "active", $"pr-{pullRequestNumber}"); + + public string GetTemporaryZipPath(PreviewWorkItem workItem) => + Path.Combine(ContentRoot, "downloads", $"pr-{workItem.PullRequestNumber}-{workItem.RunId}-{workItem.RunAttempt}.zip"); + + public string GetStagingDirectoryPath(PreviewWorkItem workItem) => + Path.Combine(ContentRoot, "staging", $"pr-{workItem.PullRequestNumber}-{workItem.RunId}-{workItem.RunAttempt}"); + + private async Task UpdateRecordAsync(int pullRequestNumber, Action update, CancellationToken cancellationToken, bool persist = true) + { + await _gate.WaitAsync(cancellationToken); + try + { + if (!_records.TryGetValue(pullRequestNumber, out var record)) + { + return; + } + + update(record); + if (persist) + { + await SaveLockedAsync(cancellationToken); + } + } + finally + { + _gate.Release(); + } + } + + private async Task SaveLockedAsync(CancellationToken cancellationToken) + { + Directory.CreateDirectory(StateRoot); + var json = JsonSerializer.Serialize(_records, JsonOptions); + await File.WriteAllTextAsync(_registryPath, json, cancellationToken); + } + + private bool NormalizeLoadedRecord(PreviewRecord record) + { + if (record.State == PreviewLoadState.Ready + && !string.IsNullOrWhiteSpace(record.ActiveDirectoryPath) + && !Directory.Exists(record.ActiveDirectoryPath)) + { + record.State = PreviewLoadState.Registered; + record.ActiveDirectoryPath = null; + record.ReadyAtUtc = null; + record.LastError = null; + record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; + record.Progress = NextProgress( + record, + stage: "Registered", + message: "The local preview cache was cleared and will reload on the next request.", + percent: 0, + stagePercent: 0, + bytesDownloaded: null, + bytesTotal: null, + itemsCompleted: null, + itemsTotal: null, + itemsLabel: null); + return true; + } + + if (record.State == PreviewLoadState.Loading) + { + record.State = PreviewLoadState.Registered; + record.ActiveDirectoryPath = null; + record.ReadyAtUtc = null; + record.LastError = null; + record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; + record.Progress = NextProgress( + record, + stage: "Registered", + message: "Preview preparation was interrupted and will restart on the next request.", + percent: 0, + stagePercent: 0, + bytesDownloaded: null, + bytesTotal: null, + itemsCompleted: null, + itemsTotal: null, + itemsLabel: null); + return true; + } + + return false; + } + + private static PreviewProgress NextProgress( + PreviewRecord record, + string stage, + string message, + int percent, + int stagePercent, + long? bytesDownloaded, + long? bytesTotal, + int? itemsCompleted, + int? itemsTotal, + string? itemsLabel) => + new() + { + Version = record.Progress.Version + 1, + Stage = stage, + Message = message, + Percent = Math.Clamp(percent, 0, 100), + StagePercent = Math.Clamp(stagePercent, 0, 100), + BytesDownloaded = bytesDownloaded, + BytesTotal = bytesTotal, + ItemsCompleted = itemsCompleted, + ItemsTotal = itemsTotal, + ItemsLabel = itemsLabel, + UpdatedAtUtc = DateTimeOffset.UtcNow + }; + + private static bool IsNewer(PreviewRecord existing, PreviewRegistrationRequest request, DateTimeOffset completedAtUtc) + { + if (completedAtUtc > existing.CompletedAtUtc) + { + return true; + } + + if (completedAtUtc < existing.CompletedAtUtc) + { + return false; + } + + if (request.RunId > existing.RunId) + { + return true; + } + + if (request.RunId < existing.RunId) + { + return false; + } + + return request.RunAttempt > existing.RunAttempt; + } + + private static string ResolveDefaultStateRoot() + { + var home = Environment.GetEnvironmentVariable("HOME"); + if (!string.IsNullOrWhiteSpace(home)) + { + return Path.Combine(home, "pr-preview-data"); + } + + return Path.Combine(AppContext.BaseDirectory, "pr-preview-data"); + } + + private static string ResolveDefaultContentRoot(string stateRoot) + { + if (string.Equals(Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"), "true", StringComparison.OrdinalIgnoreCase)) + { + return Path.Combine(Path.GetTempPath(), "pr-preview-data"); + } + + return stateRoot; + } + + private void DeleteDirectoryIfPresent(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch (IOException exception) + { + _logger.LogWarning(exception, "Failed to delete preview directory {Directory}", path); + } + catch (UnauthorizedAccessException exception) + { + _logger.LogWarning(exception, "Failed to delete preview directory {Directory}", path); + } + } +} diff --git a/src/statichost/PreviewHost/Program.cs b/src/statichost/PreviewHost/Program.cs new file mode 100644 index 000000000..f1817b10e --- /dev/null +++ b/src/statichost/PreviewHost/Program.cs @@ -0,0 +1,441 @@ +using System.ComponentModel.DataAnnotations; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using PreviewHost.Previews; + +var webJsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); +webJsonOptions.Converters.Add(new JsonStringEnumConverter()); +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddProblemDetails(); +builder.Services.AddHealthChecks(); +builder.Services.ConfigureHttpJsonOptions(static options => +{ + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); +}); +builder.Services + .AddOptions() + .Bind(builder.Configuration.GetSection(PreviewHostOptions.SectionName)) + .Validate( + static options => options.HasGitHubToken || options.HasGitHubAppConfiguration, + $"Either '{PreviewHostOptions.SectionName}:GitHubToken' or both '{PreviewHostOptions.SectionName}:GitHubAppId' and '{PreviewHostOptions.SectionName}:GitHubAppPrivateKey' must be configured.") + .Validate( + static options => options.HasValidExtractionMode, + $"The '{PreviewHostOptions.SectionName}:ExtractionMode' setting must be either 'managed' or 'command-line'.") + .Validate( + static options => CommandLineExtractionSupport.IsConfigurationSupported(options), + CommandLineExtractionSupport.GetConfigurationValidationMessage()) + .ValidateOnStart(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var app = builder.Build(); +var previewHostOptions = app.Services.GetRequiredService>().Value; + +var previewStateStore = app.Services.GetRequiredService(); +await previewStateStore.InitializeAsync(CancellationToken.None); +app.Logger.LogInformation( + "PreviewHost GitHub authentication mode: {GitHubAuthenticationMode}", + previewHostOptions.GetGitHubAuthenticationMode()); +app.Logger.LogInformation( + "PreviewHost artifact extraction mode: {ExtractionMode}", + previewHostOptions.GetExtractionModeDescription()); +app.Logger.LogInformation( + "PreviewHost storage roots: state {StateRoot}, content {ContentRoot}", + previewStateStore.StateRoot, + previewStateStore.ContentRoot); +if (previewHostOptions.UseCommandLineExtraction + && CommandLineExtractionSupport.TryResolveConfiguredTool(previewHostOptions, out var extractionToolPath)) +{ + app.Logger.LogInformation( + "PreviewHost command-line extractor resolved to: {ExtractionToolPath}", + extractionToolPath); +} + +if (!app.Environment.IsDevelopment()) +{ + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseExceptionHandler(); +app.UseStaticFiles(new StaticFileOptions +{ + OnPrepareResponse = static context => + { + if (context.Context.Request.Path.StartsWithSegments("/_preview", StringComparison.OrdinalIgnoreCase)) + { + context.Context.Response.Headers.CacheControl = "no-cache, no-store, must-revalidate"; + } + } +}); + +app.Use(async (context, next) => +{ + if ((HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsHead(context.Request.Method)) + && PreviewRoute.TryParseLegacy(context.Request.Path, out var legacyPullRequestNumber, out var legacyRelativePath)) + { + context.Response.Redirect($"{PreviewRoute.BuildPath(legacyPullRequestNumber, legacyRelativePath)}{context.Request.QueryString}", permanent: false); + return; + } + + if ((HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsHead(context.Request.Method)) + && PreviewRoute.IsCollectionPath(context.Request.Path)) + { + var indexDispatcher = context.RequestServices.GetRequiredService(); + await indexDispatcher.DispatchIndexAsync(context, context.RequestAborted); + return; + } + + if (!TryResolvePreviewRequest(context, out var pullRequestNumber, out var relativePath)) + { + await next(); + return; + } + + var dispatcher = context.RequestServices.GetRequiredService(); + await dispatcher.DispatchAsync(context, pullRequestNumber, relativePath, context.RequestAborted); +}); + +app.MapGet("/", () => Results.Redirect(PreviewRoute.CollectionPath)); + +app.MapHealthChecks("/healthz", new HealthCheckOptions +{ + AllowCachingResponses = false +}); + +app.MapGet( + "/api/previews/recent", + async (PreviewStateStore stateStore, IOptions options, CancellationToken cancellationToken) => + { + var snapshots = await stateStore.ListRecentSnapshotsAsync(options.Value.MaxActivePreviews, cancellationToken); + return Results.Json(new + { + updatedAtUtc = DateTimeOffset.UtcNow, + maxActivePreviews = options.Value.MaxActivePreviews, + snapshots + }); + }); + +app.MapGet( + "/api/previews/catalog", + async (GitHubArtifactClient gitHubArtifactClient, PreviewStateStore stateStore, IOptions options, CancellationToken cancellationToken) => + { + var openPullRequests = await gitHubArtifactClient.ListOpenPullRequestsAsync(cancellationToken); + var trackedSnapshots = await stateStore.ListSnapshotsAsync(cancellationToken); + var activePreviewCount = trackedSnapshots.Values.Count(static snapshot => snapshot.State is PreviewLoadState.Loading or PreviewLoadState.Ready); + + var entries = openPullRequests + .Select(pullRequest => new + { + pullRequestNumber = pullRequest.PullRequestNumber, + title = pullRequest.Title, + pullRequestUrl = pullRequest.HtmlUrl, + previewPath = PreviewRoute.BuildPath(pullRequest.PullRequestNumber), + authorLogin = pullRequest.AuthorLogin, + isDraft = pullRequest.IsDraft, + headSha = pullRequest.HeadSha, + hasSuccessfulPreviewBuild = pullRequest.HasSuccessfulPreviewBuild, + createdAtUtc = pullRequest.CreatedAtUtc, + updatedAtUtc = pullRequest.UpdatedAtUtc, + preview = trackedSnapshots.TryGetValue(pullRequest.PullRequestNumber, out var snapshot) ? snapshot : null + }) + .ToArray(); + + return Results.Json(new + { + updatedAtUtc = DateTimeOffset.UtcNow, + openPullRequestCount = entries.Length, + previewablePullRequestCount = entries.Count(static entry => entry.hasSuccessfulPreviewBuild), + maxActivePreviews = options.Value.MaxActivePreviews, + activePreviewCount, + entries + }); + }); + +app.MapGet( + "/api/previews/{pullRequestNumber:int}", + async (int pullRequestNumber, PreviewStateStore stateStore, CancellationToken cancellationToken) => + { + var snapshot = await stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); + return snapshot is null + ? Results.NotFound() + : Results.Json(snapshot); + }); + +app.MapGet( + "/api/previews/{pullRequestNumber:int}/bootstrap", + async (int pullRequestNumber, PreviewCoordinator coordinator, IOptions options, CancellationToken cancellationToken) => + { + var result = await coordinator.BootstrapAsync(pullRequestNumber, cancellationToken); + return result.Snapshot is null + ? Results.Json(CreateUnavailablePreviewPayload( + pullRequestNumber, + options.Value, + result.FailureMessage ?? "The preview host could not find a successful frontend build for this pull request yet.")) + : Results.Json(result.Snapshot); + }); + +app.MapGet( + "/api/previews/{pullRequestNumber:int}/events", + async (HttpContext context, int pullRequestNumber, PreviewStateStore stateStore, PreviewCoordinator coordinator, CancellationToken cancellationToken) => + { + context.Response.ContentType = "text/event-stream"; + context.Response.Headers.CacheControl = "no-cache, no-store, must-revalidate"; + coordinator.EnsureLoading(pullRequestNumber); + + long lastVersion = -1; + + while (!cancellationToken.IsCancellationRequested) + { + var snapshot = await stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); + if (snapshot is null) + { + var missingSnapshot = JsonSerializer.Serialize( + new PreviewStatusSnapshot + { + RepositoryOwner = string.Empty, + RepositoryName = string.Empty, + PullRequestNumber = pullRequestNumber, + State = PreviewLoadState.Failed, + Stage = "Missing", + Message = "No preview registration is available for this pull request.", + Percent = 0, + StagePercent = 0, + Version = lastVersion + 1, + Error = "No preview registration is available for this pull request.", + UpdatedAtUtc = DateTimeOffset.UtcNow, + PreviewPath = PreviewRoute.BuildPath(pullRequestNumber) + }, + webJsonOptions); + + await context.Response.WriteAsync($"data: {missingSnapshot}\n\n", cancellationToken); + break; + } + + if (snapshot.Version != lastVersion) + { + var payload = JsonSerializer.Serialize(snapshot, webJsonOptions); + await context.Response.WriteAsync($"data: {payload}\n\n", cancellationToken); + await context.Response.Body.FlushAsync(cancellationToken); + lastVersion = snapshot.Version; + + if (snapshot.IsReady || snapshot.State is PreviewLoadState.Failed or PreviewLoadState.Cancelled) + { + break; + } + } + + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + }); + +app.MapPost( + "/api/previews/{pullRequestNumber:int}/cancel", + async (int pullRequestNumber, PreviewCoordinator coordinator, CancellationToken cancellationToken) => + { + var snapshot = await coordinator.CancelAsync(pullRequestNumber, cancellationToken); + return snapshot is null + ? Results.NotFound() + : Results.Json(snapshot); + }); + +app.MapPost( + "/api/previews/{pullRequestNumber:int}/retry", + async (int pullRequestNumber, PreviewCoordinator coordinator, IOptions options, CancellationToken cancellationToken) => + { + var result = await coordinator.RetryAsync(pullRequestNumber, cancellationToken); + return result.Snapshot is null + ? Results.Json(CreateUnavailablePreviewPayload( + pullRequestNumber, + options.Value, + result.FailureMessage ?? "The preview host could not find a successful frontend build for this pull request yet.")) + : Results.Json(result.Snapshot); + }); + +app.MapPost( + "/api/previews/{pullRequestNumber:int}/reset", + async (int pullRequestNumber, PreviewCoordinator coordinator, CancellationToken cancellationToken) => + { + var removed = await coordinator.ResetAsync(pullRequestNumber, cancellationToken); + return removed + ? Results.NoContent() + : Results.NotFound(); + }); + +app.MapPost( + "/api/previews/registrations", + async (HttpContext context, PreviewRegistrationRequest request, PreviewStateStore stateStore, IOptions options, CancellationToken cancellationToken) => + { + if (!HasValidBearerToken(context.Request, options.Value.RegistrationToken)) + { + return Results.Unauthorized(); + } + + if (!TryValidate(request, out var validationErrors)) + { + return Results.ValidationProblem(validationErrors); + } + + var result = await stateStore.RegisterAsync(request, cancellationToken); + return Results.Json(new + { + accepted = result.Accepted, + previewPath = PreviewRoute.BuildPath(request.PullRequestNumber), + snapshot = result.Snapshot + }); + }); + +app.MapDelete( + "/api/previews/{pullRequestNumber:int}", + async (HttpContext context, int pullRequestNumber, PreviewStateStore stateStore, IOptions options, CancellationToken cancellationToken) => + { + if (!HasValidBearerToken(context.Request, options.Value.RegistrationToken)) + { + return Results.Unauthorized(); + } + + await stateStore.RemoveAsync(pullRequestNumber, cancellationToken); + return Results.NoContent(); + }); + +await app.RunAsync(); + +static bool TryValidate(PreviewRegistrationRequest request, out Dictionary validationErrors) +{ + var validationResults = new List(); + var validationContext = new ValidationContext(request); + + if (Validator.TryValidateObject(request, validationContext, validationResults, validateAllProperties: true)) + { + validationErrors = []; + return true; + } + + validationErrors = validationResults + .SelectMany( + result => result.MemberNames.DefaultIfEmpty(string.Empty), + static (result, memberName) => new { MemberName = memberName, result.ErrorMessage }) + .GroupBy(static item => item.MemberName, StringComparer.Ordinal) + .ToDictionary( + static group => group.Key, + static group => group.Select(static item => item.ErrorMessage ?? "Validation failed.").ToArray(), + StringComparer.Ordinal); + + return false; +} + +static bool HasValidBearerToken(HttpRequest request, string expectedToken) +{ + if (string.IsNullOrWhiteSpace(expectedToken)) + { + return false; + } + + var authorizationHeader = request.Headers.Authorization.ToString(); + if (!authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var providedToken = authorizationHeader["Bearer ".Length..].Trim(); + var expectedBytes = Encoding.UTF8.GetBytes(expectedToken); + var providedBytes = Encoding.UTF8.GetBytes(providedToken); + + return expectedBytes.Length == providedBytes.Length + && CryptographicOperations.FixedTimeEquals(expectedBytes, providedBytes); +} + +static object CreateUnavailablePreviewPayload(int pullRequestNumber, PreviewHostOptions options, string failureMessage) => + new + { + pullRequestNumber, + state = "Missing", + stage = "Missing", + failureMessage, + previewPath = PreviewRoute.BuildPath(pullRequestNumber), + updatedAtUtc = DateTimeOffset.UtcNow, + repositoryOwner = options.RepositoryOwner, + repositoryName = options.RepositoryName + }; + +static bool TryResolvePreviewRequest(HttpContext context, out int pullRequestNumber, out string relativePath) +{ + if (PreviewRoute.TryParse(context.Request.Path, out pullRequestNumber, out relativePath)) + { + return true; + } + + if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method)) + { + pullRequestNumber = default; + relativePath = string.Empty; + return false; + } + + if (context.Request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase) + || context.Request.Path.StartsWithSegments("/healthz", StringComparison.OrdinalIgnoreCase)) + { + pullRequestNumber = default; + relativePath = string.Empty; + return false; + } + + if (!TryGetPullRequestNumberFromReferer(context.Request, out pullRequestNumber)) + { + relativePath = string.Empty; + return false; + } + + relativePath = GetRelativePreviewPath(context.Request.Path); + return true; +} + +static bool TryGetPullRequestNumberFromReferer(HttpRequest request, out int pullRequestNumber) +{ + pullRequestNumber = default; + + var refererHeader = request.Headers.Referer.ToString(); + if (string.IsNullOrWhiteSpace(refererHeader) + || !Uri.TryCreate(refererHeader, UriKind.Absolute, out var refererUri)) + { + return false; + } + + var requestHost = request.Host.Host; + if (!string.Equals(refererUri.Host, requestHost, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (request.Host.Port is { } requestPort && !refererUri.IsDefaultPort && refererUri.Port != requestPort) + { + return false; + } + + return PreviewRoute.TryParse(refererUri.AbsolutePath, out pullRequestNumber, out _) + || PreviewRoute.TryParseLegacy(refererUri.AbsolutePath, out pullRequestNumber, out _); +} + +static string GetRelativePreviewPath(PathString requestPath) +{ + var pathValue = requestPath.Value; + if (string.IsNullOrEmpty(pathValue) || pathValue == "/") + { + return string.Empty; + } + + var relativePath = pathValue.TrimStart('/'); + if (pathValue.EndsWith('/') && !relativePath.EndsWith('/')) + { + relativePath = $"{relativePath}/"; + } + + return relativePath; +} diff --git a/src/statichost/PreviewHost/Properties/launchSettings.json b/src/statichost/PreviewHost/Properties/launchSettings.json new file mode 100644 index 000000000..18eff7506 --- /dev/null +++ b/src/statichost/PreviewHost/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17155;http://localhost:15081", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15081", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/statichost/PreviewHost/appsettings.Development.json b/src/statichost/PreviewHost/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/src/statichost/PreviewHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/statichost/PreviewHost/appsettings.json b/src/statichost/PreviewHost/appsettings.json new file mode 100644 index 000000000..9558123fd --- /dev/null +++ b/src/statichost/PreviewHost/appsettings.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "PreviewHost": { + "MaxActivePreviews": 10 + } +} diff --git a/src/statichost/PreviewHost/wwwroot/_preview/aspire-mark.svg b/src/statichost/PreviewHost/wwwroot/_preview/aspire-mark.svg new file mode 100644 index 000000000..89fe2a3fc --- /dev/null +++ b/src/statichost/PreviewHost/wwwroot/_preview/aspire-mark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/statichost/PreviewHost/wwwroot/_preview/github-mark.svg b/src/statichost/PreviewHost/wwwroot/_preview/github-mark.svg new file mode 100644 index 000000000..2c00a294f --- /dev/null +++ b/src/statichost/PreviewHost/wwwroot/_preview/github-mark.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/statichost/PreviewHost/wwwroot/_preview/index.html b/src/statichost/PreviewHost/wwwroot/_preview/index.html new file mode 100644 index 000000000..0954c9dc3 --- /dev/null +++ b/src/statichost/PreviewHost/wwwroot/_preview/index.html @@ -0,0 +1,110 @@ + + + + + + Aspire PR Previews + + + + +
+
+
+ + Aspire logo + + Aspire + PR Preview + + +

Aspire PR Preview

+

Open pull requests

+

Pick any open PR and the preview host will warm the latest frontend build on demand while keeping only a small window hot.

+
+
+ + + Repository + + Loading warm window... + Loading open pull requests... +
+
+ +
+
+ Authors +
+ +
+
+
+ Preview status +
+ +
+
+
+ +
+ + + +
+
+ + + + diff --git a/src/statichost/PreviewHost/wwwroot/_preview/index.js b/src/statichost/PreviewHost/wwwroot/_preview/index.js new file mode 100644 index 000000000..1cb5c7ed4 --- /dev/null +++ b/src/statichost/PreviewHost/wwwroot/_preview/index.js @@ -0,0 +1,710 @@ +const previewGrid = document.getElementById("preview-grid"); +const windowCapacity = document.getElementById("window-capacity"); +const windowCount = document.getElementById("window-count"); +const availabilityFilterBar = document.getElementById("availability-filter-bar"); +const authorFilterBar = document.getElementById("author-filter-bar"); + +const numberFormatter = new Intl.NumberFormat(); +const dateFormatter = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", +}); + +const initialFilters = readFiltersFromQuery(); + +let catalogEntries = []; +let authorOptions = []; +let showPreviewableOnly = initialFilters.previewable; +let selectedAuthors = new Set(initialFilters.authors); +let activePreviewCount = 0; +let maxActivePreviews = 0; +let openPullRequestCount = 0; +let previewablePullRequestCount = 0; +let openDropdown = ""; +const resettingPreviews = new Set(); + +availabilityFilterBar.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) { + return; + } + + const button = event.target.closest("[data-filter-trigger='availability']"); + if (!(button instanceof HTMLButtonElement)) { + return; + } + + toggleDropdown("availability"); +}); + +availabilityFilterBar.addEventListener("change", (event) => { + if (!(event.target instanceof HTMLInputElement)) { + return; + } + + if (event.target.dataset.previewableCheckbox !== "true") { + return; + } + + showPreviewableOnly = event.target.checked; + syncQueryString(); + syncAvailabilityFilter(); + renderCatalog(); +}); + +authorFilterBar.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) { + return; + } + + const button = event.target.closest("[data-filter-trigger='author']"); + if (!(button instanceof HTMLButtonElement)) { + const clearButton = event.target.closest("[data-clear-authors]"); + if (!(clearButton instanceof HTMLButtonElement)) { + return; + } + + selectedAuthors = new Set(); + syncQueryString(); + syncAuthorFilter(catalogEntries); + renderCatalog(); + return; + } + + toggleDropdown("author"); +}); + +authorFilterBar.addEventListener("change", (event) => { + if (!(event.target instanceof HTMLInputElement)) { + return; + } + + if (event.target.dataset.authorCheckbox !== "true") { + return; + } + + const value = normalizeAuthorValue(event.target.value); + if (!value) { + return; + } + + if (event.target.checked) { + selectedAuthors.add(value); + } else { + selectedAuthors.delete(value); + } + + syncQueryString(); + syncAuthorFilter(catalogEntries); + renderCatalog(); +}); + +document.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) { + return; + } + + const path = typeof event.composedPath === "function" + ? event.composedPath() + : []; + + if (path.includes(availabilityFilterBar) || path.includes(authorFilterBar)) { + return; + } + + closeDropdowns(); +}); + +document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + closeDropdowns(); + } +}); + +previewGrid.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) { + return; + } + + const button = event.target.closest("[data-reset-preview]"); + if (!(button instanceof HTMLButtonElement)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const pullRequestNumber = Number(button.dataset.resetPreview); + if (!Number.isInteger(pullRequestNumber) || pullRequestNumber <= 0) { + return; + } + + void resetPreview(pullRequestNumber); +}); + +loadCatalog().catch((error) => { + previewGrid.setAttribute("aria-busy", "false"); + availabilityFilterBar.setAttribute("aria-busy", "false"); + authorFilterBar.setAttribute("aria-busy", "false"); + syncAvailabilityFilter(); + syncAuthorFilter([]); + renderEmptyState(error instanceof Error ? error.message : "The preview host could not load open pull requests."); +}); + +setInterval(() => { + loadCatalog().catch((error) => { + console.error(error); + }); +}, 15000); + +async function loadCatalog() { + const response = await fetch("/api/previews/catalog", { + cache: "no-store", + }); + + if (!response.ok) { + throw new Error(`Open pull requests request failed with status ${response.status}.`); + } + + const payload = await response.json(); + catalogEntries = Array.isArray(payload.entries) ? payload.entries : []; + maxActivePreviews = payload.maxActivePreviews ?? 0; + activePreviewCount = payload.activePreviewCount ?? 0; + openPullRequestCount = payload.openPullRequestCount ?? catalogEntries.length; + previewablePullRequestCount = payload.previewablePullRequestCount + ?? catalogEntries.filter(isPreviewable).length; + + authorOptions = buildAuthorOptions(catalogEntries, selectedAuthors); + syncAvailabilityFilter(); + syncAuthorFilter(catalogEntries); + renderCatalog(); +} + +function renderCatalog() { + const filteredEntries = applyFilters(catalogEntries); + + previewGrid.setAttribute("aria-busy", "false"); + availabilityFilterBar.setAttribute("aria-busy", "false"); + authorFilterBar.setAttribute("aria-busy", "false"); + + windowCapacity.textContent = `Warm window: ${numberFormatter.format(activePreviewCount)} / ${numberFormatter.format(maxActivePreviews)}`; + windowCount.textContent = buildWindowCountText(filteredEntries); + + if (filteredEntries.length === 0) { + renderEmptyState(buildEmptyStateMessage()); + return; + } + + previewGrid.innerHTML = filteredEntries.map(renderPreviewCard).join("\n"); +} + +function renderPreviewCard(entry) { + const preview = entry.preview ?? null; + const previewPath = escapeHtml(entry.previewPath ?? "/prs/"); + const title = escapeHtml(entry.title ?? `PR #${entry.pullRequestNumber}`); + const subtitle = escapeHtml(`${getAuthorLabel(entry.authorLogin)} · Created ${formatDateOnly(entry.createdAtUtc)}`); + const stateText = escapeHtml(buildChipLabel(preview, entry)); + const stateClass = escapeHtml(getStateClass(preview, entry)); + const statusDetail = escapeHtml(buildStatusDetail(preview, entry)); + const openPullRequestAction = entry.pullRequestUrl + ? ` + + + ` + : ""; + const draftBadge = entry.isDraft + ? 'Draft' + : ""; + const resetAction = renderResetPreviewAction(entry, preview); + + return ` + `; +} + +function renderEmptyState(message) { + previewGrid.setAttribute("aria-busy", "false"); + previewGrid.innerHTML = ` +
+

${escapeHtml(message)}

+

Open a route like /prs/{number}/ to resolve a PR, prepare its latest frontend artifact, and add it to the warm preview window.

+
`; +} + +function syncAuthorFilter(entries) { + authorOptions = buildAuthorOptions(entries, selectedAuthors); + authorFilterBar.dataset.open = openDropdown === "author" ? "true" : "false"; + authorFilterBar.innerHTML = renderDropdown({ + kind: "author", + active: selectedAuthors.size > 0, + open: openDropdown === "author", + label: buildAuthorTriggerLabel(), + meta: buildAuthorTriggerMeta(), + menuHtml: renderAuthorFilterMenu(), + }); +} + +function syncAvailabilityFilter() { + availabilityFilterBar.dataset.open = openDropdown === "availability" ? "true" : "false"; + availabilityFilterBar.innerHTML = renderDropdown({ + kind: "availability", + active: showPreviewableOnly, + open: openDropdown === "availability", + label: showPreviewableOnly ? "Previewable only" : "All previews", + meta: showPreviewableOnly + ? `${numberFormatter.format(previewablePullRequestCount)} matching open PRs` + : `${numberFormatter.format(openPullRequestCount)} open PRs`, + menuHtml: renderAvailabilityFilterMenu(), + }); +} + +function renderDropdown({ kind, active, open, label, meta, menuHtml }) { + const activeClass = active ? " is-active" : ""; + return ` + +
+ ${menuHtml} +
`; +} + +function renderAvailabilityFilterMenu() { + const checked = showPreviewableOnly ? " checked" : ""; + return ` +
+ +
`; +} + +function renderAuthorFilterMenu() { + if (authorOptions.length === 0) { + return '
No open pull request authors are available right now.
'; + } + + const footer = selectedAuthors.size > 0 + ? ` + ` + : ""; + + return ` +
+ ${authorOptions.map(renderAuthorOption).join("\n")} +
+ ${footer}`; +} + +function renderAuthorOption(option) { + const checked = selectedAuthors.has(option.value) ? " checked" : ""; + const emptyClass = option.count === 0 ? " is-empty" : ""; + return ` + `; +} + +function renderResetPreviewAction(entry, preview) { + if (!preview) { + return ""; + } + + const pullRequestNumber = Number(entry.pullRequestNumber); + const isResetting = resettingPreviews.has(pullRequestNumber); + const label = isResetting ? "Resetting..." : "Reset preview"; + const disabled = isResetting ? " disabled" : ""; + + return ` + `; +} + +function buildAuthorOptions(entries, activeAuthors) { + const counts = new Map(); + const options = []; + + for (const entry of entries) { + const value = getAuthorValue(entry.authorLogin); + counts.set(value, (counts.get(value) ?? 0) + 1); + } + + for (const [value, count] of counts) { + options.push({ + value, + label: getAuthorLabelFromValue(value), + count, + }); + } + + for (const value of activeAuthors) { + if (counts.has(value)) { + continue; + } + + options.push({ + value, + label: getAuthorLabelFromValue(value), + count: 0, + }); + } + + return options.sort((left, right) => left.label.localeCompare(right.label, undefined, { sensitivity: "base" })); +} + +function applyFilters(entries) { + return entries.filter((entry) => { + if (showPreviewableOnly && !isPreviewable(entry)) { + return false; + } + + if (selectedAuthors.size > 0 && !selectedAuthors.has(getAuthorValue(entry.authorLogin))) { + return false; + } + + return true; + }); +} + +function getAuthorValue(authorLogin) { + return normalizeAuthorValue(authorLogin) || "unknown"; +} + +function getAuthorLabel(authorLogin) { + return getAuthorLabelFromValue(getAuthorValue(authorLogin)); +} + +function getAuthorLabelFromValue(value) { + return value === "unknown" + ? "Unknown" + : `@${value}`; +} + +function buildStatusDetail(preview, entry) { + if (!preview) { + return isPreviewable(entry) + ? "Loads on first visit." + : "No successful frontend build artifact is available for the current head yet."; + } + + if (preview.headSha && entry.headSha && preview.headSha !== entry.headSha) { + return isPreviewable(entry) + ? "New commits are ready to warm from the latest frontend build." + : "New commits are waiting for a successful frontend build."; + } + + switch (preview.state) { + case "Ready": + return "Served from the warm window."; + case "Loading": + return preview.stage ? `${preview.stage} · ${preview.percent ?? 0}%` : `${preview.percent ?? 0}%`; + case "Registered": + return "Latest build is queued to warm."; + case "Cancelled": + return "Preparation was cancelled."; + case "Failed": + return preview.error ?? preview.message ?? "The preview host could not finish preparing this build."; + case "Evicted": + return "Loads again on the next visit."; + default: + return preview.message ?? "Waiting for preview activity."; + } +} + +function buildChipLabel(preview, entry) { + if (!preview) { + return isPreviewable(entry) ? "On demand" : "Waiting on CI"; + } + + if (preview.headSha && entry.headSha && preview.headSha !== entry.headSha) { + return isPreviewable(entry) ? "Outdated" : "Waiting on CI"; + } + + switch (preview.state) { + case "Ready": + return "Ready"; + case "Loading": + return "Preparing"; + case "Registered": + return "Queued"; + case "Cancelled": + return "Cancelled"; + case "Failed": + return "Failed"; + case "Evicted": + return "Evicted"; + default: + return preview.state ?? "Unknown"; + } +} + +function getStateClass(preview, entry) { + if (!preview) { + return isPreviewable(entry) ? "missing" : "registered"; + } + + if (preview.headSha && entry.headSha && preview.headSha !== entry.headSha) { + return isPreviewable(entry) ? "outdated" : "registered"; + } + + return String(preview.state ?? "missing").toLowerCase(); +} + +function isPreviewable(entry) { + const preview = entry.preview ?? null; + if (preview && preview.headSha && entry.headSha && preview.headSha === entry.headSha) { + return true; + } + + return entry.hasSuccessfulPreviewBuild === true; +} + +function buildWindowCountText(filteredEntries) { + if (showPreviewableOnly && selectedAuthors.size > 0) { + return `Showing ${numberFormatter.format(filteredEntries.length)} previewable PRs for ${buildSelectedAuthorSummary()}`; + } + + if (showPreviewableOnly) { + return `Showing ${numberFormatter.format(filteredEntries.length)} of ${numberFormatter.format(openPullRequestCount)} open PRs with a successful frontend build`; + } + + if (selectedAuthors.size > 0) { + return `Showing ${numberFormatter.format(filteredEntries.length)} of ${numberFormatter.format(openPullRequestCount)} open PRs for ${buildSelectedAuthorSummary()}`; + } + + return `Open PRs: ${numberFormatter.format(openPullRequestCount)}`; +} + +function buildEmptyStateMessage() { + if (showPreviewableOnly && selectedAuthors.size > 0) { + return `No previewable open pull requests match ${buildSelectedAuthorSummary()}.`; + } + + if (showPreviewableOnly) { + return "No open pull requests have a successful frontend build artifact right now."; + } + + if (selectedAuthors.size > 0) { + return `No open pull requests match ${buildSelectedAuthorSummary()}.`; + } + + return "No open pull requests need previews right now."; +} + +function buildAuthorTriggerLabel() { + if (selectedAuthors.size === 0) { + return "All authors"; + } + + if (selectedAuthors.size === 1) { + return getAuthorLabelFromValue([...selectedAuthors][0]); + } + + return `${numberFormatter.format(selectedAuthors.size)} authors selected`; +} + +function buildAuthorTriggerMeta() { + if (selectedAuthors.size === 0) { + if (authorOptions.length === 0) { + return "No authors available"; + } + + return authorOptions.length === 1 + ? "1 author available" + : `${numberFormatter.format(authorOptions.length)} authors available`; + } + + return selectedAuthors.size === 1 + ? "1 author selected" + : `${numberFormatter.format(selectedAuthors.size)} authors selected`; +} + +function buildAuthorOptionMeta(count) { + if (count === 0) { + return "No open pull requests right now"; + } + + return count === 1 + ? "1 open pull request" + : `${numberFormatter.format(count)} open pull requests`; +} + +function buildSelectedAuthorSummary() { + const labels = [...selectedAuthors] + .map(getAuthorLabelFromValue) + .sort((left, right) => left.localeCompare(right, undefined, { sensitivity: "base" })); + + if (labels.length === 0) { + return "the selected authors"; + } + + if (labels.length === 1) { + return labels[0]; + } + + if (labels.length === 2) { + return `${labels[0]} and ${labels[1]}`; + } + + return `${numberFormatter.format(labels.length)} authors`; +} + +function toggleDropdown(name) { + openDropdown = openDropdown === name ? "" : name; + syncAvailabilityFilter(); + syncAuthorFilter(catalogEntries); +} + +function closeDropdowns() { + if (!openDropdown) { + return; + } + + openDropdown = ""; + syncAvailabilityFilter(); + syncAuthorFilter(catalogEntries); +} + +function readFiltersFromQuery() { + const searchParams = new URLSearchParams(window.location.search); + const previewable = searchParams.has("previewable") + ? normalizeBooleanQuery(searchParams.get("previewable")) + : true; + const authors = new Set(); + const rawAuthors = searchParams.get("author"); + + if (rawAuthors) { + for (const value of rawAuthors.split(",")) { + const normalized = normalizeAuthorValue(value); + if (normalized) { + authors.add(normalized); + } + } + } + + return { + previewable, + authors, + }; +} + +function normalizeBooleanQuery(value) { + if (!value) { + return false; + } + + return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase()); +} + +function normalizeAuthorValue(value) { + const normalized = String(value ?? "") + .trim() + .toLowerCase() + .replace(/^author:/, "") + .replace(/^@/, ""); + + return normalized; +} + +function syncQueryString() { + const url = new URL(window.location.href); + + url.searchParams.set("previewable", showPreviewableOnly ? "true" : "false"); + + if (selectedAuthors.size > 0) { + url.searchParams.set("author", [...selectedAuthors].sort().join(",")); + } else { + url.searchParams.delete("author"); + } + + window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`); +} + +async function resetPreview(pullRequestNumber) { + if (resettingPreviews.has(pullRequestNumber)) { + return; + } + + if (!window.confirm(`Reset preview for PR #${pullRequestNumber}? This clears the cached preview so the next visit has to prepare it again.`)) { + return; + } + + resettingPreviews.add(pullRequestNumber); + renderCatalog(); + + try { + const response = await fetch(`/api/previews/${pullRequestNumber}/reset`, { + method: "POST", + headers: { + "Accept": "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`Preview reset request failed with status ${response.status}.`); + } + + await loadCatalog(); + } catch (error) { + console.error(error); + window.alert(error instanceof Error + ? error.message + : "The preview host could not reset this preview."); + } finally { + resettingPreviews.delete(pullRequestNumber); + renderCatalog(); + } +} + +function formatDateOnly(value) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "Waiting" : dateFormatter.format(date); +} + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} diff --git a/src/statichost/PreviewHost/wwwroot/_preview/left-arrow.svg b/src/statichost/PreviewHost/wwwroot/_preview/left-arrow.svg new file mode 100644 index 000000000..56f14e989 --- /dev/null +++ b/src/statichost/PreviewHost/wwwroot/_preview/left-arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/statichost/PreviewHost/wwwroot/_preview/preview.css b/src/statichost/PreviewHost/wwwroot/_preview/preview.css new file mode 100644 index 000000000..0a57310ae --- /dev/null +++ b/src/statichost/PreviewHost/wwwroot/_preview/preview.css @@ -0,0 +1,1014 @@ +:root { + color-scheme: dark; + --bg: #1f1e33; + --bg-deep: #181825; + --bg-accent: #231d41; + --panel: rgba(24, 24, 37, 0.94); + --panel-strong: rgba(35, 29, 65, 0.76); + --panel-border: rgba(160, 164, 171, 0.22); + --text: #ffffff; + --muted: #c6c8cc; + --muted-strong: #eceef1; + --line: rgba(160, 164, 171, 0.16); + --accent: #7455dd; + --accent-strong: #512bd4; + --accent-soft: #c6c2f2; + --danger: #f65163; + --warning: #e9b44c; + --shadow: rgba(10, 11, 20, 0.46); +} + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at top, rgba(116, 85, 221, 0.14), transparent 24%), + linear-gradient(180deg, var(--bg-deep) 0%, var(--bg) 18%, var(--bg) 100%); + color: var(--text); + font-family: "Poppins", "Segoe UI", system-ui, sans-serif; +} + +body[data-view="status"] { + display: grid; + align-content: start; + justify-items: center; + padding: 2rem 1rem 3rem; +} + +body[data-view="index"] { + padding: 2rem 1rem 3rem; +} + +.page-chrome { + width: min(42rem, 100%); + margin-bottom: 1rem; +} + +.page-shell { + width: min(42rem, 100%); +} + +.page-shell-wide { + width: min(72rem, 100%); + margin: 0 auto; +} + +.header-copy { + display: grid; + gap: 0.85rem; +} + +.brand-lockup { + display: inline-flex; + align-items: center; + gap: 0.8rem; + width: fit-content; + color: var(--text); + text-decoration: none; +} + +.brand-mark { + width: 2.5rem; + height: 2.5rem; + flex: none; + filter: drop-shadow(0 0.4rem 1.1rem rgba(81, 43, 212, 0.42)); +} + +.brand-text { + display: grid; + gap: 0.05rem; +} + +.brand-title { + font-size: 1.05rem; + line-height: 1; +} + +.brand-subtitle { + color: var(--muted); + font-size: 0.82rem; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.nav-button, +.action-link, +.action-button, +.preview-link { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + border-radius: 0.75rem; + border: 1px solid var(--panel-border); + text-decoration: none; + transition: border-color 0.18s ease, transform 0.18s ease, background 0.18s ease; +} + +.nav-button, +.action-link, +.action-button { + min-height: 2.75rem; + padding: 0.7rem 1rem; + color: var(--text); + background: rgba(24, 24, 37, 0.84); + font: inherit; + font-weight: 600; +} + +.action-link-github { + background: var(--panel-strong); + cursor: pointer; +} + +.action-button.primary { + border-color: rgba(198, 194, 242, 0.35); + background: linear-gradient(180deg, var(--accent), var(--accent-strong)); + box-shadow: 0 1rem 2rem rgba(81, 43, 212, 0.28); +} + +.action-icon { + width: 1rem; + height: 1rem; + flex: none; +} + +.action-icon-github { + background-color: currentColor; + -webkit-mask-image: url("/_preview/github-mark.svg"); + mask-image: url("/_preview/github-mark.svg"); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-size: contain; + mask-size: contain; +} + +.action-icon-left-arrow, +.action-icon-retry { + background-color: currentColor; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-size: contain; + mask-size: contain; +} + +.action-icon-left-arrow { + -webkit-mask-image: url("/_preview/left-arrow.svg"); + mask-image: url("/_preview/left-arrow.svg"); +} + +.action-icon-retry { + -webkit-mask-image: url("/_preview/retry-arrow.svg"); + mask-image: url("/_preview/retry-arrow.svg"); +} + +.nav-button:hover, +.action-link:hover, +.action-button:hover:not(:disabled), +.preview-link:hover { + transform: translateY(-1px); + border-color: rgba(198, 194, 242, 0.55); +} + +.action-button { + cursor: pointer; +} + +.action-button.secondary { + background: var(--panel-strong); +} + +.action-button.danger { + border-color: rgba(246, 81, 99, 0.28); + background: rgba(246, 81, 99, 0.12); +} + +.action-button:disabled { + cursor: wait; + opacity: 0.7; +} + +.status-card, +.preview-card, +.empty-card { + border-radius: 0.9rem; + border: 1px solid var(--panel-border); + background: + linear-gradient(180deg, rgba(198, 194, 242, 0.05), transparent 20%), + var(--panel); + box-shadow: 0 1.5rem 3rem var(--shadow); + backdrop-filter: blur(12px); +} + +.status-card { + position: relative; + padding: 1.75rem; +} + +.card-busy-indicator { + position: absolute; + top: 1.5rem; + right: 1.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border-radius: 999px; + border: 1px solid var(--line); + background: rgba(24, 24, 37, 0.88); + box-shadow: 0 0.75rem 1.5rem rgba(10, 11, 20, 0.24); + pointer-events: none; +} + +.card-spinner { + width: 1rem; + height: 1rem; + border-radius: 999px; + border: 2px solid rgba(198, 194, 242, 0.2); + border-top-color: var(--accent); + border-right-color: var(--accent-soft); + animation: status-spinner 0.9s linear infinite; +} + +.card-header, +.collection-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; +} + +.card-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.75rem; +} + +.eyebrow { + margin: 0; + color: var(--accent-soft); + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.preview-title { + margin-top: 0.35rem; + font-size: 1.35rem; +} + +h1, +h2 { + margin: 0; + line-height: 1.08; +} + +h1 { + font-size: clamp(2rem, 4vw, 3rem); +} + +h2 { + font-size: 1.2rem; +} + +p { + line-height: 1.6; +} + +.status-message { + margin: 1rem 0 0; + color: var(--muted); + font-size: 1rem; +} + +.status-message.error { + color: var(--danger); +} + +.progress-section { + display: grid; + gap: 1rem; + margin-top: 1.5rem; +} + +.progress-card, +.terminal-section { + border-radius: 1rem; + border: 1px solid var(--line); + background: rgba(24, 24, 37, 0.82); + padding: 1rem; +} + +.progress-header { + display: flex; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.75rem; + color: var(--muted); +} + +.progress-header strong { + color: var(--text); +} + +.progress-track { + overflow: hidden; + border-radius: 999px; + background: rgba(53, 56, 62, 0.56); + padding: 0.3rem; +} + +.progress-bar { + height: 0.8rem; + width: 0%; + border-radius: 999px; + background: linear-gradient(90deg, var(--accent-strong), var(--accent)); + transition: width 0.2s ease; +} + +.progress-track.stage .progress-bar { + background: linear-gradient(90deg, rgba(233, 180, 76, 0.96), rgba(198, 194, 242, 0.92)); +} + +.terminal-section { + display: grid; + gap: 0.75rem; + justify-items: start; + margin-top: 1.5rem; +} + +.terminal-summary, +.hint-text, +.collection-summary { + margin: 0; + color: var(--muted); +} + +.terminal-summary.error { + color: var(--danger); +} + +.detail-grid { + margin: 1.5rem 0 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.detail-grid dt, +.preview-meta dt { + color: var(--muted); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.detail-grid dd, +.preview-meta dd { + margin: 0.35rem 0 0; +} + +.hint-text { + margin-top: 1.25rem; +} + +.status-card-actions { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 1rem; + margin-top: 1.5rem; + padding-top: 1.25rem; + border-top: 1px solid var(--line); +} + +.status-card-secondary-actions, +.status-card-primary-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.status-card-primary-actions { + margin-left: auto; + justify-content: flex-end; +} + +.status-chip { + display: inline-flex; + align-items: center; + justify-content: center; + width: fit-content; + min-height: 2rem; + padding: 0.35rem 0.7rem; + border-radius: 999px; + border: 1px solid var(--line); + background: rgba(24, 24, 37, 0.86); + color: var(--text); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.status-chip.ready { + border-color: rgba(198, 194, 242, 0.3); + background: rgba(116, 85, 221, 0.2); +} + +.status-chip.loading, +.status-chip.registered { + border-color: rgba(116, 85, 221, 0.28); + background: rgba(81, 43, 212, 0.22); +} + +.status-chip.failed, +.status-chip.cancelled, +.status-chip.evicted { + border-color: rgba(246, 81, 99, 0.24); + background: rgba(246, 81, 99, 0.14); +} + +.status-chip.missing { + border-color: rgba(233, 180, 76, 0.22); + background: rgba(233, 180, 76, 0.14); +} + +.status-chip.outdated, +.status-chip.draft { + border-color: rgba(233, 180, 76, 0.22); + background: rgba(233, 180, 76, 0.14); +} + +.collection-meta { + display: grid; + justify-items: end; + gap: 0.35rem; + text-align: right; +} + +.collection-controls { + display: flex; + justify-content: flex-start; + flex-wrap: wrap; + align-items: flex-start; + gap: 1rem; + margin-top: 1rem; +} + +.filter-stack { + display: grid; + gap: 0.55rem; + min-width: min(100%, 16rem); +} + +.filter-label { + color: var(--muted); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.filter-dropdown { + position: relative; + width: min(100%, 18rem); +} + +.filter-trigger { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.9rem; + width: 100%; + min-height: 2.85rem; + padding: 0.65rem 0.85rem; + border-radius: 0.95rem; + border: 1px solid var(--panel-border); + background: rgba(24, 24, 37, 0.88); + color: var(--text); + font: inherit; + text-align: left; + cursor: pointer; + transition: border-color 0.18s ease, transform 0.18s ease, background 0.18s ease, box-shadow 0.18s ease; +} + +.filter-trigger:hover:not(:disabled) { + transform: translateY(-1px); + border-color: rgba(198, 194, 242, 0.42); + background: rgba(35, 29, 65, 0.84); + box-shadow: 0 0.9rem 1.8rem rgba(10, 11, 20, 0.22); +} + +.filter-trigger:focus-visible { + outline: 2px solid rgba(198, 194, 242, 0.45); + outline-offset: 2px; +} + +.filter-trigger.is-active, +.filter-dropdown[data-open="true"] .filter-trigger { + border-color: rgba(198, 194, 242, 0.35); + background: linear-gradient(180deg, rgba(53, 44, 103, 0.92), rgba(35, 29, 65, 0.92)); +} + +.filter-trigger:disabled { + cursor: wait; +} + +.filter-trigger-copy { + display: grid; + gap: 0.2rem; + min-width: 0; +} + +.filter-trigger-value, +.filter-trigger-meta { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.filter-trigger-value { + font-weight: 600; +} + +.filter-trigger-meta { + color: var(--muted); + font-size: 0.84rem; +} + +.filter-trigger-icon { + width: 0.7rem; + height: 0.7rem; + margin-right: 0.15rem; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: rotate(45deg) translateY(-1px); + opacity: 0.78; + transition: transform 0.18s ease; + flex: none; +} + +.filter-dropdown[data-open="true"] .filter-trigger-icon { + transform: rotate(225deg) translateY(-1px); +} + +.filter-trigger-loading { + position: relative; + overflow: hidden; + opacity: 0.9; +} + +.filter-menu { + position: absolute; + top: calc(100% + 0.55rem); + left: 0; + z-index: 20; + display: grid; + gap: 0.35rem; + width: min(22rem, calc(100vw - 1.8rem)); + padding: 0.55rem; + border-radius: 1rem; + border: 1px solid rgba(198, 194, 242, 0.18); + background: linear-gradient(180deg, rgba(31, 30, 51, 0.98), rgba(24, 24, 37, 0.98)); + box-shadow: 0 1.35rem 2.7rem rgba(10, 11, 20, 0.38); +} + +.filter-menu[hidden] { + display: none; +} + +.filter-menu-section { + display: grid; + gap: 0.15rem; +} + +.filter-option { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: start; + gap: 0.75rem; + padding: 0.75rem; + border-radius: 0.85rem; + cursor: pointer; + transition: background 0.18s ease; +} + +.filter-option:hover { + background: rgba(53, 44, 103, 0.34); +} + +.filter-option-input { + position: absolute; + width: 1px; + height: 1px; + margin: 0; + padding: 0; + border: 0; + opacity: 0; + pointer-events: none; +} + +.filter-option-box { + width: 1.05rem; + height: 1.05rem; + margin-top: 0.08rem; + border-radius: 0.32rem; + border: 1px solid rgba(198, 194, 242, 0.28); + background: rgba(24, 24, 37, 0.9); + box-shadow: inset 0 0 0 1px rgba(10, 11, 20, 0.16); + transition: border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease; +} + +.filter-option-input:focus-visible + .filter-option-box { + outline: 2px solid rgba(198, 194, 242, 0.45); + outline-offset: 2px; +} + +.filter-option-input:checked + .filter-option-box { + position: relative; + border-color: rgba(198, 194, 242, 0.35); + background: linear-gradient(180deg, var(--accent), var(--accent-strong)); + box-shadow: 0 0 0 1px rgba(116, 85, 221, 0.14); +} + +.filter-option-input:checked + .filter-option-box::after { + content: ""; + position: absolute; + top: 0.15rem; + left: 0.34rem; + width: 0.22rem; + height: 0.45rem; + border: solid rgba(255, 255, 255, 0.94); + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.filter-option-copy { + display: grid; + gap: 0.18rem; + min-width: 0; +} + +.filter-option-title { + color: var(--text); + font-weight: 600; +} + +.filter-option-meta, +.filter-option-count, +.filter-menu-empty, +.filter-menu-hint { + color: var(--muted); + font-size: 0.84rem; +} + +.filter-option.is-empty .filter-option-count { + opacity: 0.7; +} + +.filter-option-count { + align-self: center; +} + +.filter-menu-empty { + padding: 0.75rem; +} + +.filter-menu-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.75rem; + padding: 0.35rem 0.75rem 0.15rem; +} + +.filter-menu-action { + border: none; + background: none; + padding: 0; + color: var(--accent); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.filter-menu-action:hover { + color: var(--accent-strong); +} + +.preview-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); + gap: 1rem; + margin-top: 1.5rem; +} + +.preview-card, +.empty-card { + padding: 1.15rem; +} + +.preview-card { + transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; +} + +.preview-card:hover { + transform: translateY(-1px); + border-color: rgba(198, 194, 242, 0.36); + box-shadow: 0 1rem 2rem rgba(10, 11, 20, 0.34); +} + +.preview-card-skeleton { + pointer-events: none; +} + +.preview-card-skeleton:hover { + transform: none; + border-color: var(--panel-border); + box-shadow: 0 1.5rem 3rem var(--shadow); +} + +.preview-card-topline { + display: flex; + justify-content: space-between; + gap: 0.75rem; + align-items: flex-start; +} + +.preview-card-tools { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + border-radius: 0.65rem; + border: 1px solid var(--panel-border); + background: rgba(24, 24, 37, 0.84); + color: var(--text); + text-decoration: none; + transition: border-color 0.18s ease, transform 0.18s ease, background 0.18s ease; +} + +.icon-button:hover { + transform: translateY(-1px); + border-color: rgba(198, 194, 242, 0.42); + background: rgba(35, 29, 65, 0.82); +} + +.preview-card-link { + display: grid; + gap: 0.55rem; + margin-top: 0.35rem; + color: inherit; + text-decoration: none; +} + +.preview-card-link:hover .preview-title { + color: var(--accent); +} + +.skeleton-line, +.skeleton-chip { + position: relative; + overflow: hidden; + border-radius: 999px; + background: rgba(198, 194, 242, 0.09); +} + +.skeleton-line::after, +.skeleton-chip::after, +.filter-trigger-loading::after { + content: ""; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + transparent 0%, + rgba(198, 194, 242, 0.08) 30%, + rgba(198, 194, 242, 0.22) 50%, + rgba(198, 194, 242, 0.08) 70%, + transparent 100% + ); + animation: skeleton-shimmer 1.35s ease-in-out infinite; +} + +.skeleton-line-eyebrow { + width: 5.5rem; + height: 0.9rem; +} + +.skeleton-line-title { + width: 82%; + height: 1.35rem; + margin-top: 0.35rem; +} + +.skeleton-line-subtitle { + width: 70%; + height: 0.95rem; + margin-top: 0.85rem; +} + +.skeleton-line-status { + width: 52%; + height: 0.95rem; +} + +.skeleton-chip { + width: 4.75rem; + height: 2rem; + flex: none; +} + +.preview-title-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem; + margin-top: 0.15rem; +} + +.preview-link { + color: var(--text); + background: transparent; + border: none; + padding: 0; + min-height: 0; + font-size: 1.15rem; + font-weight: 700; + justify-content: flex-start; +} + +.preview-path { + margin: 0.45rem 0 0; + color: var(--muted); + font-family: "Fira Code", "Cascadia Code", Consolas, monospace; + font-size: 0.92rem; +} + +.preview-card-subtitle { + margin: 0; + color: var(--muted); + font-size: 0.9rem; +} + +.preview-card-status-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.65rem; +} + +.preview-status-detail { + color: var(--muted); + font-size: 0.95rem; +} + +.preview-card-footer { + display: grid; + gap: 0.9rem; + margin-top: 1rem; +} + +.preview-card-meta { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; +} + +.preview-meta-pill { + display: inline-flex; + align-items: center; + min-height: 2rem; + padding: 0.35rem 0.7rem; + border-radius: 0.6rem; + border: 1px solid var(--line); + background: rgba(24, 24, 37, 0.82); + color: var(--muted); + font-size: 0.88rem; +} + +.preview-card-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 0; +} + +.preview-card-reset { + width: 100%; + min-height: 2.4rem; + padding: 0.6rem 0.85rem; + justify-content: center; + font-size: 0.92rem; +} + +.preview-meta { + margin: 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem 1rem; +} + +code { + border-radius: 0.4rem; + background: rgba(35, 29, 65, 0.72); + padding: 0.15rem 0.35rem; + font-family: Consolas, "Cascadia Code", monospace; +} + +@keyframes skeleton-shimmer { + 100% { + transform: translateX(100%); + } +} + +@keyframes status-spinner { + 100% { + transform: rotate(360deg); + } +} + +@media (max-width: 640px) { + body[data-view="status"], + body[data-view="index"] { + padding: 1rem 0.9rem 2rem; + } + + .status-card { + padding: 1.25rem; + } + + .card-busy-indicator { + top: 1rem; + right: 1rem; + } + + .card-header, + .collection-header { + flex-direction: column; + } + + .card-actions, + .collection-controls, + .collection-meta { + justify-content: flex-start; + justify-items: start; + text-align: left; + } + + .status-card-actions { + flex-direction: column; + align-items: stretch; + } + + .status-card-secondary-actions, + .status-card-primary-actions { + width: 100%; + } + + .status-card-primary-actions { + margin-left: 0; + justify-content: flex-start; + } + + .status-card-actions .action-link, + .status-card-actions .action-button { + width: 100%; + } + + .detail-grid, + .preview-meta { + grid-template-columns: 1fr; + } + + .preview-card-topline { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/src/statichost/PreviewHost/wwwroot/_preview/retry-arrow.svg b/src/statichost/PreviewHost/wwwroot/_preview/retry-arrow.svg new file mode 100644 index 000000000..b1f17ad24 --- /dev/null +++ b/src/statichost/PreviewHost/wwwroot/_preview/retry-arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/statichost/PreviewHost/wwwroot/_preview/status.html b/src/statichost/PreviewHost/wwwroot/_preview/status.html new file mode 100644 index 000000000..b953e280f --- /dev/null +++ b/src/statichost/PreviewHost/wwwroot/_preview/status.html @@ -0,0 +1,115 @@ + + + + + + Aspire PR Preview + + + + + + +
+
+ + +
+
+ + Aspire logo + + Aspire + PR Preview + + +

Preparing preview

+
+
+ +

+ + + + + +
+
+
Status
+
Preparing
+
+
+
Overall
+
0%
+
+
+
Preview path
+
/prs/
+
+
+
Updated
+
Waiting
+
+ + +
+ +

+ + +
+
+ + + + diff --git a/src/statichost/PreviewHost/wwwroot/_preview/status.js b/src/statichost/PreviewHost/wwwroot/_preview/status.js new file mode 100644 index 000000000..f98395974 --- /dev/null +++ b/src/statichost/PreviewHost/wwwroot/_preview/status.js @@ -0,0 +1,591 @@ +const routeMatch = window.location.pathname.match(/^\/prs\/(\d+)(?:\/.*)?$/i); +const pullRequestNumber = routeMatch ? Number(routeMatch[1]) : null; +const refreshTarget = pullRequestNumber ? `/prs/${pullRequestNumber}/` : "/prs/"; + +const pageTitle = document.getElementById("page-title"); +const message = document.getElementById("message"); +const progressSection = document.getElementById("progress-section"); +const terminalSection = document.getElementById("terminal-section"); +const terminalChip = document.getElementById("terminal-chip"); +const terminalSummary = document.getElementById("terminal-summary"); +const statusValue = document.getElementById("status-value"); +const overallSummary = document.getElementById("overall-summary"); +const previewPath = document.getElementById("preview-path"); +const updatedAt = document.getElementById("updated-at"); +const cardBusyIndicator = document.getElementById("card-busy-indicator"); +const downloadDetailGroup = document.getElementById("download-detail-group"); +const downloadDetail = document.getElementById("download-detail"); +const extractDetailGroup = document.getElementById("extract-detail-group"); +const extractDetail = document.getElementById("extract-detail"); +const hint = document.getElementById("hint"); +const overallProgressValue = document.getElementById("overall-progress-value"); +const stageProgressLabel = document.getElementById("stage-progress-label"); +const stageProgressValue = document.getElementById("stage-progress-value"); +const overallProgressBar = document.getElementById("overall-progress-bar"); +const stageProgressBar = document.getElementById("stage-progress-bar"); +const statusCardActions = document.getElementById("status-card-actions"); +const openPrLink = document.getElementById("open-pr-link"); +const cancelButton = document.getElementById("cancel-button"); +const retryButton = document.getElementById("retry-button"); +const retryButtonLabel = retryButton?.querySelector(".button-label"); + +const numberFormatter = new Intl.NumberFormat(); +const dateFormatter = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", +}); +const clockFormatter = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + second: "2-digit", +}); + +let currentState = null; +let eventSource = null; +let retryInFlight = false; +let cancelInFlight = false; +let suppressCloseCancellation = false; +let closeCancellationSent = false; +const downloadRateTracker = createRateTracker(); +const extractionRateTracker = createRateTracker(); + +initializeShell(); +window.addEventListener("pagehide", cancelIfClosingDuringPreparation); +window.addEventListener("beforeunload", cancelIfClosingDuringPreparation); +document.addEventListener("click", preservePreparationWhenReturningToCatalog); + +bootstrap().catch((error) => { + applyState(buildFailureState(error instanceof Error ? error.message : "The preview host could not load the preview status.")); +}); + +cancelButton.addEventListener("click", async () => { + if (!pullRequestNumber || cancelInFlight) { + return; + } + + cancelInFlight = true; + updateActionButtons(); + + try { + const response = await fetch(`/api/previews/${pullRequestNumber}/cancel`, { + method: "POST", + cache: "no-store", + }); + + if (!response.ok) { + throw new Error(`Cancel failed with status ${response.status}.`); + } + + closeEventSource(); + applyState(await response.json()); + } catch (error) { + hint.textContent = error instanceof Error + ? error.message + : "The preview host could not cancel the current preparation run."; + } finally { + cancelInFlight = false; + updateActionButtons(); + } +}); + +retryButton.addEventListener("click", async () => { + if (!pullRequestNumber || retryInFlight) { + return; + } + + retryInFlight = true; + updateActionButtons(); + + try { + const response = await fetch(`/api/previews/${pullRequestNumber}/retry`, { + method: "POST", + cache: "no-store", + }); + const payload = await readJsonSafely(response); + + if (!response.ok && response.status !== 202) { + applyState(buildFailureState( + payload?.failureMessage ?? `Retry failed with status ${response.status}.`, + payload ?? {}, + )); + return; + } + + if (!payload) { + await bootstrap(); + return; + } + + closeEventSource(); + applyState(payload); + + if (payload.isReady || payload.state === "Missing" || isTerminalState(payload.state)) { + return; + } + + if (isActiveState(payload.state)) { + connectEventsIfNeeded(payload); + return; + } + + await bootstrap(); + } catch (error) { + hint.textContent = error instanceof Error + ? error.message + : "The preview host could not restart preview preparation."; + } finally { + retryInFlight = false; + updateActionButtons(); + } +}); + +async function bootstrap() { + if (!pullRequestNumber) { + applyState(buildFailureState("This route does not contain a valid pull request number.")); + return; + } + + suppressCloseCancellation = false; + closeEventSource(); + + const response = await fetch(`/api/previews/${pullRequestNumber}/bootstrap`, { + cache: "no-store", + }); + const payload = await readJsonSafely(response); + + if (!response.ok) { + applyState(buildFailureState(payload?.failureMessage ?? "The preview host could not find a successful frontend build for this pull request yet.", payload)); + return; + } + + applyState(payload); + connectEventsIfNeeded(payload); +} + +function connectEventsIfNeeded(snapshot) { + if (!pullRequestNumber || !isActiveState(snapshot.state)) { + return; + } + + eventSource = new EventSource(`/api/previews/${pullRequestNumber}/events`); + eventSource.onmessage = (event) => { + const snapshotUpdate = JSON.parse(event.data); + applyState(snapshotUpdate); + + if (snapshotUpdate.isReady || isTerminalState(snapshotUpdate.state)) { + closeEventSource(); + } + }; + + eventSource.onerror = () => { + if (currentState && isActiveState(currentState.state)) { + hint.textContent = "Connection interrupted. The page will reconnect while the preview is still preparing."; + } + }; +} + +function applyState(snapshot) { + currentState = snapshot; + closeCancellationSent = false; + + if (snapshot.isReady) { + suppressCloseCancellation = true; + closeEventSource(); + window.location.replace(refreshTarget); + return; + } + + const statusLabel = getStatusLabel(snapshot.state); + const titleLabel = getTitle(snapshot); + const overallPercent = snapshot.percent ?? 0; + const stagePercent = snapshot.stagePercent ?? 0; + const activeState = isActiveState(snapshot.state); + const terminalState = isTerminalState(snapshot.state) || snapshot.state === "Missing"; + const text = formatMessage(snapshot.error ?? snapshot.message ?? snapshot.failureMessage ?? "Waiting for preview status."); + const downloadRate = updateRateTracker( + downloadRateTracker, + activeState && snapshot.stage === "Downloading", + snapshot.bytesDownloaded, + ); + const extractionRate = updateRateTracker( + extractionRateTracker, + activeState && snapshot.itemsLabel === "files", + snapshot.itemsCompleted, + ); + const downloadText = getDownloadText(snapshot, downloadRate); + const extractionText = getExtractionText(snapshot, extractionRate); + + document.title = titleLabel; + pageTitle.textContent = titleLabel; + message.textContent = text; + message.hidden = terminalState; + message.classList.toggle("error", terminalState && snapshot.state !== "Cancelled"); + progressSection.hidden = !activeState; + terminalSection.hidden = !terminalState; + cardBusyIndicator.hidden = !activeState; + statusValue.textContent = statusLabel; + overallSummary.textContent = activeState ? `${overallPercent}%` : statusLabel; + previewPath.textContent = snapshot.previewPath ?? refreshTarget; + updatedAt.textContent = snapshot.updatedAtUtc ? formatUpdated(snapshot.updatedAtUtc) : "Waiting"; + downloadDetailGroup.hidden = !downloadText; + downloadDetail.textContent = downloadText ?? "Waiting"; + extractDetailGroup.hidden = !extractionText; + extractDetail.textContent = extractionText ?? "Waiting"; + hint.textContent = getHint(snapshot); + + overallProgressValue.textContent = `${overallPercent}%`; + stageProgressLabel.textContent = getStageProgressLabel(snapshot); + stageProgressValue.textContent = `${stagePercent}%`; + overallProgressBar.style.width = `${overallPercent}%`; + stageProgressBar.style.width = `${stagePercent}%`; + + terminalChip.textContent = statusLabel; + terminalChip.className = `status-chip ${getStateClassName(snapshot.state)}`; + terminalSummary.textContent = terminalState ? text : ""; + terminalSummary.classList.toggle("error", terminalState && snapshot.state !== "Cancelled"); + + updateOpenPrLink(snapshot); + updateActionButtons(); +} + +function initializeShell() { + if (!pullRequestNumber) { + return; + } + + const initialTitle = `Preparing PR #${pullRequestNumber}`; + document.title = initialTitle; + pageTitle.textContent = initialTitle; + message.textContent = "Checking GitHub for the latest successful frontend artifact."; + message.hidden = false; + cardBusyIndicator.hidden = false; + previewPath.textContent = refreshTarget; + hint.textContent = "This page starts loading the preview automatically when a build is available."; +} + +function updateOpenPrLink(snapshot) { + const repositoryOwner = snapshot.repositoryOwner; + const repositoryName = snapshot.repositoryName; + if (!repositoryOwner || !repositoryName || !pullRequestNumber) { + openPrLink.hidden = true; + return; + } + + openPrLink.href = `https://github.com/${repositoryOwner}/${repositoryName}/pull/${pullRequestNumber}`; + openPrLink.hidden = false; +} + +function updateActionButtons() { + const canCancel = currentState && isActiveState(currentState.state); + const canRetry = currentState && (isTerminalState(currentState.state) || currentState.state === "Missing"); + + cancelButton.hidden = !canCancel && !cancelInFlight; + cancelButton.disabled = !canCancel || cancelInFlight; + cancelButton.textContent = cancelInFlight ? "Cancelling..." : "Cancel prep"; + + retryButton.hidden = !canRetry && !retryInFlight; + retryButton.disabled = !canRetry || retryInFlight; + if (retryButtonLabel) { + retryButtonLabel.textContent = retryInFlight ? "Restarting..." : "Retry prep"; + } + + if (statusCardActions) { + statusCardActions.hidden = openPrLink.hidden && cancelButton.hidden && retryButton.hidden; + } +} + +function buildFailureState(messageText, payload = {}) { + return { + pullRequestNumber: payload.pullRequestNumber ?? pullRequestNumber ?? 0, + state: payload.state ?? "Missing", + stage: payload.stage ?? "Failed", + message: payload.message ?? messageText, + error: payload.error ?? messageText, + percent: payload.percent ?? 0, + stagePercent: payload.stagePercent ?? 100, + bytesDownloaded: payload.bytesDownloaded ?? null, + bytesTotal: payload.bytesTotal ?? null, + itemsCompleted: payload.itemsCompleted ?? null, + itemsTotal: payload.itemsTotal ?? null, + itemsLabel: payload.itemsLabel ?? null, + updatedAtUtc: payload.updatedAtUtc ?? new Date().toISOString(), + previewPath: payload.previewPath ?? refreshTarget, + repositoryOwner: payload.repositoryOwner ?? null, + repositoryName: payload.repositoryName ?? null, + isReady: false, + }; +} + +async function readJsonSafely(response) { + try { + return await response.json(); + } catch { + return null; + } +} + +function closeEventSource() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } +} + +function preservePreparationWhenReturningToCatalog(event) { + if (!(event.target instanceof Element) || event.defaultPrevented) { + return; + } + + const link = event.target.closest("a[data-preserve-preparation='true']"); + if (!(link instanceof HTMLAnchorElement)) { + return; + } + + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + + if (link.target && link.target !== "_self") { + return; + } + + suppressCloseCancellation = true; +} + +function cancelIfClosingDuringPreparation() { + if (!pullRequestNumber + || suppressCloseCancellation + || closeCancellationSent + || retryInFlight + || cancelInFlight + || !currentState + || !isActiveState(currentState.state)) { + return; + } + + closeCancellationSent = true; + const cancelUrl = `/api/previews/${pullRequestNumber}/cancel`; + + if (typeof navigator.sendBeacon === "function") { + navigator.sendBeacon(cancelUrl, new Blob([], { type: "application/octet-stream" })); + return; + } + + fetch(cancelUrl, { + method: "POST", + cache: "no-store", + keepalive: true, + }).catch(() => { + }); +} + +function isActiveState(state) { + return state === "Registered" || state === "Loading"; +} + +function isTerminalState(state) { + return state === "Failed" || state === "Cancelled" || state === "Evicted"; +} + +function getStatusLabel(state) { + switch (state) { + case "Registered": + return "Queued"; + case "Loading": + return "Preparing"; + case "Ready": + return "Ready"; + case "Failed": + return "Failed"; + case "Cancelled": + return "Cancelled"; + case "Evicted": + return "Evicted"; + case "Missing": + return "Unavailable"; + default: + return state ?? "Preparing"; + } +} + +function getTitle(snapshot) { + const number = snapshot.pullRequestNumber ?? pullRequestNumber ?? "?"; + if (snapshot.state === "Failed" || snapshot.state === "Missing") { + return `PR #${number} preview unavailable`; + } + + if (snapshot.state === "Cancelled") { + return `PR #${number} prep cancelled`; + } + + return `Preparing PR #${number}`; +} + +function getHint(snapshot) { + if (snapshot.state === "Missing") { + return "Retry after CI publishes a new artifact."; + } + + if (snapshot.state === "Failed") { + return "Fix the backing configuration or publish a new build, then retry."; + } + + if (snapshot.state === "Cancelled") { + return "Retry when you are ready to start again."; + } + + if (snapshot.state === "Evicted") { + return "Retry to prepare it again."; + } + + return "This page will open the preview automatically as soon as preparation finishes."; +} + +function getStateClassName(state) { + return String(state ?? "missing").toLowerCase(); +} + +function getStageProgressLabel(snapshot) { + if (snapshot.stage === "Downloading") { + return "Downloading preview artifact"; + } + + if (snapshot.stage === "Extracting" && snapshot.itemsLabel === "files") { + return "Extracting preview files"; + } + + return snapshot.stage ? `${snapshot.stage} progress` : "Stage progress"; +} + +function formatMessage(text) { + return String(text).replace(/\d{4,}/g, (value) => numberFormatter.format(Number(value))); +} + +function formatUpdated(value) { + const date = new Date(value); + return Number.isNaN(date.getTime()) + ? value + : `${dateFormatter.format(date)} · ${clockFormatter.format(date)}`; +} + +function getDownloadText(snapshot, bytesPerSecond) { + if (!Number.isFinite(snapshot.bytesDownloaded) && !Number.isFinite(snapshot.bytesTotal)) { + return null; + } + + const parts = []; + + if (Number.isFinite(snapshot.bytesDownloaded) && Number.isFinite(snapshot.bytesTotal) && snapshot.bytesTotal > 0) { + parts.push(`${formatBytes(snapshot.bytesDownloaded)} / ${formatBytes(snapshot.bytesTotal)}`); + } else if (Number.isFinite(snapshot.bytesDownloaded)) { + parts.push(`${formatBytes(snapshot.bytesDownloaded)} downloaded`); + } + + if (snapshot.stage === "Downloading" && Number.isFinite(bytesPerSecond) && bytesPerSecond > 0) { + parts.push(`${formatBytes(bytesPerSecond)}/s`); + } + + return parts.join(" · "); +} + +function getExtractionText(snapshot, itemsPerSecond) { + if (snapshot.itemsLabel !== "files" || !Number.isFinite(snapshot.itemsCompleted) && !Number.isFinite(snapshot.itemsTotal)) { + return null; + } + + const completed = Number.isFinite(snapshot.itemsCompleted) + ? numberFormatter.format(snapshot.itemsCompleted) + : "0"; + const total = Number.isFinite(snapshot.itemsTotal) && snapshot.itemsTotal > 0 + ? ` / ${numberFormatter.format(snapshot.itemsTotal)}` + : ""; + + const parts = [`${completed}${total} files`]; + if (Number.isFinite(itemsPerSecond) && itemsPerSecond > 0) { + parts.push(`${formatUnitRate(itemsPerSecond)} files/s`); + } + + return parts.join(" · "); +} + +function formatBytes(value) { + const bytes = Number(value); + if (!Number.isFinite(bytes) || bytes < 0) { + return "0 B"; + } + + const units = ["B", "KB", "MB", "GB", "TB"]; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex += 1; + } + + const digits = size >= 100 || unitIndex === 0 ? 0 : size >= 10 ? 1 : 2; + return `${size.toFixed(digits)} ${units[unitIndex]}`; +} + +function createRateTracker() { + return { + samples: [], + lastRate: null, + lastRateUpdatedAt: 0, + }; +} + +function updateRateTracker(tracker, isActive, value) { + const now = Date.now(); + + if (!isActive || !Number.isFinite(value) || Number(value) < 0) { + tracker.samples = []; + return now - tracker.lastRateUpdatedAt <= 10000 + ? tracker.lastRate + : null; + } + + const numericValue = Number(value); + const lastSample = tracker.samples[tracker.samples.length - 1]; + + if (lastSample && numericValue < lastSample.value) { + tracker.samples = []; + } + + if (!lastSample || numericValue !== lastSample.value || now - lastSample.time >= 500) { + tracker.samples.push({ time: now, value: numericValue }); + } + + while (tracker.samples.length > 0 && now - tracker.samples[0].time > 8000) { + tracker.samples.shift(); + } + + const firstSample = tracker.samples[0]; + const newestSample = tracker.samples[tracker.samples.length - 1]; + if (!firstSample || !newestSample || newestSample.time <= firstSample.time || newestSample.value <= firstSample.value) { + return now - tracker.lastRateUpdatedAt <= 10000 + ? tracker.lastRate + : null; + } + + tracker.lastRate = (newestSample.value - firstSample.value) / ((newestSample.time - firstSample.time) / 1000); + tracker.lastRateUpdatedAt = now; + return tracker.lastRate; +} + +function formatUnitRate(value) { + const numericValue = Number(value); + if (!Number.isFinite(numericValue) || numericValue <= 0) { + return "0"; + } + + if (numericValue >= 100) { + return numberFormatter.format(Math.round(numericValue)); + } + + if (numericValue >= 10) { + return numericValue.toFixed(1); + } + + return numericValue.toFixed(2); +} From f01db6b8b17a6196493154595bd4a7bf844fcb56 Mon Sep 17 00:00:00 2001 From: David Pine Date: Tue, 31 Mar 2026 08:38:07 -0500 Subject: [PATCH 2/5] Fix preview workflow metadata resolution Pass exact preview artifact metadata from the parent CI run into register-preview so the workflow no longer guesses artifact details across workflow_run boundaries. Also use the Aspire bot GitHub App token for PR body updates and artifact access when available. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 64 ++++++++++++++++- .github/workflows/frontend-build.yml | 27 +++++++ .github/workflows/pr-preview-cleanup.yml | 18 +++++ .github/workflows/pr-preview-register.yml | 86 ++++++++++++++++------- 4 files changed, 170 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9368b36a7..ac6236b14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,28 +72,80 @@ jobs: artifact_retention_days: ${{ github.event_name == 'pull_request' && 30 || 7 }} site_base_path: ${{ github.event_name == 'pull_request' && format('/prs/{0}', github.event.pull_request.number) || '/' }} + preview-registration-metadata: + needs: [changes, frontend-build] + if: ${{ github.event_name == 'pull_request' && needs.frontend-build.result == 'success' }} + runs-on: ubuntu-latest + steps: + - name: Write preview registration metadata + shell: bash + env: + REPOSITORY_OWNER: ${{ github.repository_owner }} + REPOSITORY_NAME: ${{ github.event.repository.name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + ARTIFACT_NAME: ${{ needs.frontend-build.outputs.artifact_name }} + ARTIFACT_RUN_ID: ${{ needs.frontend-build.outputs.artifact_run_id }} + ARTIFACT_RUN_ATTEMPT: ${{ needs.frontend-build.outputs.artifact_run_attempt }} + COMPLETED_AT_UTC: ${{ needs.frontend-build.outputs.completed_at_utc }} + run: | + metadata_dir="$RUNNER_TEMP/pr-preview-registration" + mkdir -p "$metadata_dir" + + jq -n \ + --arg repositoryOwner "$REPOSITORY_OWNER" \ + --arg repositoryName "$REPOSITORY_NAME" \ + --arg headSha "$HEAD_SHA" \ + --arg artifactName "$ARTIFACT_NAME" \ + --arg completedAtUtc "$COMPLETED_AT_UTC" \ + --argjson pullRequestNumber "$PR_NUMBER" \ + --argjson runId "$ARTIFACT_RUN_ID" \ + --argjson runAttempt "$ARTIFACT_RUN_ATTEMPT" \ + '{ + repositoryOwner: $repositoryOwner, + repositoryName: $repositoryName, + pullRequestNumber: $pullRequestNumber, + headSha: $headSha, + runId: $runId, + runAttempt: $runAttempt, + artifactName: $artifactName, + completedAtUtc: $completedAtUtc + }' > "$metadata_dir/preview-registration.json" + + - name: Upload preview registration metadata + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: pr-preview-registration + path: ${{ runner.temp }}/pr-preview-registration/preview-registration.json + if-no-files-found: error + retention-days: 7 + apphost-build: needs: changes if: ${{ needs.changes.outputs.apphost == 'true' }} uses: ./.github/workflows/apphost-build.yml ci-gate: - needs: [changes, frontend-build, apphost-build] + needs: [changes, frontend-build, preview-registration-metadata, apphost-build] if: ${{ always() && !cancelled() }} runs-on: ubuntu-latest steps: - name: Verify CI results shell: bash env: + IS_PULL_REQUEST: ${{ github.event_name == 'pull_request' }} CHANGES_RESULT: ${{ needs.changes.result }} FRONTEND_CHANGED: ${{ needs.changes.outputs.frontend }} APPHOST_CHANGED: ${{ needs.changes.outputs.apphost }} FRONTEND_RESULT: ${{ needs['frontend-build'].result }} + PREVIEW_METADATA_RESULT: ${{ needs['preview-registration-metadata'].result }} APPHOST_RESULT: ${{ needs['apphost-build'].result }} run: | + echo "is pull_request event: $IS_PULL_REQUEST" echo "changes result: $CHANGES_RESULT" echo "frontend changed: $FRONTEND_CHANGED" echo "frontend-build result: $FRONTEND_RESULT" + echo "preview-registration-metadata result: $PREVIEW_METADATA_RESULT" echo "apphost changed: $APPHOST_CHANGED" echo "apphost-build result: $APPHOST_RESULT" @@ -112,6 +164,16 @@ jobs: exit 1 fi + if [[ "$IS_PULL_REQUEST" == "true" && "$FRONTEND_CHANGED" == "true" ]]; then + if [[ "$PREVIEW_METADATA_RESULT" != "success" ]]; then + echo "preview-registration-metadata should have run and succeeded." + exit 1 + fi + elif [[ "$PREVIEW_METADATA_RESULT" != "skipped" ]]; then + echo "preview-registration-metadata should have been skipped." + exit 1 + fi + if [[ "$APPHOST_CHANGED" == "true" ]]; then if [[ "$APPHOST_RESULT" != "success" ]]; then echo "apphost-build should have run and succeeded." diff --git a/.github/workflows/frontend-build.yml b/.github/workflows/frontend-build.yml index 6827bbfc2..780ab564a 100644 --- a/.github/workflows/frontend-build.yml +++ b/.github/workflows/frontend-build.yml @@ -23,6 +23,19 @@ on: required: false default: "/" type: string + outputs: + artifact_name: + description: Artifact name published by the workflow + value: ${{ jobs.build.outputs.artifact_name }} + artifact_run_id: + description: Workflow run ID that owns the published artifact + value: ${{ jobs.build.outputs.artifact_run_id }} + artifact_run_attempt: + description: Workflow run attempt that published the artifact + value: ${{ jobs.build.outputs.artifact_run_attempt }} + completed_at_utc: + description: UTC timestamp when the artifact workflow completed successfully + value: ${{ jobs.build.outputs.completed_at_utc }} permissions: contents: read @@ -31,6 +44,11 @@ jobs: build: name: Frontend Build runs-on: ubuntu-latest + outputs: + artifact_name: ${{ steps.publish-metadata.outputs.artifact_name }} + artifact_run_id: ${{ steps.publish-metadata.outputs.artifact_run_id }} + artifact_run_attempt: ${{ steps.publish-metadata.outputs.artifact_run_attempt }} + completed_at_utc: ${{ steps.publish-metadata.outputs.completed_at_utc }} defaults: run: working-directory: src/frontend @@ -81,3 +99,12 @@ jobs: path: src/frontend/dist if-no-files-found: warn retention-days: ${{ inputs.artifact_retention_days }} + + - name: Capture artifact metadata + id: publish-metadata + shell: bash + run: | + echo "artifact_name=${{ inputs.artifact_name }}" >> "$GITHUB_OUTPUT" + echo "artifact_run_id=${{ github.run_id }}" >> "$GITHUB_OUTPUT" + echo "artifact_run_attempt=${{ github.run_attempt }}" >> "$GITHUB_OUTPUT" + echo "completed_at_utc=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/pr-preview-cleanup.yml b/.github/workflows/pr-preview-cleanup.yml index eb8109d3a..22bd1f3df 100644 --- a/.github/workflows/pr-preview-cleanup.yml +++ b/.github/workflows/pr-preview-cleanup.yml @@ -16,6 +16,8 @@ jobs: id: config env: PREVIEW_REGISTRATION_TOKEN: ${{ secrets.PR_PREVIEW_REGISTRATION_TOKEN }} + ASPIRE_BOT_APP_ID: ${{ secrets.ASPIRE_BOT_APP_ID }} + ASPIRE_BOT_PRIVATE_KEY: ${{ secrets.ASPIRE_BOT_PRIVATE_KEY }} run: | if [ -z "$PREVIEW_REGISTRATION_TOKEN" ]; then echo "configured=false" >> "$GITHUB_OUTPUT" @@ -23,6 +25,21 @@ jobs: echo "configured=true" >> "$GITHUB_OUTPUT" fi + if [ -z "$ASPIRE_BOT_APP_ID" ] || [ -z "$ASPIRE_BOT_PRIVATE_KEY" ]; then + echo "app_configured=false" >> "$GITHUB_OUTPUT" + else + echo "app_configured=true" >> "$GITHUB_OUTPUT" + fi + + - name: Create Aspire bot token + if: ${{ steps.config.outputs.app_configured == 'true' }} + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.ASPIRE_BOT_APP_ID }} + private-key: ${{ secrets.ASPIRE_BOT_PRIVATE_KEY }} + permission-pull-requests: write + - name: Remove preview registration if: ${{ steps.config.outputs.configured == 'true' }} env: @@ -38,6 +55,7 @@ jobs: - name: Remove preview block from PR description uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: + github-token: ${{ steps.app-token.outputs.token || github.token }} script: | const markerStart = ''; const markerEnd = ''; diff --git a/.github/workflows/pr-preview-register.yml b/.github/workflows/pr-preview-register.yml index 39eec65c2..7275d0407 100644 --- a/.github/workflows/pr-preview-register.yml +++ b/.github/workflows/pr-preview-register.yml @@ -19,6 +19,8 @@ jobs: id: config env: PREVIEW_REGISTRATION_TOKEN: ${{ secrets.PR_PREVIEW_REGISTRATION_TOKEN }} + ASPIRE_BOT_APP_ID: ${{ secrets.ASPIRE_BOT_APP_ID }} + ASPIRE_BOT_PRIVATE_KEY: ${{ secrets.ASPIRE_BOT_PRIVATE_KEY }} run: | if [ -z "$PREVIEW_REGISTRATION_TOKEN" ]; then echo "configured=false" >> "$GITHUB_OUTPUT" @@ -26,22 +28,30 @@ jobs: echo "configured=true" >> "$GITHUB_OUTPUT" fi - - name: Resolve preview metadata + if [ -z "$ASPIRE_BOT_APP_ID" ] || [ -z "$ASPIRE_BOT_PRIVATE_KEY" ]; then + echo "app_configured=false" >> "$GITHUB_OUTPUT" + else + echo "app_configured=true" >> "$GITHUB_OUTPUT" + fi + + - name: Create Aspire bot token + if: ${{ steps.config.outputs.configured == 'true' && steps.config.outputs.app_configured == 'true' }} + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.ASPIRE_BOT_APP_ID }} + private-key: ${{ secrets.ASPIRE_BOT_PRIVATE_KEY }} + permission-actions: read + permission-pull-requests: write + + - name: Locate preview registration metadata artifact if: ${{ steps.config.outputs.configured == 'true' }} - id: metadata + id: metadata-artifact uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: + github-token: ${{ steps.app-token.outputs.token || github.token }} script: | const run = context.payload.workflow_run; - - if (!run.pull_requests || run.pull_requests.length === 0) { - core.setOutput('should_register', 'false'); - return; - } - - const pullRequest = run.pull_requests[0]; - const artifactName = `frontend-dist-pr-${pullRequest.number}`; - const artifacts = await github.paginate( github.rest.actions.listWorkflowRunArtifacts, { @@ -51,20 +61,47 @@ jobs: per_page: 100, }); - const previewArtifact = artifacts.find(artifact => artifact.name === artifactName && !artifact.expired); - if (!previewArtifact) { - core.info(`No preview artifact named ${artifactName} was found on run ${run.id}.`); - core.setOutput('should_register', 'false'); + const metadataArtifact = artifacts.find(artifact => artifact.name === 'pr-preview-registration' && !artifact.expired); + if (!metadataArtifact) { + core.info(`No preview registration metadata artifact was found on run ${run.id}.`); + core.setOutput('found', 'false'); return; } - core.setOutput('should_register', 'true'); - core.setOutput('pr_number', String(pullRequest.number)); - core.setOutput('artifact_name', artifactName); - core.setOutput('head_sha', run.head_sha); - core.setOutput('run_id', String(run.id)); - core.setOutput('run_attempt', String(run.run_attempt)); - core.setOutput('completed_at', run.updated_at ?? run.created_at); + core.setOutput('found', 'true'); + core.setOutput('artifact_id', String(metadataArtifact.id)); + + - name: Download preview registration metadata + if: ${{ steps.config.outputs.configured == 'true' && steps.metadata-artifact.outputs.found == 'true' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ steps.metadata-artifact.outputs.artifact_id }} + path: ${{ runner.temp }}/pr-preview-registration + github-token: ${{ steps.app-token.outputs.token || github.token }} + repository: ${{ github.repository }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Resolve preview metadata + if: ${{ steps.config.outputs.configured == 'true' && steps.metadata-artifact.outputs.found == 'true' }} + id: metadata + shell: bash + run: | + metadata_file="${RUNNER_TEMP}/pr-preview-registration/preview-registration.json" + + if [ ! -f "$metadata_file" ]; then + echo "Expected preview registration metadata at $metadata_file." + exit 1 + fi + + echo "should_register=true" >> "$GITHUB_OUTPUT" + echo "repository_owner=$(jq -re '.repositoryOwner' "$metadata_file")" >> "$GITHUB_OUTPUT" + echo "repository_name=$(jq -re '.repositoryName' "$metadata_file")" >> "$GITHUB_OUTPUT" + echo "pr_number=$(jq -re '.pullRequestNumber' "$metadata_file")" >> "$GITHUB_OUTPUT" + echo "artifact_name=$(jq -re '.artifactName' "$metadata_file")" >> "$GITHUB_OUTPUT" + echo "head_sha=$(jq -re '.headSha' "$metadata_file")" >> "$GITHUB_OUTPUT" + echo "run_id=$(jq -re '.runId' "$metadata_file")" >> "$GITHUB_OUTPUT" + echo "run_attempt=$(jq -re '.runAttempt' "$metadata_file")" >> "$GITHUB_OUTPUT" + echo "completed_at=$(jq -re '.completedAtUtc' "$metadata_file")" >> "$GITHUB_OUTPUT" - name: Register preview build if: ${{ steps.config.outputs.configured == 'true' && steps.metadata.outputs.should_register == 'true' }} @@ -77,8 +114,8 @@ jobs: RUN_ID: ${{ steps.metadata.outputs.run_id }} RUN_ATTEMPT: ${{ steps.metadata.outputs.run_attempt }} COMPLETED_AT: ${{ steps.metadata.outputs.completed_at }} - REPOSITORY_OWNER: ${{ github.repository_owner }} - REPOSITORY_NAME: ${{ github.event.repository.name }} + REPOSITORY_OWNER: ${{ steps.metadata.outputs.repository_owner }} + REPOSITORY_NAME: ${{ steps.metadata.outputs.repository_name }} run: | payload=$(jq -n \ --arg repositoryOwner "$REPOSITORY_OWNER" \ @@ -114,6 +151,7 @@ jobs: PREVIEW_BASE_URL: ${{ vars.PR_PREVIEW_BASE_URL }} PR_NUMBER: ${{ steps.metadata.outputs.pr_number }} with: + github-token: ${{ steps.app-token.outputs.token || github.token }} script: | const markerStart = ''; const markerEnd = ''; From 3144450ea54e4cc6c88458fbcfcd4b7fd6388686 Mon Sep 17 00:00:00 2001 From: David Pine Date: Tue, 31 Mar 2026 08:42:04 -0500 Subject: [PATCH 3/5] Address PR review feedback Fix the AppHost workflow indentation, validate preview registrations against the configured repository, make registry writes durable, harden cleanup deletes, and align the Preview AppHost to the repo's current Aspire package versions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/apphost-build.yml | 60 +++++++++---------- .../Aspire.Dev.Preview.AppHost.csproj | 4 +- .../Previews/PreviewCoordinator.cs | 44 ++++++++++++-- .../PreviewHost/Previews/PreviewStateStore.cs | 33 +++++++++- src/statichost/PreviewHost/Program.cs | 23 +++++++ 5 files changed, 125 insertions(+), 39 deletions(-) diff --git a/.github/workflows/apphost-build.yml b/.github/workflows/apphost-build.yml index f7bb6f4a2..e608718a2 100644 --- a/.github/workflows/apphost-build.yml +++ b/.github/workflows/apphost-build.yml @@ -23,33 +23,33 @@ jobs: project_path: src/apphost/Aspire.Dev.Preview.AppHost artifact_name: preview-apphost-release steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 - with: - global-json-file: global.json - - - name: Restore - run: cd ${{ matrix.apphost.project_path }} && dotnet restore - - - name: Build - run: cd ${{ matrix.apphost.project_path }} && dotnet build --no-restore --configuration Release - - - name: Verify output - run: | - APPHOST_DLL=$(ls -1 ${{ matrix.apphost.project_path }}/bin/Release/*/${{ matrix.apphost.project_name }}.dll 2>/dev/null | head -n 1) - if [ -z "$APPHOST_DLL" ]; then - echo "AppHost build failed - output assembly not found" - ls -R ${{ matrix.apphost.project_path }}/bin/Release || true - exit 1 - fi - echo "Found $APPHOST_DLL" - - - name: Upload artifact - if: ${{ always() }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.apphost.artifact_name }} - path: ${{ matrix.apphost.project_path }}/bin/Release/*/ - if-no-files-found: warn - retention-days: 7 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: + global-json-file: global.json + + - name: Restore + run: cd ${{ matrix.apphost.project_path }} && dotnet restore + + - name: Build + run: cd ${{ matrix.apphost.project_path }} && dotnet build --no-restore --configuration Release + + - name: Verify output + run: | + APPHOST_DLL=$(ls -1 ${{ matrix.apphost.project_path }}/bin/Release/*/${{ matrix.apphost.project_name }}.dll 2>/dev/null | head -n 1) + if [ -z "$APPHOST_DLL" ]; then + echo "AppHost build failed - output assembly not found" + ls -R ${{ matrix.apphost.project_path }}/bin/Release || true + exit 1 + fi + echo "Found $APPHOST_DLL" + + - name: Upload artifact + if: ${{ always() }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ matrix.apphost.artifact_name }} + path: ${{ matrix.apphost.project_path }}/bin/Release/*/ + if-no-files-found: warn + retention-days: 7 diff --git a/src/apphost/Aspire.Dev.Preview.AppHost/Aspire.Dev.Preview.AppHost.csproj b/src/apphost/Aspire.Dev.Preview.AppHost/Aspire.Dev.Preview.AppHost.csproj index 18b2edc0b..4359c112f 100644 --- a/src/apphost/Aspire.Dev.Preview.AppHost/Aspire.Dev.Preview.AppHost.csproj +++ b/src/apphost/Aspire.Dev.Preview.AppHost/Aspire.Dev.Preview.AppHost.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,7 +9,7 @@ - + diff --git a/src/statichost/PreviewHost/Previews/PreviewCoordinator.cs b/src/statichost/PreviewHost/Previews/PreviewCoordinator.cs index 29e5b6584..6a5d4df74 100644 --- a/src/statichost/PreviewHost/Previews/PreviewCoordinator.cs +++ b/src/statichost/PreviewHost/Previews/PreviewCoordinator.cs @@ -810,19 +810,51 @@ private static string ResolveActivationSourceDirectory(string extractedRoot) return nestedDirectory?.Directory ?? extractedRoot; } - private static void DeleteFileIfPresent(string? path) + private void DeleteFileIfPresent(string? path) { - if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) + if (string.IsNullOrWhiteSpace(path)) { - File.Delete(path); + return; + } + + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (IOException exception) + { + _logger.LogWarning(exception, "Failed to delete temporary preview file {Path}", path); + } + catch (UnauthorizedAccessException exception) + { + _logger.LogWarning(exception, "Failed to delete temporary preview file {Path}", path); } } - private static void DeleteDirectoryIfPresent(string? path) + private void DeleteDirectoryIfPresent(string? path) { - if (!string.IsNullOrWhiteSpace(path) && Directory.Exists(path)) + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch (IOException exception) + { + _logger.LogWarning(exception, "Failed to delete temporary preview directory {Directory}", path); + } + catch (UnauthorizedAccessException exception) { - Directory.Delete(path, recursive: true); + _logger.LogWarning(exception, "Failed to delete temporary preview directory {Directory}", path); } } diff --git a/src/statichost/PreviewHost/Previews/PreviewStateStore.cs b/src/statichost/PreviewHost/Previews/PreviewStateStore.cs index beb7e3960..7a2cce061 100644 --- a/src/statichost/PreviewHost/Previews/PreviewStateStore.cs +++ b/src/statichost/PreviewHost/Previews/PreviewStateStore.cs @@ -508,7 +508,19 @@ private async Task SaveLockedAsync(CancellationToken cancellationToken) { Directory.CreateDirectory(StateRoot); var json = JsonSerializer.Serialize(_records, JsonOptions); - await File.WriteAllTextAsync(_registryPath, json, cancellationToken); + var registryDirectory = Path.GetDirectoryName(_registryPath) ?? StateRoot; + Directory.CreateDirectory(registryDirectory); + + var tempFilePath = Path.Combine(registryDirectory, $"{Path.GetFileName(_registryPath)}.{Path.GetRandomFileName()}.tmp"); + try + { + await File.WriteAllTextAsync(tempFilePath, json, cancellationToken); + File.Move(tempFilePath, _registryPath, overwrite: true); + } + finally + { + DeleteFileIfPresent(tempFilePath); + } } private bool NormalizeLoadedRecord(PreviewRecord record) @@ -650,4 +662,23 @@ private void DeleteDirectoryIfPresent(string path) _logger.LogWarning(exception, "Failed to delete preview directory {Directory}", path); } } + + private void DeleteFileIfPresent(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (IOException exception) + { + _logger.LogWarning(exception, "Failed to delete preview file {Path}", path); + } + catch (UnauthorizedAccessException exception) + { + _logger.LogWarning(exception, "Failed to delete preview file {Path}", path); + } + } } diff --git a/src/statichost/PreviewHost/Program.cs b/src/statichost/PreviewHost/Program.cs index f1817b10e..7dc35868c 100644 --- a/src/statichost/PreviewHost/Program.cs +++ b/src/statichost/PreviewHost/Program.cs @@ -283,6 +283,11 @@ return Results.ValidationProblem(validationErrors); } + if (!TryValidateRepository(request, options.Value, out var repositoryValidationErrors)) + { + return Results.ValidationProblem(repositoryValidationErrors); + } + var result = await stateStore.RegisterAsync(request, cancellationToken); return Results.Json(new { @@ -331,6 +336,24 @@ static bool TryValidate(PreviewRegistrationRequest request, out Dictionary validationErrors) +{ + if (string.Equals(request.RepositoryOwner, options.RepositoryOwner, StringComparison.OrdinalIgnoreCase) + && string.Equals(request.RepositoryName, options.RepositoryName, StringComparison.OrdinalIgnoreCase)) + { + validationErrors = []; + return true; + } + + var repositoryMessage = $"Preview registrations must target the configured repository '{options.RepositoryOwner}/{options.RepositoryName}'."; + validationErrors = new Dictionary(StringComparer.Ordinal) + { + [nameof(PreviewRegistrationRequest.RepositoryOwner)] = [repositoryMessage], + [nameof(PreviewRegistrationRequest.RepositoryName)] = [repositoryMessage] + }; + return false; +} + static bool HasValidBearerToken(HttpRequest request, string expectedToken) { if (string.IsNullOrWhiteSpace(expectedToken)) From 7f17ffef4d77790f6d7df82cb530fe5379fbd142 Mon Sep 17 00:00:00 2001 From: David Pine Date: Tue, 31 Mar 2026 15:38:35 -0500 Subject: [PATCH 4/5] feat: Implement GitHub authentication and user access control for preview host - Added session management to track user authentication status and display user information in the UI. - Integrated GitHub OAuth for sign-in and sign-out functionality. - Created a new authorization handler to check user write access for previews. - Enhanced the preview catalog and status pages to reflect user authentication state. - Updated UI components to show user avatar and role when signed in. - Implemented CSRF protection for state-changing requests. - Added caching for user access permissions to improve performance. --- .github/workflows/ci.yml | 62 +- .github/workflows/frontend-build.yml | 27 - .github/workflows/pr-preview-cleanup.yml | 72 -- .github/workflows/pr-preview-register.yml | 187 ---- .../Aspire.Dev.Preview.AppHost/AppHost.cs | 14 +- .../Previews/GitHubArtifactClient.cs | 52 +- .../PreviewHost/Previews/PreviewAuth.cs | 87 ++ .../Previews/PreviewCoordinator.cs | 161 +++- .../PreviewHost/Previews/PreviewModels.cs | 213 ++++- .../Previews/PreviewRequestDispatcher.cs | 34 +- .../PreviewHost/Previews/PreviewStateStore.cs | 46 + src/statichost/PreviewHost/Program.cs | 804 +++++++++++++----- .../PreviewHost/wwwroot/_preview/index.html | 18 +- .../PreviewHost/wwwroot/_preview/index.js | 167 +++- .../PreviewHost/wwwroot/_preview/preview.css | 56 ++ .../PreviewHost/wwwroot/_preview/status.html | 20 +- .../PreviewHost/wwwroot/_preview/status.js | 248 ++++-- 17 files changed, 1584 insertions(+), 684 deletions(-) delete mode 100644 .github/workflows/pr-preview-cleanup.yml delete mode 100644 .github/workflows/pr-preview-register.yml create mode 100644 src/statichost/PreviewHost/Previews/PreviewAuth.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac6236b14..8e0264e0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,61 +72,13 @@ jobs: artifact_retention_days: ${{ github.event_name == 'pull_request' && 30 || 7 }} site_base_path: ${{ github.event_name == 'pull_request' && format('/prs/{0}', github.event.pull_request.number) || '/' }} - preview-registration-metadata: - needs: [changes, frontend-build] - if: ${{ github.event_name == 'pull_request' && needs.frontend-build.result == 'success' }} - runs-on: ubuntu-latest - steps: - - name: Write preview registration metadata - shell: bash - env: - REPOSITORY_OWNER: ${{ github.repository_owner }} - REPOSITORY_NAME: ${{ github.event.repository.name }} - PR_NUMBER: ${{ github.event.pull_request.number }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - ARTIFACT_NAME: ${{ needs.frontend-build.outputs.artifact_name }} - ARTIFACT_RUN_ID: ${{ needs.frontend-build.outputs.artifact_run_id }} - ARTIFACT_RUN_ATTEMPT: ${{ needs.frontend-build.outputs.artifact_run_attempt }} - COMPLETED_AT_UTC: ${{ needs.frontend-build.outputs.completed_at_utc }} - run: | - metadata_dir="$RUNNER_TEMP/pr-preview-registration" - mkdir -p "$metadata_dir" - - jq -n \ - --arg repositoryOwner "$REPOSITORY_OWNER" \ - --arg repositoryName "$REPOSITORY_NAME" \ - --arg headSha "$HEAD_SHA" \ - --arg artifactName "$ARTIFACT_NAME" \ - --arg completedAtUtc "$COMPLETED_AT_UTC" \ - --argjson pullRequestNumber "$PR_NUMBER" \ - --argjson runId "$ARTIFACT_RUN_ID" \ - --argjson runAttempt "$ARTIFACT_RUN_ATTEMPT" \ - '{ - repositoryOwner: $repositoryOwner, - repositoryName: $repositoryName, - pullRequestNumber: $pullRequestNumber, - headSha: $headSha, - runId: $runId, - runAttempt: $runAttempt, - artifactName: $artifactName, - completedAtUtc: $completedAtUtc - }' > "$metadata_dir/preview-registration.json" - - - name: Upload preview registration metadata - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: pr-preview-registration - path: ${{ runner.temp }}/pr-preview-registration/preview-registration.json - if-no-files-found: error - retention-days: 7 - apphost-build: needs: changes if: ${{ needs.changes.outputs.apphost == 'true' }} uses: ./.github/workflows/apphost-build.yml ci-gate: - needs: [changes, frontend-build, preview-registration-metadata, apphost-build] + needs: [changes, frontend-build, apphost-build] if: ${{ always() && !cancelled() }} runs-on: ubuntu-latest steps: @@ -138,14 +90,12 @@ jobs: FRONTEND_CHANGED: ${{ needs.changes.outputs.frontend }} APPHOST_CHANGED: ${{ needs.changes.outputs.apphost }} FRONTEND_RESULT: ${{ needs['frontend-build'].result }} - PREVIEW_METADATA_RESULT: ${{ needs['preview-registration-metadata'].result }} APPHOST_RESULT: ${{ needs['apphost-build'].result }} run: | echo "is pull_request event: $IS_PULL_REQUEST" echo "changes result: $CHANGES_RESULT" echo "frontend changed: $FRONTEND_CHANGED" echo "frontend-build result: $FRONTEND_RESULT" - echo "preview-registration-metadata result: $PREVIEW_METADATA_RESULT" echo "apphost changed: $APPHOST_CHANGED" echo "apphost-build result: $APPHOST_RESULT" @@ -164,16 +114,6 @@ jobs: exit 1 fi - if [[ "$IS_PULL_REQUEST" == "true" && "$FRONTEND_CHANGED" == "true" ]]; then - if [[ "$PREVIEW_METADATA_RESULT" != "success" ]]; then - echo "preview-registration-metadata should have run and succeeded." - exit 1 - fi - elif [[ "$PREVIEW_METADATA_RESULT" != "skipped" ]]; then - echo "preview-registration-metadata should have been skipped." - exit 1 - fi - if [[ "$APPHOST_CHANGED" == "true" ]]; then if [[ "$APPHOST_RESULT" != "success" ]]; then echo "apphost-build should have run and succeeded." diff --git a/.github/workflows/frontend-build.yml b/.github/workflows/frontend-build.yml index 780ab564a..6827bbfc2 100644 --- a/.github/workflows/frontend-build.yml +++ b/.github/workflows/frontend-build.yml @@ -23,19 +23,6 @@ on: required: false default: "/" type: string - outputs: - artifact_name: - description: Artifact name published by the workflow - value: ${{ jobs.build.outputs.artifact_name }} - artifact_run_id: - description: Workflow run ID that owns the published artifact - value: ${{ jobs.build.outputs.artifact_run_id }} - artifact_run_attempt: - description: Workflow run attempt that published the artifact - value: ${{ jobs.build.outputs.artifact_run_attempt }} - completed_at_utc: - description: UTC timestamp when the artifact workflow completed successfully - value: ${{ jobs.build.outputs.completed_at_utc }} permissions: contents: read @@ -44,11 +31,6 @@ jobs: build: name: Frontend Build runs-on: ubuntu-latest - outputs: - artifact_name: ${{ steps.publish-metadata.outputs.artifact_name }} - artifact_run_id: ${{ steps.publish-metadata.outputs.artifact_run_id }} - artifact_run_attempt: ${{ steps.publish-metadata.outputs.artifact_run_attempt }} - completed_at_utc: ${{ steps.publish-metadata.outputs.completed_at_utc }} defaults: run: working-directory: src/frontend @@ -99,12 +81,3 @@ jobs: path: src/frontend/dist if-no-files-found: warn retention-days: ${{ inputs.artifact_retention_days }} - - - name: Capture artifact metadata - id: publish-metadata - shell: bash - run: | - echo "artifact_name=${{ inputs.artifact_name }}" >> "$GITHUB_OUTPUT" - echo "artifact_run_id=${{ github.run_id }}" >> "$GITHUB_OUTPUT" - echo "artifact_run_attempt=${{ github.run_attempt }}" >> "$GITHUB_OUTPUT" - echo "completed_at_utc=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/pr-preview-cleanup.yml b/.github/workflows/pr-preview-cleanup.yml deleted file mode 100644 index 22bd1f3df..000000000 --- a/.github/workflows/pr-preview-cleanup.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: PR Preview Cleanup - -on: - pull_request: - types: [ closed ] - -permissions: - pull-requests: write - -jobs: - cleanup-preview: - if: ${{ vars.PR_PREVIEW_BASE_URL != '' }} - runs-on: ubuntu-latest - steps: - - name: Check preview configuration - id: config - env: - PREVIEW_REGISTRATION_TOKEN: ${{ secrets.PR_PREVIEW_REGISTRATION_TOKEN }} - ASPIRE_BOT_APP_ID: ${{ secrets.ASPIRE_BOT_APP_ID }} - ASPIRE_BOT_PRIVATE_KEY: ${{ secrets.ASPIRE_BOT_PRIVATE_KEY }} - run: | - if [ -z "$PREVIEW_REGISTRATION_TOKEN" ]; then - echo "configured=false" >> "$GITHUB_OUTPUT" - else - echo "configured=true" >> "$GITHUB_OUTPUT" - fi - - if [ -z "$ASPIRE_BOT_APP_ID" ] || [ -z "$ASPIRE_BOT_PRIVATE_KEY" ]; then - echo "app_configured=false" >> "$GITHUB_OUTPUT" - else - echo "app_configured=true" >> "$GITHUB_OUTPUT" - fi - - - name: Create Aspire bot token - if: ${{ steps.config.outputs.app_configured == 'true' }} - id: app-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 - with: - app-id: ${{ secrets.ASPIRE_BOT_APP_ID }} - private-key: ${{ secrets.ASPIRE_BOT_PRIVATE_KEY }} - permission-pull-requests: write - - - name: Remove preview registration - if: ${{ steps.config.outputs.configured == 'true' }} - env: - PREVIEW_BASE_URL: ${{ vars.PR_PREVIEW_BASE_URL }} - PREVIEW_REGISTRATION_TOKEN: ${{ secrets.PR_PREVIEW_REGISTRATION_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - curl --fail-with-body \ - --request DELETE \ - --url "${PREVIEW_BASE_URL%/}/api/previews/${PR_NUMBER}" \ - --header "Authorization: Bearer $PREVIEW_REGISTRATION_TOKEN" - - - name: Remove preview block from PR description - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - github-token: ${{ steps.app-token.outputs.token || github.token }} - script: | - const markerStart = ''; - const markerEnd = ''; - const pullRequest = context.payload.pull_request; - const existingBody = pullRequest.body ?? ''; - const blockExpression = new RegExp(`\\n?${markerStart}[\\s\\S]*?${markerEnd}\\n?`, 'm'); - const updatedBody = existingBody.replace(blockExpression, '\n').trimEnd(); - - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pullRequest.number, - body: updatedBody, - }); diff --git a/.github/workflows/pr-preview-register.yml b/.github/workflows/pr-preview-register.yml deleted file mode 100644 index 7275d0407..000000000 --- a/.github/workflows/pr-preview-register.yml +++ /dev/null @@ -1,187 +0,0 @@ -name: PR Preview Register - -on: - workflow_run: - workflows: [ CI ] - types: [ completed ] - -permissions: - actions: read - contents: read - pull-requests: write - -jobs: - register-preview: - if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' && vars.PR_PREVIEW_BASE_URL != '' }} - runs-on: ubuntu-latest - steps: - - name: Check preview configuration - id: config - env: - PREVIEW_REGISTRATION_TOKEN: ${{ secrets.PR_PREVIEW_REGISTRATION_TOKEN }} - ASPIRE_BOT_APP_ID: ${{ secrets.ASPIRE_BOT_APP_ID }} - ASPIRE_BOT_PRIVATE_KEY: ${{ secrets.ASPIRE_BOT_PRIVATE_KEY }} - run: | - if [ -z "$PREVIEW_REGISTRATION_TOKEN" ]; then - echo "configured=false" >> "$GITHUB_OUTPUT" - else - echo "configured=true" >> "$GITHUB_OUTPUT" - fi - - if [ -z "$ASPIRE_BOT_APP_ID" ] || [ -z "$ASPIRE_BOT_PRIVATE_KEY" ]; then - echo "app_configured=false" >> "$GITHUB_OUTPUT" - else - echo "app_configured=true" >> "$GITHUB_OUTPUT" - fi - - - name: Create Aspire bot token - if: ${{ steps.config.outputs.configured == 'true' && steps.config.outputs.app_configured == 'true' }} - id: app-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 - with: - app-id: ${{ secrets.ASPIRE_BOT_APP_ID }} - private-key: ${{ secrets.ASPIRE_BOT_PRIVATE_KEY }} - permission-actions: read - permission-pull-requests: write - - - name: Locate preview registration metadata artifact - if: ${{ steps.config.outputs.configured == 'true' }} - id: metadata-artifact - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - github-token: ${{ steps.app-token.outputs.token || github.token }} - script: | - const run = context.payload.workflow_run; - const artifacts = await github.paginate( - github.rest.actions.listWorkflowRunArtifacts, - { - owner: context.repo.owner, - repo: context.repo.repo, - run_id: run.id, - per_page: 100, - }); - - const metadataArtifact = artifacts.find(artifact => artifact.name === 'pr-preview-registration' && !artifact.expired); - if (!metadataArtifact) { - core.info(`No preview registration metadata artifact was found on run ${run.id}.`); - core.setOutput('found', 'false'); - return; - } - - core.setOutput('found', 'true'); - core.setOutput('artifact_id', String(metadataArtifact.id)); - - - name: Download preview registration metadata - if: ${{ steps.config.outputs.configured == 'true' && steps.metadata-artifact.outputs.found == 'true' }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - artifact-ids: ${{ steps.metadata-artifact.outputs.artifact_id }} - path: ${{ runner.temp }}/pr-preview-registration - github-token: ${{ steps.app-token.outputs.token || github.token }} - repository: ${{ github.repository }} - run-id: ${{ github.event.workflow_run.id }} - - - name: Resolve preview metadata - if: ${{ steps.config.outputs.configured == 'true' && steps.metadata-artifact.outputs.found == 'true' }} - id: metadata - shell: bash - run: | - metadata_file="${RUNNER_TEMP}/pr-preview-registration/preview-registration.json" - - if [ ! -f "$metadata_file" ]; then - echo "Expected preview registration metadata at $metadata_file." - exit 1 - fi - - echo "should_register=true" >> "$GITHUB_OUTPUT" - echo "repository_owner=$(jq -re '.repositoryOwner' "$metadata_file")" >> "$GITHUB_OUTPUT" - echo "repository_name=$(jq -re '.repositoryName' "$metadata_file")" >> "$GITHUB_OUTPUT" - echo "pr_number=$(jq -re '.pullRequestNumber' "$metadata_file")" >> "$GITHUB_OUTPUT" - echo "artifact_name=$(jq -re '.artifactName' "$metadata_file")" >> "$GITHUB_OUTPUT" - echo "head_sha=$(jq -re '.headSha' "$metadata_file")" >> "$GITHUB_OUTPUT" - echo "run_id=$(jq -re '.runId' "$metadata_file")" >> "$GITHUB_OUTPUT" - echo "run_attempt=$(jq -re '.runAttempt' "$metadata_file")" >> "$GITHUB_OUTPUT" - echo "completed_at=$(jq -re '.completedAtUtc' "$metadata_file")" >> "$GITHUB_OUTPUT" - - - name: Register preview build - if: ${{ steps.config.outputs.configured == 'true' && steps.metadata.outputs.should_register == 'true' }} - env: - PREVIEW_BASE_URL: ${{ vars.PR_PREVIEW_BASE_URL }} - PREVIEW_REGISTRATION_TOKEN: ${{ secrets.PR_PREVIEW_REGISTRATION_TOKEN }} - PR_NUMBER: ${{ steps.metadata.outputs.pr_number }} - ARTIFACT_NAME: ${{ steps.metadata.outputs.artifact_name }} - HEAD_SHA: ${{ steps.metadata.outputs.head_sha }} - RUN_ID: ${{ steps.metadata.outputs.run_id }} - RUN_ATTEMPT: ${{ steps.metadata.outputs.run_attempt }} - COMPLETED_AT: ${{ steps.metadata.outputs.completed_at }} - REPOSITORY_OWNER: ${{ steps.metadata.outputs.repository_owner }} - REPOSITORY_NAME: ${{ steps.metadata.outputs.repository_name }} - run: | - payload=$(jq -n \ - --arg repositoryOwner "$REPOSITORY_OWNER" \ - --arg repositoryName "$REPOSITORY_NAME" \ - --arg headSha "$HEAD_SHA" \ - --arg artifactName "$ARTIFACT_NAME" \ - --arg completedAtUtc "$COMPLETED_AT" \ - --argjson pullRequestNumber "$PR_NUMBER" \ - --argjson runId "$RUN_ID" \ - --argjson runAttempt "$RUN_ATTEMPT" \ - '{ - repositoryOwner: $repositoryOwner, - repositoryName: $repositoryName, - pullRequestNumber: $pullRequestNumber, - headSha: $headSha, - runId: $runId, - runAttempt: $runAttempt, - artifactName: $artifactName, - completedAtUtc: $completedAtUtc - }') - - curl --fail-with-body \ - --request POST \ - --url "${PREVIEW_BASE_URL%/}/api/previews/registrations" \ - --header "Authorization: Bearer $PREVIEW_REGISTRATION_TOKEN" \ - --header "Content-Type: application/json" \ - --data "$payload" - - - name: Update PR description preview block - if: ${{ steps.config.outputs.configured == 'true' && steps.metadata.outputs.should_register == 'true' }} - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - PREVIEW_BASE_URL: ${{ vars.PR_PREVIEW_BASE_URL }} - PR_NUMBER: ${{ steps.metadata.outputs.pr_number }} - with: - github-token: ${{ steps.app-token.outputs.token || github.token }} - script: | - const markerStart = ''; - const markerEnd = ''; - const prNumber = Number(process.env.PR_NUMBER); - const previewUrl = new URL(`/prs/${prNumber}/`, process.env.PREVIEW_BASE_URL).toString(); - const badgeUrl = `https://img.shields.io/badge/aspire.dev-${encodeURIComponent(`PR #${prNumber}`)}-512BD4?style=for-the-badge&labelColor=512BD4&color=555555`; - - const pullRequest = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - }); - - const existingBody = pullRequest.data.body ?? ''; - const previewBlock = [ - markerStart, - '## 🌐 Preview', - '', - `[![](${badgeUrl})](${previewUrl})`, - markerEnd, - ].join('\n'); - - const blockExpression = new RegExp(`${markerStart}[\\s\\S]*?${markerEnd}`, 'm'); - const updatedBody = blockExpression.test(existingBody) - ? existingBody.replace(blockExpression, previewBlock) - : `${existingBody.trimEnd()}${existingBody.trim() ? '\n\n' : ''}${previewBlock}`; - - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - body: updatedBody, - }); diff --git a/src/apphost/Aspire.Dev.Preview.AppHost/AppHost.cs b/src/apphost/Aspire.Dev.Preview.AppHost/AppHost.cs index 6d5872b2d..5233efa73 100644 --- a/src/apphost/Aspire.Dev.Preview.AppHost/AppHost.cs +++ b/src/apphost/Aspire.Dev.Preview.AppHost/AppHost.cs @@ -1,13 +1,21 @@ var builder = DistributedApplication.CreateBuilder(args); -var registrationToken = builder.AddParameter("registration-token", secret: true); var extractionMode = builder.AddParameter("extraction-mode", value: "command-line"); +var githubOAuthClientId = builder.AddParameter("github-oauth-client-id"); +var githubOAuthClientSecret = builder.AddParameter("github-oauth-client-secret", secret: true); +var previewControlBaseUrl = builder.AddParameter("preview-control-base-url", value: string.Empty); +var previewContentBaseUrl = builder.AddParameter("preview-content-base-url", value: string.Empty); +var previewAuthCookieDomain = builder.AddParameter("preview-auth-cookie-domain", value: string.Empty); var previewHost = builder.AddProject("previewhost") .PublishAsDockerFile() .WithExternalHttpEndpoints() - .WithEnvironment("PreviewHost__RegistrationToken", registrationToken) - .WithEnvironment("PreviewHost__ExtractionMode", extractionMode); + .WithEnvironment("PreviewHost__ExtractionMode", extractionMode) + .WithEnvironment("PreviewHost__GitHubOAuthClientId", githubOAuthClientId) + .WithEnvironment("PreviewHost__GitHubOAuthClientSecret", githubOAuthClientSecret) + .WithEnvironment("PreviewHost__ControlBaseUrl", previewControlBaseUrl) + .WithEnvironment("PreviewHost__ContentBaseUrl", previewContentBaseUrl) + .WithEnvironment("PreviewHost__AuthCookieDomain", previewAuthCookieDomain); if (!string.IsNullOrWhiteSpace(builder.Configuration["PreviewHost:GitHubToken"])) { diff --git a/src/statichost/PreviewHost/Previews/GitHubArtifactClient.cs b/src/statichost/PreviewHost/Previews/GitHubArtifactClient.cs index 42e712895..4f1d142cb 100644 --- a/src/statichost/PreviewHost/Previews/GitHubArtifactClient.cs +++ b/src/statichost/PreviewHost/Previews/GitHubArtifactClient.cs @@ -301,6 +301,14 @@ public async Task GetArtifactDescriptorAsync(PreviewWo $"Could not find a non-expired GitHub Actions artifact named '{workItem.ArtifactName}' on run {workItem.RunId}."); } + if (_options.MaxArtifactSizeBytes > 0 + && artifact.SizeInBytes > 0 + && artifact.SizeInBytes > _options.MaxArtifactSizeBytes) + { + throw new InvalidOperationException( + "The preview artifact exceeds the preview host safety limits."); + } + return new GitHubArtifactDescriptor( workItem.RepositoryOwner, workItem.RepositoryName, @@ -385,6 +393,36 @@ public async Task DownloadArtifactAsync( } } + public async Task ReviewCollaboratorPermissionAsync(string login, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(login); + EnsureCredentialsConfigured(); + EnsureRepositoryConfigured(); + + var repositoryClient = await CreateRepositoryClientAsync( + _options.RepositoryOwner, + _options.RepositoryName, + cancellationToken); + + try + { + var response = await repositoryClient.Repository.Collaborator.ReviewPermission( + _options.RepositoryOwner, + _options.RepositoryName, + login); + + var roleName = NormalizePermissionValue(response.RoleName); + var permission = NormalizePermissionValue(response.Permission); + var hasWriteAccess = HasWriteAccess(roleName) || HasWriteAccess(permission); + + return new PreviewUserAccessResult(login, hasWriteAccess, roleName, permission); + } + catch (NotFoundException) + { + return new PreviewUserAccessResult(login, false, null, null); + } + } + private static async Task GetArtifactsAsync( GitHubClient repositoryClient, string repositoryOwner, @@ -670,11 +708,11 @@ private void EnsureRepositoryConfigured() private static Artifact? ResolvePreviewArtifact(IEnumerable artifacts, int pullRequestNumber) { - var preferredNames = new[] - { + string[] preferredNames = + [ $"frontend-dist-pr-{pullRequestNumber}", DefaultPreviewArtifactName - }; + ]; foreach (var artifactName in preferredNames) { @@ -691,6 +729,14 @@ private void EnsureRepositoryConfigured() return null; } + private static string? NormalizePermissionValue(string? value) => + string.IsNullOrWhiteSpace(value) + ? null + : value.Trim().ToLowerInvariant(); + + private static bool HasWriteAccess(string? permission) => + permission is "admin" or "maintain" or "write" or "push"; + private static bool IsSuccessfulPreviewRun(WorkflowRun run) => string.Equals(run.Conclusion?.StringValue, "success", StringComparison.OrdinalIgnoreCase) && (string.Equals(run.Path, CiWorkflowPath, StringComparison.Ordinal) diff --git a/src/statichost/PreviewHost/Previews/PreviewAuth.cs b/src/statichost/PreviewHost/Previews/PreviewAuth.cs new file mode 100644 index 000000000..19fababaf --- /dev/null +++ b/src/statichost/PreviewHost/Previews/PreviewAuth.cs @@ -0,0 +1,87 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Caching.Memory; + +namespace PreviewHost.Previews; + +internal static class PreviewAuthenticationDefaults +{ + public const string CookieScheme = "PreviewHostCookie"; + public const string GitHubScheme = "GitHub"; + public const string WriterPolicy = "PreviewWriter"; + public const string CsrfHeaderName = "X-Preview-Csrf"; + public const string CsrfRequestTokenCookieName = "previewhost-csrf"; + public const string UserLoginClaimType = "urn:github:login"; + public const string UserDisplayNameClaimType = "urn:github:name"; + public const string UserAvatarUrlClaimType = "urn:github:avatar_url"; + public const string UserProfileUrlClaimType = "urn:github:html_url"; +} + +internal sealed class PreviewWriterRequirement : IAuthorizationRequirement; + +internal sealed class PreviewWriterAuthorizationHandler(PreviewUserAccessService accessService) + : AuthorizationHandler +{ + private readonly PreviewUserAccessService _accessService = accessService; + + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + PreviewWriterRequirement requirement) + { + var login = context.User.FindFirstValue(PreviewAuthenticationDefaults.UserLoginClaimType) + ?? context.User.FindFirstValue(ClaimTypes.Name); + + if (string.IsNullOrWhiteSpace(login)) + { + return; + } + + var access = await _accessService.GetAccessAsync(login, CancellationToken.None); + if (access.HasWriteAccess) + { + context.Succeed(requirement); + } + } +} + +internal sealed class PreviewUserAccessService( + IMemoryCache cache, + GitHubArtifactClient artifactClient, + ILogger logger) +{ + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); + private readonly IMemoryCache _cache = cache; + private readonly GitHubArtifactClient _artifactClient = artifactClient; + private readonly ILogger _logger = logger; + + public async Task GetAccessAsync(string login, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(login); + + var cacheKey = $"preview-user-access:{login}"; + if (_cache.TryGetValue(cacheKey, out PreviewUserAccessResult? cachedAccess) && cachedAccess is not null) + { + return cachedAccess; + } + + var access = await _artifactClient.ReviewCollaboratorPermissionAsync(login, cancellationToken); + _cache.Set(cacheKey, access, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = CacheDuration + }); + + _logger.LogInformation( + "Preview host access review for @{Login}: write access {HasWriteAccess} ({RoleName})", + login, + access.HasWriteAccess, + access.RoleName ?? access.Permission ?? "unknown"); + + return access; + } +} + +internal sealed record PreviewUserAccessResult( + string Login, + bool HasWriteAccess, + string? RoleName, + string? Permission); diff --git a/src/statichost/PreviewHost/Previews/PreviewCoordinator.cs b/src/statichost/PreviewHost/Previews/PreviewCoordinator.cs index 6a5d4df74..fdb78e434 100644 --- a/src/statichost/PreviewHost/Previews/PreviewCoordinator.cs +++ b/src/statichost/PreviewHost/Previews/PreviewCoordinator.cs @@ -29,6 +29,7 @@ internal sealed class PreviewCoordinator( private readonly ConcurrentDictionary> _activeDiscovery = []; private readonly ConcurrentDictionary _activeLoadCancellations = []; private readonly ConcurrentDictionary _activeLoads = []; + private readonly SemaphoreSlim _loadConcurrencyGate = new(Math.Max(1, options.Value.MaxConcurrentLoads)); private readonly PreviewStateStore _stateStore = stateStore; private readonly GitHubArtifactClient _artifactClient = artifactClient; private readonly ILogger _logger = logger; @@ -52,23 +53,20 @@ public async Task BootstrapAsync(int pullRequestNumber, var snapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); if (snapshot is null) { - var discovery = await EnsureRegisteredAsync(pullRequestNumber, cancellationToken); - snapshot = discovery.Snapshot; - if (snapshot is null) - { - return discovery; - } + return new PreviewDiscoveryResult( + Snapshot: null, + FailureMessage: "This preview hasn't been enabled yet. Open it from the catalog or retry this page to prepare the latest successful frontend build."); } - if (snapshot.State is PreviewLoadState.Cancelled or PreviewLoadState.Failed or PreviewLoadState.Evicted) + if (snapshot.State is PreviewLoadState.Evicted) { snapshot = await _stateStore.RequeueAsync( pullRequestNumber, - "Retrying preview preparation.", + "Reloading the preview after it was evicted from the warm window.", cancellationToken) ?? snapshot; } - if (!snapshot.IsReady) + if (snapshot.State is PreviewLoadState.Registered or PreviewLoadState.Loading) { EnsureLoading(pullRequestNumber); snapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken) ?? snapshot; @@ -77,25 +75,57 @@ public async Task BootstrapAsync(int pullRequestNumber, return new PreviewDiscoveryResult(snapshot); } - public async Task RetryAsync(int pullRequestNumber, CancellationToken cancellationToken) + public async Task PrepareAsync(int pullRequestNumber, CancellationToken cancellationToken) { - var snapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); - if (snapshot is null) + try { - return await BootstrapAsync(pullRequestNumber, cancellationToken); - } + var registrationRequest = await _artifactClient.TryResolveLatestPreviewRegistrationAsync(pullRequestNumber, cancellationToken); + PreviewStatusSnapshot? snapshot; - if (!snapshot.IsReady) + if (registrationRequest is not null) + { + var registrationResult = await _stateStore.RegisterAsync(registrationRequest, cancellationToken); + snapshot = registrationResult.Snapshot; + } + else + { + snapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); + if (snapshot is null) + { + return new PreviewDiscoveryResult( + Snapshot: null, + FailureMessage: "The preview host could not find a successful frontend artifact for this pull request yet."); + } + } + + if (snapshot.State is PreviewLoadState.Cancelled or PreviewLoadState.Failed or PreviewLoadState.Evicted) + { + snapshot = await _stateStore.RequeueAsync( + pullRequestNumber, + "Preparing the latest preview build.", + cancellationToken) ?? snapshot; + } + + if (!snapshot.IsReady) + { + EnsureLoading(pullRequestNumber); + snapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken) ?? snapshot; + } + + return new PreviewDiscoveryResult(snapshot); + } + catch (Exception exception) { - snapshot = await _stateStore.RequeueAsync( - pullRequestNumber, - "Retrying preview preparation.", - cancellationToken) ?? snapshot; + _logger.LogError(exception, "Failed to prepare preview metadata for PR #{PullRequestNumber}", pullRequestNumber); + return new PreviewDiscoveryResult( + Snapshot: null, + FailureMessage: "The preview host could not look up the latest successful build for this pull request."); } + } - EnsureLoading(pullRequestNumber); - snapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken) ?? snapshot; - return new PreviewDiscoveryResult(snapshot); + public async Task RetryAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + return await PrepareAsync(pullRequestNumber, cancellationToken); } public async Task CancelAsync(int pullRequestNumber, CancellationToken cancellationToken) @@ -210,6 +240,8 @@ private async Task LoadAsync(int pullRequestNumber, CancellationToken cancellati string? stagingDirectoryPath = null; var downloadProgress = new ProgressThrottle(); + await _loadConcurrencyGate.WaitAsync(cancellationToken); + try { var workItem = await _stateStore.GetWorkItemAsync(pullRequestNumber, cancellationToken); @@ -352,6 +384,8 @@ await _stateStore.UpdateProgressAsync( } finally { + _loadConcurrencyGate.Release(); + if (_activeLoadCancellations.TryRemove(pullRequestNumber, out var cancellationSource)) { cancellationSource.Dispose(); @@ -397,7 +431,8 @@ private async Task ExtractArchiveAsync( var extractionToolDescription = _options.ExtractionToolDescription; var extractionStopwatch = Stopwatch.StartNew(); var extractionMessage = $"Extracting preview files with {extractionToolDescription}."; - var totalFileCount = CountArchiveFileEntries(zipPath); + var archiveInspection = InspectArchive(zipPath); + var totalFileCount = archiveInspection.FileCount; var bufferSettings = PreviewBufferSettings.Resolve(); using var extractionProgressState = new ExtractionProgressState(totalFileCount); @@ -687,10 +722,74 @@ await _stateStore.UpdateProgressAsync( } } - private static int CountArchiveFileEntries(string zipPath) + private (int FileCount, long TotalUncompressedBytes) InspectArchive(string zipPath) { using var archive = ZipFile.OpenRead(zipPath); - return archive.Entries.Count(static entry => !string.IsNullOrEmpty(entry.Name)); + var fileCount = 0; + long totalUncompressedBytes = 0; + + foreach (var entry in archive.Entries) + { + ValidateArchiveEntry(entry); + + if (string.IsNullOrEmpty(entry.Name)) + { + continue; + } + + fileCount++; + if (fileCount > _options.MaxExtractedFileCount) + { + throw new InvalidOperationException( + "The preview artifact exceeds the preview host extraction safety limits."); + } + + checked + { + totalUncompressedBytes += entry.Length; + } + + if (totalUncompressedBytes > _options.MaxExtractedUncompressedBytes) + { + throw new InvalidOperationException( + "The preview artifact exceeds the preview host extraction safety limits."); + } + } + + return (fileCount, totalUncompressedBytes); + } + + private static void ValidateArchiveEntry(ZipArchiveEntry entry) + { + var normalizedFullName = entry.FullName.Replace('\\', '/'); + if (string.IsNullOrWhiteSpace(normalizedFullName)) + { + return; + } + + if (normalizedFullName.StartsWith("/", StringComparison.Ordinal) + || Path.IsPathRooted(normalizedFullName)) + { + throw new InvalidOperationException( + "The preview artifact failed security validation."); + } + + var pathSegments = normalizedFullName + .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (pathSegments.Any(static segment => segment == "..")) + { + throw new InvalidOperationException( + "The preview artifact failed security validation."); + } + + const int unixFileTypeMask = 0xF000; + const int unixSymlinkType = 0xA000; + var unixMode = (entry.ExternalAttributes >> 16) & unixFileTypeMask; + if (unixMode == unixSymlinkType) + { + throw new InvalidOperationException( + "The preview artifact failed security validation."); + } } private static FileSystemWatcher CreateExtractionFileWatcher(string destinationDirectory, ExtractionProgressState extractionProgressState) @@ -712,7 +811,7 @@ private static FileSystemWatcher CreateExtractionFileWatcher(string destinationD private static string BuildCommandDisplayString(string fileName, IEnumerable arguments) => string.Join( ' ', - new[] { QuoteCommandSegment(fileName) }.Concat(arguments.Select(QuoteCommandSegment))); + [QuoteCommandSegment(fileName), .. arguments.Select(QuoteCommandSegment)]); private static string QuoteCommandSegment(string value) => string.IsNullOrWhiteSpace(value) || value.IndexOfAny([' ', '\t', '"']) >= 0 @@ -765,6 +864,16 @@ private static string BuildFriendlyErrorMessage(Exception exception) return "The preview host is missing its GitHub repository configuration."; } + if (invalidOperationException.Message.Contains("safety limit", StringComparison.OrdinalIgnoreCase)) + { + return "The preview artifact exceeds the preview host safety limits."; + } + + if (invalidOperationException.Message.Contains("security validation", StringComparison.OrdinalIgnoreCase)) + { + return "The preview artifact failed security validation."; + } + if (invalidOperationException.Message.Contains("artifact", StringComparison.OrdinalIgnoreCase)) { return "The requested preview artifact could not be found or is no longer available."; diff --git a/src/statichost/PreviewHost/Previews/PreviewModels.cs b/src/statichost/PreviewHost/Previews/PreviewModels.cs index 0336c9364..194830e9c 100644 --- a/src/statichost/PreviewHost/Previews/PreviewModels.cs +++ b/src/statichost/PreviewHost/Previews/PreviewModels.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Http; namespace PreviewHost.Previews; @@ -17,8 +18,6 @@ internal sealed class PreviewHostOptions public int MaxActivePreviews { get; set; } = 10; - public string RegistrationToken { get; set; } = string.Empty; - public string GitHubToken { get; set; } = string.Empty; public int GitHubAppId { get; set; } @@ -29,8 +28,26 @@ internal sealed class PreviewHostOptions public string GitHubApiBaseUrl { get; set; } = "https://api.github.com/"; + public string GitHubOAuthClientId { get; set; } = string.Empty; + + public string GitHubOAuthClientSecret { get; set; } = string.Empty; + + public string ControlBaseUrl { get; set; } = string.Empty; + + public string ContentBaseUrl { get; set; } = string.Empty; + + public string AuthCookieDomain { get; set; } = string.Empty; + public string ExtractionMode { get; set; } = "managed"; + public int MaxConcurrentLoads { get; set; } = 2; + + public long MaxArtifactSizeBytes { get; set; } = 1L * 1024 * 1024 * 1024; + + public int MaxExtractedFileCount { get; set; } = 100_000; + + public long MaxExtractedUncompressedBytes { get; set; } = 4L * 1024 * 1024 * 1024; + [JsonIgnore] public bool HasGitHubToken => !string.IsNullOrWhiteSpace(GitHubToken); @@ -39,15 +56,45 @@ internal sealed class PreviewHostOptions GitHubAppId > 0 && !string.IsNullOrWhiteSpace(GitHubAppPrivateKey); + [JsonIgnore] + public bool HasGitHubOAuthConfiguration => + !string.IsNullOrWhiteSpace(GitHubOAuthClientId) + && !string.IsNullOrWhiteSpace(GitHubOAuthClientSecret); + [JsonIgnore] public bool HasValidExtractionMode => string.Equals(ExtractionMode, "managed", StringComparison.OrdinalIgnoreCase) || string.Equals(ExtractionMode, "command-line", StringComparison.OrdinalIgnoreCase); + [JsonIgnore] + public bool HasValidConfiguredBaseUrls => + TryParseAbsoluteBaseUri(ControlBaseUrl, out _) + && TryParseAbsoluteBaseUri(ContentBaseUrl, out _); + + [JsonIgnore] + public bool HasValidSafetyLimits => + MaxConcurrentLoads > 0 + && MaxArtifactSizeBytes > 0 + && MaxExtractedFileCount > 0 + && MaxExtractedUncompressedBytes > 0; + [JsonIgnore] public bool UseCommandLineExtraction => string.Equals(ExtractionMode, "command-line", StringComparison.OrdinalIgnoreCase); + [JsonIgnore] + public bool HasSeparatedContentOrigin => + TryGetControlBaseUri(out var controlBaseUri) + && controlBaseUri is not null + && TryGetContentBaseUri(out var contentBaseUri) + && contentBaseUri is not null + && !UrisShareOrigin(controlBaseUri, contentBaseUri); + + [JsonIgnore] + public bool CanAuthenticateContentRequests => + !HasSeparatedContentOrigin + || !string.IsNullOrWhiteSpace(AuthCookieDomain); + [JsonIgnore] public string ExtractionToolDescription => UseCommandLineExtraction @@ -75,6 +122,168 @@ public string GetExtractionModeDescription() => UseCommandLineExtraction ? $"command-line ({ExtractionToolDescription})" : $"managed ({ExtractionToolDescription})"; + + public bool TryGetControlBaseUri(out Uri? baseUri) => TryParseAbsoluteBaseUri(ControlBaseUrl, out baseUri); + + public bool TryGetContentBaseUri(out Uri? baseUri) => TryParseAbsoluteBaseUri(ContentBaseUrl, out baseUri); + + public bool IsContentRequest(HttpRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + return HasSeparatedContentOrigin + && TryGetContentBaseUri(out var contentBaseUri) + && contentBaseUri is not null + && HostMatches(contentBaseUri, request); + } + + public bool IsControlRequest(HttpRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + if (TryGetControlBaseUri(out var controlBaseUri) && controlBaseUri is not null) + { + return HostMatches(controlBaseUri, request); + } + + return !IsContentRequest(request); + } + + public string BuildControlUrl(HttpRequest request, string relativeUrl) + { + ArgumentNullException.ThrowIfNull(request); + return BuildAbsoluteUrl( + TryGetControlBaseUri(out var controlBaseUri) ? controlBaseUri : null, + request, + relativeUrl); + } + + public string BuildContentUrl(HttpRequest request, string relativeUrl) + { + ArgumentNullException.ThrowIfNull(request); + + Uri? baseUri = null; + if (TryGetContentBaseUri(out var contentBaseUri)) + { + baseUri = contentBaseUri; + } + else if (TryGetControlBaseUri(out var controlBaseUri)) + { + baseUri = controlBaseUri; + } + + return BuildAbsoluteUrl(baseUri, request, relativeUrl); + } + + public string GetControlBaseUrl(HttpRequest request) + { + ArgumentNullException.ThrowIfNull(request); + return GetBaseUrlString(TryGetControlBaseUri(out var controlBaseUri) ? controlBaseUri : null, request); + } + + public string GetContentBaseUrl(HttpRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + Uri? baseUri = null; + if (TryGetContentBaseUri(out var contentBaseUri)) + { + baseUri = contentBaseUri; + } + else if (TryGetControlBaseUri(out var controlBaseUri)) + { + baseUri = controlBaseUri; + } + + return GetBaseUrlString(baseUri, request); + } + + private static bool TryParseAbsoluteBaseUri(string? value, out Uri? baseUri) + { + if (string.IsNullOrWhiteSpace(value)) + { + baseUri = null; + return true; + } + + if (!Uri.TryCreate(value, UriKind.Absolute, out var parsed) + || !(string.Equals(parsed.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + || string.Equals(parsed.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))) + { + baseUri = null; + return false; + } + + var builder = new UriBuilder(parsed) + { + Query = string.Empty, + Fragment = string.Empty + }; + + if (string.IsNullOrWhiteSpace(builder.Path)) + { + builder.Path = "/"; + } + else if (!builder.Path.EndsWith("/", StringComparison.Ordinal)) + { + builder.Path = $"{builder.Path}/"; + } + + baseUri = builder.Uri; + return true; + } + + private static bool UrisShareOrigin(Uri left, Uri right) => + string.Equals(left.Scheme, right.Scheme, StringComparison.OrdinalIgnoreCase) + && string.Equals(left.Host, right.Host, StringComparison.OrdinalIgnoreCase) + && left.Port == right.Port; + + private static bool HostMatches(Uri baseUri, HttpRequest request) => + string.Equals(baseUri.Host, request.Host.Host, StringComparison.OrdinalIgnoreCase) + && GetPort(baseUri) == GetPort(request); + + private static int GetPort(Uri baseUri) => baseUri.IsDefaultPort + ? string.Equals(baseUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) ? 443 : 80 + : baseUri.Port; + + private static int GetPort(HttpRequest request) + { + if (request.Host.Port is { } port) + { + return port; + } + + return string.Equals(request.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) ? 80 : 443; + } + + private static string BuildAbsoluteUrl(Uri? baseUri, HttpRequest request, string relativeUrl) + { + if (string.IsNullOrWhiteSpace(relativeUrl)) + { + relativeUrl = "/"; + } + else if (!relativeUrl.StartsWith("/", StringComparison.Ordinal)) + { + relativeUrl = $"/{relativeUrl}"; + } + + if (baseUri is null) + { + return relativeUrl; + } + + return new Uri(baseUri, relativeUrl).ToString(); + } + + private static string GetBaseUrlString(Uri? baseUri, HttpRequest request) + { + if (baseUri is not null) + { + return baseUri.ToString(); + } + + return $"{request.Scheme}://{request.Host}/"; + } } internal sealed class PreviewRegistrationRequest diff --git a/src/statichost/PreviewHost/Previews/PreviewRequestDispatcher.cs b/src/statichost/PreviewHost/Previews/PreviewRequestDispatcher.cs index 744b121ea..18fefa9ba 100644 --- a/src/statichost/PreviewHost/Previews/PreviewRequestDispatcher.cs +++ b/src/statichost/PreviewHost/Previews/PreviewRequestDispatcher.cs @@ -26,18 +26,50 @@ internal sealed class PreviewRequestDispatcher( public async Task DispatchIndexAsync(HttpContext context, CancellationToken cancellationToken) { + if (_options.IsContentRequest(context.Request)) + { + context.Response.Redirect(_options.BuildControlUrl(context.Request, PreviewRoute.CollectionPath), permanent: false); + return; + } + await WritePreviewShellAsync(context, "index.html", cancellationToken); } public async Task DispatchAsync(HttpContext context, int pullRequestNumber, string relativePath, CancellationToken cancellationToken) { var snapshot = await stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); + var contentRequest = _options.IsContentRequest(context.Request); + var separatedContentOrigin = _options.HasSeparatedContentOrigin; + + if (separatedContentOrigin && !contentRequest) + { + if (!string.IsNullOrEmpty(relativePath)) + { + context.Response.Redirect( + _options.BuildContentUrl(context.Request, PreviewRoute.BuildPath(pullRequestNumber, relativePath)), + permanent: false); + return; + } + + await WritePreviewShellAsync(context, "status.html", cancellationToken); + return; + } if (snapshot is null || !snapshot.IsReady || string.IsNullOrWhiteSpace(snapshot.ActiveDirectoryPath)) { + if (separatedContentOrigin && contentRequest) + { + context.Response.Redirect( + _options.BuildControlUrl(context.Request, PreviewRoute.BuildPath(pullRequestNumber)), + permanent: false); + return; + } + if (!string.IsNullOrEmpty(relativePath)) { - context.Response.Redirect(PreviewRoute.BuildPath(pullRequestNumber)); + context.Response.Redirect( + _options.BuildControlUrl(context.Request, PreviewRoute.BuildPath(pullRequestNumber)), + permanent: false); return; } diff --git a/src/statichost/PreviewHost/Previews/PreviewStateStore.cs b/src/statichost/PreviewHost/Previews/PreviewStateStore.cs index 7a2cce061..2051177e4 100644 --- a/src/statichost/PreviewHost/Previews/PreviewStateStore.cs +++ b/src/statichost/PreviewHost/Previews/PreviewStateStore.cs @@ -474,6 +474,52 @@ public async Task RemoveAsync(int pullRequestNumber, CancellationToken cancellat } } + public async Task RemoveMissingAsync(IReadOnlyCollection activePullRequestNumbers, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(activePullRequestNumbers); + + HashSet activeNumbers = activePullRequestNumbers.Count == 0 + ? [] + : [.. activePullRequestNumbers]; + List directoriesToDelete = []; + List removedPullRequests = []; + + await _gate.WaitAsync(cancellationToken); + try + { + foreach (var pullRequestNumber in _records.Keys.Where(number => !activeNumbers.Contains(number)).ToArray()) + { + if (!_records.Remove(pullRequestNumber, out var record)) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(record.ActiveDirectoryPath)) + { + directoriesToDelete.Add(record.ActiveDirectoryPath); + } + + removedPullRequests.Add(pullRequestNumber); + } + + if (removedPullRequests.Count > 0) + { + await SaveLockedAsync(cancellationToken); + } + } + finally + { + _gate.Release(); + } + + foreach (var directoryPath in directoriesToDelete) + { + DeleteDirectoryIfPresent(directoryPath); + } + + return removedPullRequests.Count; + } + public string GetActiveDirectoryPath(int pullRequestNumber) => Path.Combine(ContentRoot, "active", $"pr-{pullRequestNumber}"); public string GetTemporaryZipPath(PreviewWorkItem workItem) => diff --git a/src/statichost/PreviewHost/Program.cs b/src/statichost/PreviewHost/Program.cs index 7dc35868c..e545ced53 100644 --- a/src/statichost/PreviewHost/Program.cs +++ b/src/statichost/PreviewHost/Program.cs @@ -1,18 +1,26 @@ -using System.ComponentModel.DataAnnotations; -using System.Security.Cryptography; -using System.Text; +using System.Net.Http.Headers; +using System.Security.Claims; using System.Text.Json; using System.Text.Json.Serialization; +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OAuth; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Options; using PreviewHost.Previews; var webJsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); webJsonOptions.Converters.Add(new JsonStringEnumConverter()); + var builder = WebApplication.CreateBuilder(args); builder.Services.AddProblemDetails(); builder.Services.AddHealthChecks(); +builder.Services.AddMemoryCache(); builder.Services.ConfigureHttpJsonOptions(static options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); @@ -23,23 +31,157 @@ .Validate( static options => options.HasGitHubToken || options.HasGitHubAppConfiguration, $"Either '{PreviewHostOptions.SectionName}:GitHubToken' or both '{PreviewHostOptions.SectionName}:GitHubAppId' and '{PreviewHostOptions.SectionName}:GitHubAppPrivateKey' must be configured.") + .Validate( + static options => options.HasGitHubOAuthConfiguration, + $"Both '{PreviewHostOptions.SectionName}:GitHubOAuthClientId' and '{PreviewHostOptions.SectionName}:GitHubOAuthClientSecret' must be configured.") .Validate( static options => options.HasValidExtractionMode, $"The '{PreviewHostOptions.SectionName}:ExtractionMode' setting must be either 'managed' or 'command-line'.") + .Validate( + static options => options.HasValidConfiguredBaseUrls, + $"The '{PreviewHostOptions.SectionName}:ControlBaseUrl' and '{PreviewHostOptions.SectionName}:ContentBaseUrl' settings must be absolute http(s) URLs when provided.") + .Validate( + static options => options.HasValidSafetyLimits, + $"The '{PreviewHostOptions.SectionName}' safety limits must all be positive values.") .Validate( static options => CommandLineExtractionSupport.IsConfigurationSupported(options), CommandLineExtractionSupport.GetConfigurationValidationMessage()) .ValidateOnStart(); + +var authCookieDomain = builder.Configuration[$"{PreviewHostOptions.SectionName}:AuthCookieDomain"]; + +builder.Services + .AddAuthentication(options => + { + options.DefaultScheme = PreviewAuthenticationDefaults.CookieScheme; + options.DefaultChallengeScheme = PreviewAuthenticationDefaults.GitHubScheme; + options.DefaultSignInScheme = PreviewAuthenticationDefaults.CookieScheme; + }) + .AddCookie(PreviewAuthenticationDefaults.CookieScheme, options => + { + options.Cookie.Name = "previewhost-auth"; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + if (!string.IsNullOrWhiteSpace(authCookieDomain)) + { + options.Cookie.Domain = authCookieDomain; + } + + options.AccessDeniedPath = "/auth/access-denied"; + options.Events = new CookieAuthenticationEvents + { + OnRedirectToLogin = context => HandleCookieRedirectAsync(context, StatusCodes.Status401Unauthorized), + OnRedirectToAccessDenied = context => HandleCookieRedirectAsync(context, StatusCodes.Status403Forbidden) + }; + }) + .AddOAuth(PreviewAuthenticationDefaults.GitHubScheme, options => + { + options.ClientId = builder.Configuration[$"{PreviewHostOptions.SectionName}:GitHubOAuthClientId"] ?? string.Empty; + options.ClientSecret = builder.Configuration[$"{PreviewHostOptions.SectionName}:GitHubOAuthClientSecret"] ?? string.Empty; + options.CallbackPath = "/signin-github"; + options.AuthorizationEndpoint = "https://github.com/login/oauth/authorize"; + options.TokenEndpoint = "https://github.com/login/oauth/access_token"; + options.UserInformationEndpoint = "https://api.github.com/user"; + options.SaveTokens = false; + options.Scope.Add("read:user"); + options.CorrelationCookie.SameSite = SameSiteMode.Lax; + options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always; + if (!string.IsNullOrWhiteSpace(authCookieDomain)) + { + options.CorrelationCookie.Domain = authCookieDomain; + } + + options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id"); + options.ClaimActions.MapJsonKey(ClaimTypes.Name, "login"); + options.ClaimActions.MapJsonKey(PreviewAuthenticationDefaults.UserLoginClaimType, "login"); + options.ClaimActions.MapJsonKey(PreviewAuthenticationDefaults.UserDisplayNameClaimType, "name"); + options.ClaimActions.MapJsonKey(PreviewAuthenticationDefaults.UserAvatarUrlClaimType, "avatar_url"); + options.ClaimActions.MapJsonKey(PreviewAuthenticationDefaults.UserProfileUrlClaimType, "html_url"); + + options.Events = new OAuthEvents + { + OnCreatingTicket = async context => + { + using var request = new HttpRequestMessage(HttpMethod.Get, context.Options.UserInformationEndpoint); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.AccessToken); + request.Headers.UserAgent.ParseAdd("aspire-dev-preview-host"); + + using var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted); + response.EnsureSuccessStatusCode(); + + await using var userInfoStream = await response.Content.ReadAsStreamAsync(context.HttpContext.RequestAborted); + using var userDocument = await JsonDocument.ParseAsync(userInfoStream, cancellationToken: context.HttpContext.RequestAborted); + context.RunClaimActions(userDocument.RootElement); + } + }; + }); + +builder.Services.AddAuthorization(options => +{ + options.AddPolicy( + PreviewAuthenticationDefaults.WriterPolicy, + policy => policy + .RequireAuthenticatedUser() + .AddRequirements(new PreviewWriterRequirement())); +}); + +builder.Services.AddAntiforgery(options => +{ + options.HeaderName = PreviewAuthenticationDefaults.CsrfHeaderName; + options.FormFieldName = "__RequestVerificationToken"; +}); + +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.AddPolicy( + "preview-read", + context => RateLimitPartition.GetFixedWindowLimiter( + GetRateLimitPartitionKey(context, "read"), + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 120, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + AutoReplenishment = true + })); + options.AddPolicy( + "preview-write", + context => RateLimitPartition.GetFixedWindowLimiter( + GetRateLimitPartitionKey(context, "write"), + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 30, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + AutoReplenishment = true + })); + options.AddPolicy( + "preview-events", + context => RateLimitPartition.GetConcurrencyLimiter( + GetRateLimitPartitionKey(context, "events"), + _ => new ConcurrencyLimiterOptions + { + PermitLimit = 2, + QueueLimit = 0 + })); +}); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); var app = builder.Build(); var previewHostOptions = app.Services.GetRequiredService>().Value; var previewStateStore = app.Services.GetRequiredService(); await previewStateStore.InitializeAsync(CancellationToken.None); + app.Logger.LogInformation( "PreviewHost GitHub authentication mode: {GitHubAuthenticationMode}", previewHostOptions.GetGitHubAuthenticationMode()); @@ -50,6 +192,17 @@ "PreviewHost storage roots: state {StateRoot}, content {ContentRoot}", previewStateStore.StateRoot, previewStateStore.ContentRoot); +app.Logger.LogInformation( + "PreviewHost content origin separation enabled: {HasSeparatedContentOrigin}", + previewHostOptions.HasSeparatedContentOrigin); + +if (previewHostOptions.HasSeparatedContentOrigin && !previewHostOptions.CanAuthenticateContentRequests) +{ + app.Logger.LogWarning( + "PreviewHost is configured with a separate content origin, but '{SectionName}:AuthCookieDomain' is empty. Control routes stay writer-gated, but preview content remains public on the content origin until a shared cookie domain is configured.", + PreviewHostOptions.SectionName); +} + if (previewHostOptions.UseCommandLineExtraction && CommandLineExtractionSupport.TryResolveConfiguredTool(previewHostOptions, out var extractionToolPath)) { @@ -76,18 +229,70 @@ } }); +app.UseAuthentication(); +app.UseAuthorization(); +app.UseRateLimiter(); + +app.Use(async (context, next) => +{ + if (previewHostOptions.IsContentRequest(context.Request) + && context.Request.Path.StartsWithSegments("/auth", StringComparison.OrdinalIgnoreCase)) + { + context.Response.Redirect( + $"{previewHostOptions.BuildControlUrl(context.Request, context.Request.Path.Value ?? "/")}{context.Request.QueryString}", + permanent: false); + return; + } + + if (previewHostOptions.IsContentRequest(context.Request) + && context.Request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase)) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + await next(); +}); + +app.Use(async (context, next) => +{ + if (ShouldIssueCsrfToken(context, previewHostOptions)) + { + var antiforgery = context.RequestServices.GetRequiredService(); + var tokens = antiforgery.GetAndStoreTokens(context); + if (!string.IsNullOrWhiteSpace(tokens.RequestToken)) + { + context.Response.Cookies.Append( + PreviewAuthenticationDefaults.CsrfRequestTokenCookieName, + tokens.RequestToken, + CreateCsrfCookieOptions(previewHostOptions)); + } + } + + await next(); +}); + app.Use(async (context, next) => { if ((HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsHead(context.Request.Method)) && PreviewRoute.TryParseLegacy(context.Request.Path, out var legacyPullRequestNumber, out var legacyRelativePath)) { - context.Response.Redirect($"{PreviewRoute.BuildPath(legacyPullRequestNumber, legacyRelativePath)}{context.Request.QueryString}", permanent: false); + var targetPath = PreviewRoute.BuildPath(legacyPullRequestNumber, legacyRelativePath); + context.Response.Redirect( + $"{previewHostOptions.BuildControlUrl(context.Request, targetPath)}{context.Request.QueryString}", + permanent: false); return; } if ((HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsHead(context.Request.Method)) && PreviewRoute.IsCollectionPath(context.Request.Path)) { + if (!previewHostOptions.IsContentRequest(context.Request) + && !await EnsurePreviewWriterAccessAsync(context)) + { + return; + } + var indexDispatcher = context.RequestServices.GetRequiredService(); await indexDispatcher.DispatchIndexAsync(context, context.RequestAborted); return; @@ -99,280 +304,352 @@ return; } + var requiresWriterAccess = !previewHostOptions.IsContentRequest(context.Request) + || previewHostOptions.CanAuthenticateContentRequests; + if (requiresWriterAccess && !await EnsurePreviewWriterAccessAsync(context)) + { + return; + } + var dispatcher = context.RequestServices.GetRequiredService(); await dispatcher.DispatchAsync(context, pullRequestNumber, relativePath, context.RequestAborted); }); -app.MapGet("/", () => Results.Redirect(PreviewRoute.CollectionPath)); - -app.MapHealthChecks("/healthz", new HealthCheckOptions -{ - AllowCachingResponses = false -}); - -app.MapGet( - "/api/previews/recent", - async (PreviewStateStore stateStore, IOptions options, CancellationToken cancellationToken) => - { - var snapshots = await stateStore.ListRecentSnapshotsAsync(options.Value.MaxActivePreviews, cancellationToken); - return Results.Json(new - { - updatedAtUtc = DateTimeOffset.UtcNow, - maxActivePreviews = options.Value.MaxActivePreviews, - snapshots - }); - }); +app.MapGet("/", (HttpContext context) => + Results.Redirect(previewHostOptions.BuildControlUrl(context.Request, PreviewRoute.CollectionPath))); app.MapGet( - "/api/previews/catalog", - async (GitHubArtifactClient gitHubArtifactClient, PreviewStateStore stateStore, IOptions options, CancellationToken cancellationToken) => - { - var openPullRequests = await gitHubArtifactClient.ListOpenPullRequestsAsync(cancellationToken); - var trackedSnapshots = await stateStore.ListSnapshotsAsync(cancellationToken); - var activePreviewCount = trackedSnapshots.Values.Count(static snapshot => snapshot.State is PreviewLoadState.Loading or PreviewLoadState.Ready); - - var entries = openPullRequests - .Select(pullRequest => new + "/auth/login", + (HttpContext context, string? returnUrl) => + Results.Challenge( + new AuthenticationProperties { - pullRequestNumber = pullRequest.PullRequestNumber, - title = pullRequest.Title, - pullRequestUrl = pullRequest.HtmlUrl, - previewPath = PreviewRoute.BuildPath(pullRequest.PullRequestNumber), - authorLogin = pullRequest.AuthorLogin, - isDraft = pullRequest.IsDraft, - headSha = pullRequest.HeadSha, - hasSuccessfulPreviewBuild = pullRequest.HasSuccessfulPreviewBuild, - createdAtUtc = pullRequest.CreatedAtUtc, - updatedAtUtc = pullRequest.UpdatedAtUtc, - preview = trackedSnapshots.TryGetValue(pullRequest.PullRequestNumber, out var snapshot) ? snapshot : null - }) - .ToArray(); - - return Results.Json(new - { - updatedAtUtc = DateTimeOffset.UtcNow, - openPullRequestCount = entries.Length, - previewablePullRequestCount = entries.Count(static entry => entry.hasSuccessfulPreviewBuild), - maxActivePreviews = options.Value.MaxActivePreviews, - activePreviewCount, - entries - }); - }); + RedirectUri = NormalizeReturnUrl(returnUrl) + }, + [PreviewAuthenticationDefaults.GitHubScheme])); app.MapGet( - "/api/previews/{pullRequestNumber:int}", - async (int pullRequestNumber, PreviewStateStore stateStore, CancellationToken cancellationToken) => + "/auth/logout", + async (HttpContext context, string? returnUrl) => { - var snapshot = await stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); - return snapshot is null - ? Results.NotFound() - : Results.Json(snapshot); + await context.SignOutAsync(PreviewAuthenticationDefaults.CookieScheme); + return Results.LocalRedirect(NormalizeReturnUrl(returnUrl)); }); app.MapGet( - "/api/previews/{pullRequestNumber:int}/bootstrap", - async (int pullRequestNumber, PreviewCoordinator coordinator, IOptions options, CancellationToken cancellationToken) => - { - var result = await coordinator.BootstrapAsync(pullRequestNumber, cancellationToken); - return result.Snapshot is null - ? Results.Json(CreateUnavailablePreviewPayload( - pullRequestNumber, - options.Value, - result.FailureMessage ?? "The preview host could not find a successful frontend build for this pull request yet.")) - : Results.Json(result.Snapshot); - }); + "/auth/access-denied", + () => Results.Content( + BuildAccessDeniedPage(previewHostOptions), + contentType: "text/html; charset=utf-8")); -app.MapGet( - "/api/previews/{pullRequestNumber:int}/events", - async (HttpContext context, int pullRequestNumber, PreviewStateStore stateStore, PreviewCoordinator coordinator, CancellationToken cancellationToken) => - { - context.Response.ContentType = "text/event-stream"; - context.Response.Headers.CacheControl = "no-cache, no-store, must-revalidate"; - coordinator.EnsureLoading(pullRequestNumber); +app.MapHealthChecks("/healthz", new HealthCheckOptions +{ + AllowCachingResponses = false +}); - long lastVersion = -1; +var previewApi = app.MapGroup("/api/previews") + .RequireAuthorization(PreviewAuthenticationDefaults.WriterPolicy); - while (!cancellationToken.IsCancellationRequested) +previewApi.MapGet( + "/session", + (HttpContext context) => { - var snapshot = await stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); - if (snapshot is null) + var login = context.User.FindFirstValue(PreviewAuthenticationDefaults.UserLoginClaimType) + ?? context.User.FindFirstValue(ClaimTypes.Name) + ?? string.Empty; + var displayName = context.User.FindFirstValue(PreviewAuthenticationDefaults.UserDisplayNameClaimType); + var avatarUrl = context.User.FindFirstValue(PreviewAuthenticationDefaults.UserAvatarUrlClaimType); + var profileUrl = context.User.FindFirstValue(PreviewAuthenticationDefaults.UserProfileUrlClaimType); + + return Results.Json(new { - var missingSnapshot = JsonSerializer.Serialize( - new PreviewStatusSnapshot - { - RepositoryOwner = string.Empty, - RepositoryName = string.Empty, - PullRequestNumber = pullRequestNumber, - State = PreviewLoadState.Failed, - Stage = "Missing", - Message = "No preview registration is available for this pull request.", - Percent = 0, - StagePercent = 0, - Version = lastVersion + 1, - Error = "No preview registration is available for this pull request.", - UpdatedAtUtc = DateTimeOffset.UtcNow, - PreviewPath = PreviewRoute.BuildPath(pullRequestNumber) - }, - webJsonOptions); - - await context.Response.WriteAsync($"data: {missingSnapshot}\n\n", cancellationToken); - break; + viewer = new + { + login, + displayName = string.IsNullOrWhiteSpace(displayName) ? login : displayName, + avatarUrl, + profileUrl + }, + repositoryOwner = previewHostOptions.RepositoryOwner, + repositoryName = previewHostOptions.RepositoryName, + controlBaseUrl = previewHostOptions.GetControlBaseUrl(context.Request), + contentBaseUrl = previewHostOptions.GetContentBaseUrl(context.Request), + hasSeparatedContentOrigin = previewHostOptions.HasSeparatedContentOrigin, + canAuthenticateContentRequests = previewHostOptions.CanAuthenticateContentRequests, + signOutPath = $"/auth/logout?returnUrl={Uri.EscapeDataString(PreviewRoute.CollectionPath)}" + }); + }) + .RequireRateLimiting("preview-read"); + +previewApi.MapGet( + "/recent", + async (CancellationToken cancellationToken) => + { + var snapshots = await previewStateStore.ListRecentSnapshotsAsync( + previewHostOptions.MaxActivePreviews, + cancellationToken); + return Results.Json(new + { + updatedAtUtc = DateTimeOffset.UtcNow, + maxActivePreviews = previewHostOptions.MaxActivePreviews, + snapshots + }); + }) + .RequireRateLimiting("preview-read"); + +previewApi.MapGet( + "/catalog", + async (GitHubArtifactClient gitHubArtifactClient, CancellationToken cancellationToken) => + { + var openPullRequests = await gitHubArtifactClient.ListOpenPullRequestsAsync(cancellationToken); + var prunedCount = await previewStateStore.RemoveMissingAsync( + openPullRequests.Select(static pullRequest => pullRequest.PullRequestNumber).ToArray(), + cancellationToken); + + if (prunedCount > 0) + { + app.Logger.LogInformation("Removed {PrunedCount} closed pull request previews from the local preview state.", prunedCount); } - if (snapshot.Version != lastVersion) + var trackedSnapshots = await previewStateStore.ListSnapshotsAsync(cancellationToken); + var activePreviewCount = trackedSnapshots.Values.Count(static snapshot => snapshot.State is PreviewLoadState.Loading or PreviewLoadState.Ready); + var entries = openPullRequests + .Select(pullRequest => new + { + pullRequestNumber = pullRequest.PullRequestNumber, + title = pullRequest.Title, + pullRequestUrl = pullRequest.HtmlUrl, + previewPath = PreviewRoute.BuildPath(pullRequest.PullRequestNumber), + authorLogin = pullRequest.AuthorLogin, + isDraft = pullRequest.IsDraft, + headSha = pullRequest.HeadSha, + hasSuccessfulPreviewBuild = pullRequest.HasSuccessfulPreviewBuild, + createdAtUtc = pullRequest.CreatedAtUtc, + updatedAtUtc = pullRequest.UpdatedAtUtc, + preview = trackedSnapshots.TryGetValue(pullRequest.PullRequestNumber, out var snapshot) ? snapshot : null + }) + .ToArray(); + + return Results.Json(new { - var payload = JsonSerializer.Serialize(snapshot, webJsonOptions); - await context.Response.WriteAsync($"data: {payload}\n\n", cancellationToken); - await context.Response.Body.FlushAsync(cancellationToken); - lastVersion = snapshot.Version; + updatedAtUtc = DateTimeOffset.UtcNow, + openPullRequestCount = entries.Length, + previewablePullRequestCount = entries.Count(static entry => entry.hasSuccessfulPreviewBuild), + maxActivePreviews = previewHostOptions.MaxActivePreviews, + activePreviewCount, + entries + }); + }) + .RequireRateLimiting("preview-read"); + +previewApi.MapGet( + "/{pullRequestNumber:int}", + async (int pullRequestNumber, CancellationToken cancellationToken) => + { + var snapshot = await previewStateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); + return snapshot is null + ? Results.NotFound() + : Results.Json(snapshot); + }) + .RequireRateLimiting("preview-read"); + +previewApi.MapGet( + "/{pullRequestNumber:int}/bootstrap", + async (HttpContext context, int pullRequestNumber, PreviewCoordinator coordinator, CancellationToken cancellationToken) => + { + var result = await coordinator.BootstrapAsync(pullRequestNumber, cancellationToken); + return result.Snapshot is null + ? Results.NotFound(CreateUnavailablePreviewPayload( + pullRequestNumber, + previewHostOptions, + result.FailureMessage ?? "This preview hasn't been enabled yet.")) + : Results.Json(result.Snapshot); + }) + .RequireRateLimiting("preview-read"); + +previewApi.MapGet( + "/{pullRequestNumber:int}/events", + async (HttpContext context, int pullRequestNumber, PreviewCoordinator coordinator, CancellationToken cancellationToken) => + { + context.Response.ContentType = "text/event-stream"; + context.Response.Headers.CacheControl = "no-cache, no-store, must-revalidate"; + + long lastVersion = -1; - if (snapshot.IsReady || snapshot.State is PreviewLoadState.Failed or PreviewLoadState.Cancelled) + while (!cancellationToken.IsCancellationRequested) + { + var snapshot = await previewStateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); + if (snapshot is null) { + var missingSnapshot = JsonSerializer.Serialize( + CreateUnavailablePreviewPayload( + pullRequestNumber, + previewHostOptions, + "This preview hasn't been enabled yet."), + webJsonOptions); + + await context.Response.WriteAsync($"data: {missingSnapshot}\n\n", cancellationToken); break; } - } - await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); - } - }); - -app.MapPost( - "/api/previews/{pullRequestNumber:int}/cancel", - async (int pullRequestNumber, PreviewCoordinator coordinator, CancellationToken cancellationToken) => - { - var snapshot = await coordinator.CancelAsync(pullRequestNumber, cancellationToken); - return snapshot is null - ? Results.NotFound() - : Results.Json(snapshot); - }); + if (snapshot.State is PreviewLoadState.Registered or PreviewLoadState.Loading) + { + coordinator.EnsureLoading(pullRequestNumber); + } -app.MapPost( - "/api/previews/{pullRequestNumber:int}/retry", - async (int pullRequestNumber, PreviewCoordinator coordinator, IOptions options, CancellationToken cancellationToken) => - { - var result = await coordinator.RetryAsync(pullRequestNumber, cancellationToken); - return result.Snapshot is null - ? Results.Json(CreateUnavailablePreviewPayload( - pullRequestNumber, - options.Value, - result.FailureMessage ?? "The preview host could not find a successful frontend build for this pull request yet.")) - : Results.Json(result.Snapshot); - }); + if (snapshot.Version != lastVersion) + { + var payload = JsonSerializer.Serialize(snapshot, webJsonOptions); + await context.Response.WriteAsync($"data: {payload}\n\n", cancellationToken); + await context.Response.Body.FlushAsync(cancellationToken); + lastVersion = snapshot.Version; -app.MapPost( - "/api/previews/{pullRequestNumber:int}/reset", - async (int pullRequestNumber, PreviewCoordinator coordinator, CancellationToken cancellationToken) => - { - var removed = await coordinator.ResetAsync(pullRequestNumber, cancellationToken); - return removed - ? Results.NoContent() - : Results.NotFound(); - }); + if (snapshot.IsReady || snapshot.State is PreviewLoadState.Failed or PreviewLoadState.Cancelled) + { + break; + } + } -app.MapPost( - "/api/previews/registrations", - async (HttpContext context, PreviewRegistrationRequest request, PreviewStateStore stateStore, IOptions options, CancellationToken cancellationToken) => - { - if (!HasValidBearerToken(context.Request, options.Value.RegistrationToken)) - { - return Results.Unauthorized(); - } + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + }) + .RequireRateLimiting("preview-events"); - if (!TryValidate(request, out var validationErrors)) +previewApi.MapPost( + "/{pullRequestNumber:int}/prepare", + async (HttpContext context, int pullRequestNumber, IAntiforgery antiforgery, PreviewCoordinator coordinator, CancellationToken cancellationToken) => { - return Results.ValidationProblem(validationErrors); - } - - if (!TryValidateRepository(request, options.Value, out var repositoryValidationErrors)) + await antiforgery.ValidateRequestAsync(context); + var result = await coordinator.PrepareAsync(pullRequestNumber, cancellationToken); + return result.Snapshot is null + ? Results.NotFound(CreateUnavailablePreviewPayload( + pullRequestNumber, + previewHostOptions, + result.FailureMessage ?? "The preview host could not find a successful frontend build for this pull request yet.")) + : Results.Json(result.Snapshot); + }) + .RequireRateLimiting("preview-write"); + +previewApi.MapPost( + "/{pullRequestNumber:int}/cancel", + async (HttpContext context, int pullRequestNumber, IAntiforgery antiforgery, PreviewCoordinator coordinator, CancellationToken cancellationToken) => { - return Results.ValidationProblem(repositoryValidationErrors); - } - - var result = await stateStore.RegisterAsync(request, cancellationToken); - return Results.Json(new + await antiforgery.ValidateRequestAsync(context); + var snapshot = await coordinator.CancelAsync(pullRequestNumber, cancellationToken); + return snapshot is null + ? Results.NotFound() + : Results.Json(snapshot); + }) + .RequireRateLimiting("preview-write"); + +previewApi.MapPost( + "/{pullRequestNumber:int}/retry", + async (HttpContext context, int pullRequestNumber, IAntiforgery antiforgery, PreviewCoordinator coordinator, CancellationToken cancellationToken) => { - accepted = result.Accepted, - previewPath = PreviewRoute.BuildPath(request.PullRequestNumber), - snapshot = result.Snapshot - }); - }); - -app.MapDelete( - "/api/previews/{pullRequestNumber:int}", - async (HttpContext context, int pullRequestNumber, PreviewStateStore stateStore, IOptions options, CancellationToken cancellationToken) => - { - if (!HasValidBearerToken(context.Request, options.Value.RegistrationToken)) + await antiforgery.ValidateRequestAsync(context); + var result = await coordinator.RetryAsync(pullRequestNumber, cancellationToken); + return result.Snapshot is null + ? Results.NotFound(CreateUnavailablePreviewPayload( + pullRequestNumber, + previewHostOptions, + result.FailureMessage ?? "The preview host could not find a successful frontend build for this pull request yet.")) + : Results.Json(result.Snapshot); + }) + .RequireRateLimiting("preview-write"); + +previewApi.MapPost( + "/{pullRequestNumber:int}/reset", + async (HttpContext context, int pullRequestNumber, IAntiforgery antiforgery, PreviewCoordinator coordinator, CancellationToken cancellationToken) => { - return Results.Unauthorized(); - } - - await stateStore.RemoveAsync(pullRequestNumber, cancellationToken); - return Results.NoContent(); - }); + await antiforgery.ValidateRequestAsync(context); + var removed = await coordinator.ResetAsync(pullRequestNumber, cancellationToken); + return removed + ? Results.NoContent() + : Results.NotFound(); + }) + .RequireRateLimiting("preview-write"); await app.RunAsync(); -static bool TryValidate(PreviewRegistrationRequest request, out Dictionary validationErrors) +static Task HandleCookieRedirectAsync(RedirectContext context, int statusCode) { - var validationResults = new List(); - var validationContext = new ValidationContext(request); - - if (Validator.TryValidateObject(request, validationContext, validationResults, validateAllProperties: true)) + if (IsApiRequest(context.Request)) { - validationErrors = []; - return true; + context.Response.StatusCode = statusCode; + return Task.CompletedTask; } - validationErrors = validationResults - .SelectMany( - result => result.MemberNames.DefaultIfEmpty(string.Empty), - static (result, memberName) => new { MemberName = memberName, result.ErrorMessage }) - .GroupBy(static item => item.MemberName, StringComparer.Ordinal) - .ToDictionary( - static group => group.Key, - static group => group.Select(static item => item.ErrorMessage ?? "Validation failed.").ToArray(), - StringComparer.Ordinal); - - return false; + context.Response.Redirect(context.RedirectUri); + return Task.CompletedTask; } -static bool TryValidateRepository(PreviewRegistrationRequest request, PreviewHostOptions options, out Dictionary validationErrors) +static bool IsApiRequest(HttpRequest request) => + request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase) + || request.Headers.Accept.Any(static value => !string.IsNullOrEmpty(value) && value.Contains("application/json", StringComparison.OrdinalIgnoreCase)); + +static string NormalizeReturnUrl(string? returnUrl) { - if (string.Equals(request.RepositoryOwner, options.RepositoryOwner, StringComparison.OrdinalIgnoreCase) - && string.Equals(request.RepositoryName, options.RepositoryName, StringComparison.OrdinalIgnoreCase)) + if (string.IsNullOrWhiteSpace(returnUrl)) { - validationErrors = []; - return true; + return PreviewRoute.CollectionPath; } - var repositoryMessage = $"Preview registrations must target the configured repository '{options.RepositoryOwner}/{options.RepositoryName}'."; - validationErrors = new Dictionary(StringComparer.Ordinal) + return Uri.TryCreate(returnUrl, UriKind.Relative, out var relativeUri) + ? relativeUri.ToString() + : PreviewRoute.CollectionPath; +} + +static bool ShouldIssueCsrfToken(HttpContext context, PreviewHostOptions options) => + (HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsHead(context.Request.Method)) + && context.User.Identity?.IsAuthenticated == true + && options.IsControlRequest(context.Request); + +static CookieOptions CreateCsrfCookieOptions(PreviewHostOptions options) +{ + var cookieOptions = new CookieOptions { - [nameof(PreviewRegistrationRequest.RepositoryOwner)] = [repositoryMessage], - [nameof(PreviewRegistrationRequest.RepositoryName)] = [repositoryMessage] + HttpOnly = false, + IsEssential = true, + SameSite = SameSiteMode.Strict, + Secure = true, + Path = "/" }; - return false; + + if (!string.IsNullOrWhiteSpace(options.AuthCookieDomain)) + { + cookieOptions.Domain = options.AuthCookieDomain; + } + + return cookieOptions; } -static bool HasValidBearerToken(HttpRequest request, string expectedToken) +static async Task EnsurePreviewWriterAccessAsync(HttpContext context) { - if (string.IsNullOrWhiteSpace(expectedToken)) + var authorizationService = context.RequestServices.GetRequiredService(); + var authorizationResult = await authorizationService.AuthorizeAsync( + context.User, + resource: null, + PreviewAuthenticationDefaults.WriterPolicy); + + if (authorizationResult.Succeeded) { - return false; + return true; } - var authorizationHeader = request.Headers.Authorization.ToString(); - if (!authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + if (context.User.Identity?.IsAuthenticated == true) { + await context.ForbidAsync(PreviewAuthenticationDefaults.CookieScheme); return false; } - var providedToken = authorizationHeader["Bearer ".Length..].Trim(); - var expectedBytes = Encoding.UTF8.GetBytes(expectedToken); - var providedBytes = Encoding.UTF8.GetBytes(providedToken); + await context.ChallengeAsync( + PreviewAuthenticationDefaults.GitHubScheme, + new AuthenticationProperties + { + RedirectUri = $"{context.Request.PathBase}{context.Request.Path}{context.Request.QueryString}" + }); - return expectedBytes.Length == providedBytes.Length - && CryptographicOperations.FixedTimeEquals(expectedBytes, providedBytes); + return false; } static object CreateUnavailablePreviewPayload(int pullRequestNumber, PreviewHostOptions options, string failureMessage) => @@ -403,6 +680,7 @@ static bool TryResolvePreviewRequest(HttpContext context, out int pullRequestNum } if (context.Request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase) + || context.Request.Path.StartsWithSegments("/auth", StringComparison.OrdinalIgnoreCase) || context.Request.Path.StartsWithSegments("/healthz", StringComparison.OrdinalIgnoreCase)) { pullRequestNumber = default; @@ -462,3 +740,93 @@ static string GetRelativePreviewPath(PathString requestPath) return relativePath; } + +static string GetRateLimitPartitionKey(HttpContext context, string bucket) +{ + var login = context.User.FindFirstValue(PreviewAuthenticationDefaults.UserLoginClaimType) + ?? context.User.FindFirstValue(ClaimTypes.Name) + ?? context.Connection.RemoteIpAddress?.ToString() + ?? "anonymous"; + + return $"{bucket}:{login}"; +} + +static string BuildAccessDeniedPage(PreviewHostOptions options) +{ + var repositoryName = $"{options.RepositoryOwner}/{options.RepositoryName}"; + return $$""" + + + + + + Preview access required + + + +
+

Preview access required

+

Sign in with GitHub using an account that has write access to {{repositoryName}} to browse, prepare, or reset previews.

+

Preview content is intentionally limited to trusted repository writers so the preview host no longer relies on unauthenticated control endpoints or automatic PR registration.

+ Sign in with GitHub +
+ + + """; +} diff --git a/src/statichost/PreviewHost/wwwroot/_preview/index.html b/src/statichost/PreviewHost/wwwroot/_preview/index.html index 0954c9dc3..5aeecab67 100644 --- a/src/statichost/PreviewHost/wwwroot/_preview/index.html +++ b/src/statichost/PreviewHost/wwwroot/_preview/index.html @@ -23,10 +23,20 @@

Open pull requests

Pick any open PR and the preview host will warm the latest frontend build on demand while keeping only a small window hot.

- - - Repository - +
+ + + + + Repository + +
Loading warm window... Loading open pull requests...
diff --git a/src/statichost/PreviewHost/wwwroot/_preview/index.js b/src/statichost/PreviewHost/wwwroot/_preview/index.js index 1cb5c7ed4..3d7f1a30a 100644 --- a/src/statichost/PreviewHost/wwwroot/_preview/index.js +++ b/src/statichost/PreviewHost/wwwroot/_preview/index.js @@ -3,6 +3,10 @@ const windowCapacity = document.getElementById("window-capacity"); const windowCount = document.getElementById("window-count"); const availabilityFilterBar = document.getElementById("availability-filter-bar"); const authorFilterBar = document.getElementById("author-filter-bar"); +const signoutLink = document.getElementById("signout-link"); +const viewerSummary = document.getElementById("viewer-summary"); +const viewerAvatar = document.getElementById("viewer-avatar"); +const viewerName = document.getElementById("viewer-name"); const numberFormatter = new Intl.NumberFormat(); const dateFormatter = new Intl.DateTimeFormat(undefined, { @@ -21,6 +25,7 @@ let maxActivePreviews = 0; let openPullRequestCount = 0; let previewablePullRequestCount = 0; let openDropdown = ""; +let sessionInfo = null; const resettingPreviews = new Set(); availabilityFilterBar.addEventListener("click", (event) => { @@ -141,7 +146,7 @@ previewGrid.addEventListener("click", (event) => { void resetPreview(pullRequestNumber); }); -loadCatalog().catch((error) => { +Promise.all([loadSession(), loadCatalog()]).catch((error) => { previewGrid.setAttribute("aria-busy", "false"); availabilityFilterBar.setAttribute("aria-busy", "false"); authorFilterBar.setAttribute("aria-busy", "false"); @@ -159,8 +164,19 @@ setInterval(() => { async function loadCatalog() { const response = await fetch("/api/previews/catalog", { cache: "no-store", + credentials: "same-origin", }); + if (response.status === 401) { + redirectToLogin(); + return; + } + + if (response.status === 403) { + window.location.replace("/auth/access-denied"); + return; + } + if (!response.ok) { throw new Error(`Open pull requests request failed with status ${response.status}.`); } @@ -179,6 +195,58 @@ async function loadCatalog() { renderCatalog(); } +async function loadSession() { + const response = await fetch("/api/previews/session", { + cache: "no-store", + credentials: "same-origin", + }); + + if (response.status === 401) { + redirectToLogin(); + throw new Error("Sign in with GitHub to browse previews."); + } + + if (response.status === 403) { + window.location.replace("/auth/access-denied"); + throw new Error("Preview access denied."); + } + + if (!response.ok) { + throw new Error(`Session request failed with status ${response.status}.`); + } + + sessionInfo = await response.json(); + applySession(sessionInfo); +} + +function applySession(session) { + if (signoutLink && typeof session?.signOutPath === "string" && session.signOutPath) { + signoutLink.href = session.signOutPath; + signoutLink.hidden = false; + } + + if (!viewerSummary || !viewerName) { + return; + } + + const displayName = session?.viewer?.displayName || session?.viewer?.login || "Signed in"; + const login = session?.viewer?.login ? `@${session.viewer.login}` : "GitHub repo writer"; + viewerName.textContent = displayName; + viewerName.title = login; + + const roleElement = viewerSummary.querySelector(".viewer-role"); + if (roleElement) { + roleElement.textContent = login; + } + + if (viewerAvatar && session?.viewer?.avatarUrl) { + viewerAvatar.src = session.viewer.avatarUrl; + viewerAvatar.hidden = false; + } + + viewerSummary.hidden = false; +} + function renderCatalog() { const filteredEntries = applyFilters(catalogEntries); @@ -214,7 +282,7 @@ function renderPreviewCard(entry) { const draftBadge = entry.isDraft ? 'Draft' : ""; - const resetAction = renderResetPreviewAction(entry, preview); + const footer = renderCardFooter(entry, preview); return `
@@ -234,7 +302,7 @@ function renderPreviewCard(entry) { ${statusDetail} - ${resetAction} + ${footer}
`; } @@ -243,7 +311,7 @@ function renderEmptyState(message) { previewGrid.innerHTML = `

${escapeHtml(message)}

-

Open a route like /prs/{number}/ to resolve a PR, prepare its latest frontend artifact, and add it to the warm preview window.

+

Choose a pull request to open /prs/{number}/, which signs in through GitHub and prepares the latest successful frontend artifact on demand.

`; } @@ -339,22 +407,30 @@ function renderAuthorOption(option) { `; } -function renderResetPreviewAction(entry, preview) { - if (!preview) { - return ""; - } - +function renderCardFooter(entry, preview) { const pullRequestNumber = Number(entry.pullRequestNumber); + const previewPath = escapeHtml(entry.previewPath ?? `/prs/${pullRequestNumber}/`); + const primaryLabel = escapeHtml(buildPrimaryActionLabel(preview, entry)); + const primaryClass = isPreviewable(entry) + ? "action-button primary" + : "action-button secondary"; const isResetting = resettingPreviews.has(pullRequestNumber); const label = isResetting ? "Resetting..." : "Reset preview"; const disabled = isResetting ? " disabled" : ""; + const resetAction = preview + ? ` + ` + : ""; return ` `; } @@ -422,13 +498,13 @@ function getAuthorLabelFromValue(value) { function buildStatusDetail(preview, entry) { if (!preview) { return isPreviewable(entry) - ? "Loads on first visit." + ? "Open preview to prepare the latest successful frontend build." : "No successful frontend build artifact is available for the current head yet."; } if (preview.headSha && entry.headSha && preview.headSha !== entry.headSha) { return isPreviewable(entry) - ? "New commits are ready to warm from the latest frontend build." + ? "Open preview to refresh this PR to the latest successful frontend build." : "New commits are waiting for a successful frontend build."; } @@ -450,6 +526,31 @@ function buildStatusDetail(preview, entry) { } } +function buildPrimaryActionLabel(preview, entry) { + if (!preview) { + return isPreviewable(entry) ? "Prepare preview" : "View status"; + } + + if (preview.headSha && entry.headSha && preview.headSha !== entry.headSha) { + return isPreviewable(entry) ? "Refresh preview" : "View status"; + } + + switch (preview.state) { + case "Ready": + return "Open preview"; + case "Loading": + return "View progress"; + case "Registered": + return "Open preview"; + case "Cancelled": + case "Failed": + case "Evicted": + return "Retry preview"; + default: + return "Open preview"; + } +} + function buildChipLabel(preview, entry) { if (!preview) { return isPreviewable(entry) ? "On demand" : "Waiting on CI"; @@ -674,11 +775,23 @@ async function resetPreview(pullRequestNumber) { try { const response = await fetch(`/api/previews/${pullRequestNumber}/reset`, { method: "POST", + credentials: "same-origin", headers: { "Accept": "application/json", + ...getCsrfHeaders(), }, }); + if (response.status === 401) { + redirectToLogin(); + return; + } + + if (response.status === 403) { + window.location.replace("/auth/access-denied"); + return; + } + if (!response.ok) { throw new Error(`Preview reset request failed with status ${response.status}.`); } @@ -708,3 +821,29 @@ function escapeHtml(value) { .replaceAll('"', """) .replaceAll("'", "'"); } + +function redirectToLogin() { + const returnUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`; + window.location.replace(`/auth/login?returnUrl=${encodeURIComponent(returnUrl)}`); +} + +function getCsrfHeaders() { + const csrfToken = getCsrfToken(); + return csrfToken + ? { "X-Preview-Csrf": csrfToken } + : {}; +} + +function getCsrfToken() { + const cookieParts = document.cookie.split(";"); + for (const part of cookieParts) { + const [rawName, ...rawValue] = part.trim().split("="); + if (rawName !== "previewhost-csrf" && rawName !== encodeURIComponent("previewhost-csrf")) { + continue; + } + + return decodeURIComponent(rawValue.join("=")); + } + + return ""; +} diff --git a/src/statichost/PreviewHost/wwwroot/_preview/preview.css b/src/statichost/PreviewHost/wwwroot/_preview/preview.css index 0a57310ae..1b01ded8a 100644 --- a/src/statichost/PreviewHost/wwwroot/_preview/preview.css +++ b/src/statichost/PreviewHost/wwwroot/_preview/preview.css @@ -52,6 +52,19 @@ body[data-view="index"] { margin-bottom: 1rem; } +.page-chrome-actions, +.collection-actions, +.session-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; +} + +.page-chrome-actions { + justify-content: space-between; +} + .page-shell { width: min(42rem, 100%); } @@ -456,6 +469,42 @@ p { background: rgba(233, 180, 76, 0.14); } +.viewer-summary { + display: inline-flex; + align-items: center; + gap: 0.65rem; + min-height: 2.75rem; + padding: 0.5rem 0.7rem; + border-radius: 0.8rem; + border: 1px solid var(--panel-border); + background: rgba(24, 24, 37, 0.84); +} + +.viewer-avatar { + width: 1.9rem; + height: 1.9rem; + border-radius: 999px; + object-fit: cover; + border: 1px solid rgba(198, 194, 242, 0.24); +} + +.viewer-copy { + display: grid; + gap: 0.08rem; + min-width: 0; +} + +.viewer-copy strong { + font-size: 0.92rem; + line-height: 1.1; +} + +.viewer-role { + color: var(--muted); + font-size: 0.78rem; + line-height: 1.2; +} + .collection-meta { display: grid; justify-items: end; @@ -982,6 +1031,13 @@ code { text-align: left; } + .page-chrome-actions, + .collection-actions, + .session-actions { + width: 100%; + justify-content: flex-start; + } + .status-card-actions { flex-direction: column; align-items: stretch; diff --git a/src/statichost/PreviewHost/wwwroot/_preview/status.html b/src/statichost/PreviewHost/wwwroot/_preview/status.html index b953e280f..289e03066 100644 --- a/src/statichost/PreviewHost/wwwroot/_preview/status.html +++ b/src/statichost/PreviewHost/wwwroot/_preview/status.html @@ -9,10 +9,22 @@
- - - Back to previews - +
+ + + Back to previews + +
+ + +
+
diff --git a/src/statichost/PreviewHost/wwwroot/_preview/status.js b/src/statichost/PreviewHost/wwwroot/_preview/status.js index f98395974..ae9a5b754 100644 --- a/src/statichost/PreviewHost/wwwroot/_preview/status.js +++ b/src/statichost/PreviewHost/wwwroot/_preview/status.js @@ -28,6 +28,10 @@ const openPrLink = document.getElementById("open-pr-link"); const cancelButton = document.getElementById("cancel-button"); const retryButton = document.getElementById("retry-button"); const retryButtonLabel = retryButton?.querySelector(".button-label"); +const signoutLink = document.getElementById("signout-link"); +const viewerSummary = document.getElementById("viewer-summary"); +const viewerAvatar = document.getElementById("viewer-avatar"); +const viewerName = document.getElementById("viewer-name"); const numberFormatter = new Intl.NumberFormat(); const dateFormatter = new Intl.DateTimeFormat(undefined, { @@ -42,10 +46,11 @@ const clockFormatter = new Intl.DateTimeFormat(undefined, { let currentState = null; let eventSource = null; -let retryInFlight = false; +let prepareInFlight = false; let cancelInFlight = false; let suppressCloseCancellation = false; let closeCancellationSent = false; +let sessionInfo = null; const downloadRateTracker = createRateTracker(); const extractionRateTracker = createRateTracker(); @@ -54,9 +59,16 @@ window.addEventListener("pagehide", cancelIfClosingDuringPreparation); window.addEventListener("beforeunload", cancelIfClosingDuringPreparation); document.addEventListener("click", preservePreparationWhenReturningToCatalog); -bootstrap().catch((error) => { - applyState(buildFailureState(error instanceof Error ? error.message : "The preview host could not load the preview status.")); -}); +void initialize(); + +async function initialize() { + try { + await loadSession(); + await preparePreview(); + } catch (error) { + applyState(buildFailureState(error instanceof Error ? error.message : "The preview host could not load the preview status.")); + } +} cancelButton.addEventListener("click", async () => { if (!pullRequestNumber || cancelInFlight) { @@ -70,8 +82,22 @@ cancelButton.addEventListener("click", async () => { const response = await fetch(`/api/previews/${pullRequestNumber}/cancel`, { method: "POST", cache: "no-store", + credentials: "same-origin", + headers: getCsrfHeaders({ + "Accept": "application/json", + }), }); + if (response.status === 401) { + redirectToLogin(); + return; + } + + if (response.status === 403) { + window.location.replace("/auth/access-denied"); + return; + } + if (!response.ok) { throw new Error(`Cancel failed with status ${response.status}.`); } @@ -89,77 +115,116 @@ cancelButton.addEventListener("click", async () => { }); retryButton.addEventListener("click", async () => { - if (!pullRequestNumber || retryInFlight) { + try { + await preparePreview(); + } catch (error) { + hint.textContent = error instanceof Error + ? error.message + : "The preview host could not restart preview preparation."; + } +}); + +async function loadSession() { + const response = await fetch("/api/previews/session", { + cache: "no-store", + credentials: "same-origin", + }); + + if (response.status === 401) { + redirectToLogin(); + throw new Error("Sign in with GitHub to prepare previews."); + } + + if (response.status === 403) { + window.location.replace("/auth/access-denied"); + throw new Error("Preview access denied."); + } + + if (!response.ok) { + throw new Error(`Session request failed with status ${response.status}.`); + } + + sessionInfo = await response.json(); + applySession(sessionInfo); +} + +function applySession(session) { + if (signoutLink && typeof session?.signOutPath === "string" && session.signOutPath) { + signoutLink.href = session.signOutPath; + signoutLink.hidden = false; + } + + if (!viewerSummary || !viewerName) { return; } - retryInFlight = true; + const displayName = session?.viewer?.displayName || session?.viewer?.login || "Signed in"; + const login = session?.viewer?.login ? `@${session.viewer.login}` : "GitHub repo writer"; + viewerName.textContent = displayName; + viewerName.title = login; + + const roleElement = viewerSummary.querySelector(".viewer-role"); + if (roleElement) { + roleElement.textContent = login; + } + + if (viewerAvatar && session?.viewer?.avatarUrl) { + viewerAvatar.src = session.viewer.avatarUrl; + viewerAvatar.hidden = false; + } + + viewerSummary.hidden = false; +} + +async function preparePreview() { + if (!pullRequestNumber || prepareInFlight) { + return; + } + + prepareInFlight = true; updateActionButtons(); + suppressCloseCancellation = false; + closeEventSource(); try { - const response = await fetch(`/api/previews/${pullRequestNumber}/retry`, { + const response = await fetch(`/api/previews/${pullRequestNumber}/prepare`, { method: "POST", cache: "no-store", + credentials: "same-origin", + headers: getCsrfHeaders({ + "Accept": "application/json", + }), }); const payload = await readJsonSafely(response); - if (!response.ok && response.status !== 202) { - applyState(buildFailureState( - payload?.failureMessage ?? `Retry failed with status ${response.status}.`, - payload ?? {}, - )); + if (response.status === 401) { + redirectToLogin(); return; } - if (!payload) { - await bootstrap(); + if (response.status === 403) { + window.location.replace("/auth/access-denied"); return; } - closeEventSource(); - applyState(payload); - - if (payload.isReady || payload.state === "Missing" || isTerminalState(payload.state)) { + if (response.status === 404) { + applyState(buildFailureState( + payload?.failureMessage ?? "The preview host could not find a successful frontend build for this pull request yet.", + payload ?? {}, + )); return; } - if (isActiveState(payload.state)) { - connectEventsIfNeeded(payload); - return; + if (!response.ok) { + throw new Error(`Preview request failed with status ${response.status}.`); } - await bootstrap(); - } catch (error) { - hint.textContent = error instanceof Error - ? error.message - : "The preview host could not restart preview preparation."; + applyState(payload); + connectEventsIfNeeded(payload); } finally { - retryInFlight = false; + prepareInFlight = false; updateActionButtons(); } -}); - -async function bootstrap() { - if (!pullRequestNumber) { - applyState(buildFailureState("This route does not contain a valid pull request number.")); - return; - } - - suppressCloseCancellation = false; - closeEventSource(); - - const response = await fetch(`/api/previews/${pullRequestNumber}/bootstrap`, { - cache: "no-store", - }); - const payload = await readJsonSafely(response); - - if (!response.ok) { - applyState(buildFailureState(payload?.failureMessage ?? "The preview host could not find a successful frontend build for this pull request yet.", payload)); - return; - } - - applyState(payload); - connectEventsIfNeeded(payload); } function connectEventsIfNeeded(snapshot) { @@ -191,7 +256,7 @@ function applyState(snapshot) { if (snapshot.isReady) { suppressCloseCancellation = true; closeEventSource(); - window.location.replace(refreshTarget); + window.location.replace(buildContentUrl(snapshot.previewPath ?? refreshTarget)); return; } @@ -256,11 +321,11 @@ function initializeShell() { const initialTitle = `Preparing PR #${pullRequestNumber}`; document.title = initialTitle; pageTitle.textContent = initialTitle; - message.textContent = "Checking GitHub for the latest successful frontend artifact."; + message.textContent = "Checking GitHub for the latest successful frontend artifact you can preview."; message.hidden = false; cardBusyIndicator.hidden = false; previewPath.textContent = refreshTarget; - hint.textContent = "This page starts loading the preview automatically when a build is available."; + hint.textContent = "This page prepares the latest successful build for this PR every time you open it."; } function updateOpenPrLink(snapshot) { @@ -278,15 +343,18 @@ function updateOpenPrLink(snapshot) { function updateActionButtons() { const canCancel = currentState && isActiveState(currentState.state); const canRetry = currentState && (isTerminalState(currentState.state) || currentState.state === "Missing"); + const retryLabel = currentState?.state === "Missing" + ? "Check latest build" + : "Retry prep"; cancelButton.hidden = !canCancel && !cancelInFlight; cancelButton.disabled = !canCancel || cancelInFlight; cancelButton.textContent = cancelInFlight ? "Cancelling..." : "Cancel prep"; - retryButton.hidden = !canRetry && !retryInFlight; - retryButton.disabled = !canRetry || retryInFlight; + retryButton.hidden = !canRetry && !prepareInFlight; + retryButton.disabled = prepareInFlight || (!canRetry && !prepareInFlight); if (retryButtonLabel) { - retryButtonLabel.textContent = retryInFlight ? "Restarting..." : "Retry prep"; + retryButtonLabel.textContent = prepareInFlight ? "Checking..." : retryLabel; } if (statusCardActions) { @@ -356,25 +424,39 @@ function cancelIfClosingDuringPreparation() { if (!pullRequestNumber || suppressCloseCancellation || closeCancellationSent - || retryInFlight + || prepareInFlight || cancelInFlight || !currentState || !isActiveState(currentState.state)) { return; } + const csrfToken = getCsrfToken(); + if (!csrfToken) { + return; + } + closeCancellationSent = true; const cancelUrl = `/api/previews/${pullRequestNumber}/cancel`; + const payload = new URLSearchParams({ + "__RequestVerificationToken": csrfToken, + }); if (typeof navigator.sendBeacon === "function") { - navigator.sendBeacon(cancelUrl, new Blob([], { type: "application/octet-stream" })); + navigator.sendBeacon(cancelUrl, payload); return; } fetch(cancelUrl, { method: "POST", cache: "no-store", + credentials: "same-origin", keepalive: true, + headers: { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + }, + body: payload, }).catch(() => { }); } @@ -418,24 +500,28 @@ function getTitle(snapshot) { return `PR #${number} prep cancelled`; } + if (snapshot.state === "Ready") { + return `Opening PR #${number} preview`; + } + return `Preparing PR #${number}`; } function getHint(snapshot) { if (snapshot.state === "Missing") { - return "Retry after CI publishes a new artifact."; + return "Wait for CI to publish a successful frontend build, then check again."; } if (snapshot.state === "Failed") { - return "Fix the backing configuration or publish a new build, then retry."; + return "Retry to ask GitHub for the latest successful build again."; } if (snapshot.state === "Cancelled") { - return "Retry when you are ready to start again."; + return "Retry when you are ready to prepare the latest build again."; } if (snapshot.state === "Evicted") { - return "Retry to prepare it again."; + return "Retry to move this PR back into the warm preview window."; } return "This page will open the preview automatically as soon as preparation finishes."; @@ -589,3 +675,41 @@ function formatUnitRate(value) { return numericValue.toFixed(2); } + +function buildContentUrl(relativePath) { + try { + return new URL(relativePath, sessionInfo?.contentBaseUrl ?? window.location.origin).toString(); + } catch { + return relativePath; + } +} + +function redirectToLogin() { + const returnUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`; + window.location.replace(`/auth/login?returnUrl=${encodeURIComponent(returnUrl)}`); +} + +function getCsrfHeaders(additionalHeaders = {}) { + const headers = { ...additionalHeaders }; + const csrfToken = getCsrfToken(); + if (csrfToken) { + headers["X-Preview-Csrf"] = csrfToken; + } + + return headers; +} + +function getCsrfToken() { + const encodedName = encodeURIComponent("previewhost-csrf"); + const parts = document.cookie.split(";"); + for (const part of parts) { + const [rawName, ...rawValue] = part.trim().split("="); + if (rawName !== encodedName && rawName !== "previewhost-csrf") { + continue; + } + + return decodeURIComponent(rawValue.join("=")); + } + + return ""; +} From 66eb7cb629d6007c85b1f3038acf3dd76b55ce10 Mon Sep 17 00:00:00 2001 From: David Pine Date: Tue, 31 Mar 2026 22:44:21 -0500 Subject: [PATCH 5/5] refactor: Update terminology and improve UI elements for PR previews --- .../PreviewHost/wwwroot/_preview/index.html | 29 ++--- .../PreviewHost/wwwroot/_preview/index.js | 47 ++++--- .../PreviewHost/wwwroot/_preview/preview.css | 117 +++++++++++++++--- .../PreviewHost/wwwroot/_preview/status.html | 20 +-- .../PreviewHost/wwwroot/_preview/status.js | 37 +++--- 5 files changed, 179 insertions(+), 71 deletions(-) diff --git a/src/statichost/PreviewHost/wwwroot/_preview/index.html b/src/statichost/PreviewHost/wwwroot/_preview/index.html index 5aeecab67..21afcef12 100644 --- a/src/statichost/PreviewHost/wwwroot/_preview/index.html +++ b/src/statichost/PreviewHost/wwwroot/_preview/index.html @@ -19,26 +19,27 @@

Aspire PR Preview

-

Open pull requests

-

Pick any open PR and the preview host will warm the latest frontend build on demand while keeping only a small window hot.

+

Open PRs

+

Open any PR to warm its latest frontend preview. Only a small active window stays hot.

-
- - Loading warm window... - Loading open pull requests... +
+ Loading capacity... + Loading PRs... +
@@ -49,19 +50,19 @@

Open pull requests

- Preview status + Status
diff --git a/src/statichost/PreviewHost/wwwroot/_preview/index.js b/src/statichost/PreviewHost/wwwroot/_preview/index.js index 3d7f1a30a..835deb995 100644 --- a/src/statichost/PreviewHost/wwwroot/_preview/index.js +++ b/src/statichost/PreviewHost/wwwroot/_preview/index.js @@ -152,7 +152,7 @@ Promise.all([loadSession(), loadCatalog()]).catch((error) => { authorFilterBar.setAttribute("aria-busy", "false"); syncAvailabilityFilter(); syncAuthorFilter([]); - renderEmptyState(error instanceof Error ? error.message : "The preview host could not load open pull requests."); + renderEmptyState(error instanceof Error ? error.message : "Couldn't load open PRs."); }); setInterval(() => { @@ -178,7 +178,7 @@ async function loadCatalog() { } if (!response.ok) { - throw new Error(`Open pull requests request failed with status ${response.status}.`); + throw new Error(`Open PR request failed with status ${response.status}.`); } const payload = await response.json(); @@ -203,7 +203,7 @@ async function loadSession() { if (response.status === 401) { redirectToLogin(); - throw new Error("Sign in with GitHub to browse previews."); + throw new Error("Sign in with GitHub to view previews."); } if (response.status === 403) { @@ -231,8 +231,16 @@ function applySession(session) { const displayName = session?.viewer?.displayName || session?.viewer?.login || "Signed in"; const login = session?.viewer?.login ? `@${session.viewer.login}` : "GitHub repo writer"; + const profileUrl = session?.viewer?.profileUrl + || (session?.viewer?.login ? `https://github.com/${session.viewer.login}` : ""); viewerName.textContent = displayName; viewerName.title = login; + viewerSummary.title = login; + + if (viewerSummary instanceof HTMLAnchorElement && profileUrl) { + viewerSummary.href = profileUrl; + viewerSummary.setAttribute("aria-label", `${displayName} on GitHub`); + } const roleElement = viewerSummary.querySelector(".viewer-role"); if (roleElement) { @@ -241,6 +249,7 @@ function applySession(session) { if (viewerAvatar && session?.viewer?.avatarUrl) { viewerAvatar.src = session.viewer.avatarUrl; + viewerAvatar.alt = `${displayName} avatar`; viewerAvatar.hidden = false; } @@ -311,7 +320,7 @@ function renderEmptyState(message) { previewGrid.innerHTML = `

${escapeHtml(message)}

-

Choose a pull request to open /prs/{number}/, which signs in through GitHub and prepares the latest successful frontend artifact on demand.

+

Open /prs/{number}/ to sign in and warm the latest successful frontend build.

`; } @@ -366,7 +375,7 @@ function renderAvailabilityFilterMenu() { Only show previewable PRs - Limit the catalog to pull requests whose current head has a successful frontend build artifact. + Only PRs whose head commit has a successful frontend build. ${numberFormatter.format(previewablePullRequestCount)} @@ -375,7 +384,7 @@ function renderAvailabilityFilterMenu() { function renderAuthorFilterMenu() { if (authorOptions.length === 0) { - return '
No open pull request authors are available right now.
'; + return '
No authors available.
'; } const footer = selectedAuthors.size > 0 @@ -498,14 +507,14 @@ function getAuthorLabelFromValue(value) { function buildStatusDetail(preview, entry) { if (!preview) { return isPreviewable(entry) - ? "Open preview to prepare the latest successful frontend build." - : "No successful frontend build artifact is available for the current head yet."; + ? "Open to warm the latest successful frontend build." + : "No successful frontend build for this head yet."; } if (preview.headSha && entry.headSha && preview.headSha !== entry.headSha) { return isPreviewable(entry) - ? "Open preview to refresh this PR to the latest successful frontend build." - : "New commits are waiting for a successful frontend build."; + ? "Open to refresh this PR to the latest successful frontend build." + : "New commits are waiting on a successful frontend build."; } switch (preview.state) { @@ -518,7 +527,7 @@ function buildStatusDetail(preview, entry) { case "Cancelled": return "Preparation was cancelled."; case "Failed": - return preview.error ?? preview.message ?? "The preview host could not finish preparing this build."; + return preview.error ?? preview.message ?? "Couldn't finish preparing this build."; case "Evicted": return "Loads again on the next visit."; default: @@ -605,7 +614,7 @@ function buildWindowCountText(filteredEntries) { } if (showPreviewableOnly) { - return `Showing ${numberFormatter.format(filteredEntries.length)} of ${numberFormatter.format(openPullRequestCount)} open PRs with a successful frontend build`; + return `Showing ${numberFormatter.format(filteredEntries.length)} of ${numberFormatter.format(openPullRequestCount)} previewable PRs`; } if (selectedAuthors.size > 0) { @@ -617,18 +626,18 @@ function buildWindowCountText(filteredEntries) { function buildEmptyStateMessage() { if (showPreviewableOnly && selectedAuthors.size > 0) { - return `No previewable open pull requests match ${buildSelectedAuthorSummary()}.`; + return `No previewable PRs match ${buildSelectedAuthorSummary()}.`; } if (showPreviewableOnly) { - return "No open pull requests have a successful frontend build artifact right now."; + return "No open PRs have a successful frontend build right now."; } if (selectedAuthors.size > 0) { - return `No open pull requests match ${buildSelectedAuthorSummary()}.`; + return `No open PRs match ${buildSelectedAuthorSummary()}.`; } - return "No open pull requests need previews right now."; + return "No open PRs right now."; } function buildAuthorTriggerLabel() { @@ -661,12 +670,12 @@ function buildAuthorTriggerMeta() { function buildAuthorOptionMeta(count) { if (count === 0) { - return "No open pull requests right now"; + return "No open PRs"; } return count === 1 - ? "1 open pull request" - : `${numberFormatter.format(count)} open pull requests`; + ? "1 open PR" + : `${numberFormatter.format(count)} open PRs`; } function buildSelectedAuthorSummary() { diff --git a/src/statichost/PreviewHost/wwwroot/_preview/preview.css b/src/statichost/PreviewHost/wwwroot/_preview/preview.css index 1b01ded8a..385df9efb 100644 --- a/src/statichost/PreviewHost/wwwroot/_preview/preview.css +++ b/src/statichost/PreviewHost/wwwroot/_preview/preview.css @@ -56,15 +56,22 @@ body[data-view="index"] { .collection-actions, .session-actions { display: flex; - flex-wrap: wrap; align-items: center; gap: 0.75rem; + min-width: 0; } .page-chrome-actions { + flex-wrap: wrap; justify-content: space-between; } +.collection-actions, +.session-actions { + flex-wrap: nowrap; + justify-content: flex-end; +} + .page-shell { width: min(42rem, 100%); } @@ -129,19 +136,41 @@ body[data-view="index"] { .nav-button, .action-link, .action-button { - min-height: 2.75rem; - padding: 0.7rem 1rem; + min-height: 2.55rem; + padding: 0.62rem 0.9rem; color: var(--text); background: rgba(24, 24, 37, 0.84); font: inherit; + font-size: 0.95rem; font-weight: 600; + white-space: nowrap; } .action-link-github { - background: var(--panel-strong); + background: linear-gradient(180deg, rgba(53, 44, 103, 0.92), rgba(35, 29, 65, 0.92)); + box-shadow: 0 0.9rem 1.8rem rgba(10, 11, 20, 0.2); cursor: pointer; } +.action-link-compact { + padding-inline: 0.9rem; +} + +.action-link-icon-only { + width: 2.55rem; + min-width: 2.55rem; + padding: 0; +} + +.action-link-quiet { + color: var(--muted-strong); + background: rgba(24, 24, 37, 0.62); +} + +.session-toolbar .action-link-github { + margin-left: auto; +} + .action-button.primary { border-color: rgba(198, 194, 242, 0.35); background: linear-gradient(180deg, var(--accent), var(--accent-strong)); @@ -472,44 +501,83 @@ p { .viewer-summary { display: inline-flex; align-items: center; - gap: 0.65rem; - min-height: 2.75rem; - padding: 0.5rem 0.7rem; - border-radius: 0.8rem; + gap: 0.6rem; + min-height: 2.55rem; + max-width: min(100%, 16rem); + padding: 0.35rem 0.75rem 0.35rem 0.45rem; + border-radius: 0.95rem; border: 1px solid var(--panel-border); background: rgba(24, 24, 37, 0.84); + box-shadow: 0 0.75rem 1.4rem rgba(10, 11, 20, 0.18); + min-width: 0; + color: var(--text); + text-decoration: none; + transition: border-color 0.18s ease, transform 0.18s ease, background 0.18s ease; +} + +.viewer-summary:hover { + transform: translateY(-1px); + border-color: rgba(198, 194, 242, 0.4); + background: rgba(35, 29, 65, 0.82); +} + +.viewer-summary:focus-visible { + outline: 2px solid rgba(198, 194, 242, 0.45); + outline-offset: 2px; } .viewer-avatar { - width: 1.9rem; - height: 1.9rem; + width: 1.75rem; + height: 1.75rem; border-radius: 999px; object-fit: cover; border: 1px solid rgba(198, 194, 242, 0.24); + flex: none; } .viewer-copy { - display: grid; - gap: 0.08rem; + display: flex; + align-items: baseline; + gap: 0.4rem; min-width: 0; } .viewer-copy strong { + min-width: 0; font-size: 0.92rem; line-height: 1.1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .viewer-role { color: var(--muted); - font-size: 0.78rem; + font-size: 0.82rem; line-height: 1.2; + flex: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .collection-meta { display: grid; + align-content: start; justify-items: end; - gap: 0.35rem; + gap: 0.5rem; text-align: right; + min-width: 0; +} + +.collection-stats { + display: grid; + gap: 0.2rem; + justify-items: end; +} + +.session-toolbar { + justify-content: flex-end; } .collection-controls { @@ -1035,9 +1103,30 @@ code { .collection-actions, .session-actions { width: 100%; + flex-wrap: wrap; + justify-content: flex-start; + } + + .session-toolbar { justify-content: flex-start; } + .viewer-summary { + max-width: 100%; + } + + .viewer-copy { + flex-wrap: wrap; + } + + .viewer-role { + white-space: normal; + } + + .collection-stats { + justify-items: start; + } + .status-card-actions { flex-direction: column; align-items: stretch; diff --git a/src/statichost/PreviewHost/wwwroot/_preview/status.html b/src/statichost/PreviewHost/wwwroot/_preview/status.html index 289e03066..f8a825d89 100644 --- a/src/statichost/PreviewHost/wwwroot/_preview/status.html +++ b/src/statichost/PreviewHost/wwwroot/_preview/status.html @@ -12,17 +12,17 @@ @@ -42,7 +42,7 @@ PR Preview -

Preparing preview

+

Preparing PR

@@ -51,7 +51,7 @@

Preparing preview

diff --git a/src/statichost/PreviewHost/wwwroot/_preview/status.js b/src/statichost/PreviewHost/wwwroot/_preview/status.js index ae9a5b754..b7f7d4d4a 100644 --- a/src/statichost/PreviewHost/wwwroot/_preview/status.js +++ b/src/statichost/PreviewHost/wwwroot/_preview/status.js @@ -66,7 +66,7 @@ async function initialize() { await loadSession(); await preparePreview(); } catch (error) { - applyState(buildFailureState(error instanceof Error ? error.message : "The preview host could not load the preview status.")); + applyState(buildFailureState(error instanceof Error ? error.message : "Couldn't load preview status.")); } } @@ -107,7 +107,7 @@ cancelButton.addEventListener("click", async () => { } catch (error) { hint.textContent = error instanceof Error ? error.message - : "The preview host could not cancel the current preparation run."; + : "Couldn't cancel this prep run."; } finally { cancelInFlight = false; updateActionButtons(); @@ -120,7 +120,7 @@ retryButton.addEventListener("click", async () => { } catch (error) { hint.textContent = error instanceof Error ? error.message - : "The preview host could not restart preview preparation."; + : "Couldn't restart prep."; } }); @@ -132,7 +132,7 @@ async function loadSession() { if (response.status === 401) { redirectToLogin(); - throw new Error("Sign in with GitHub to prepare previews."); + throw new Error("Sign in with GitHub to prepare this preview."); } if (response.status === 403) { @@ -160,8 +160,16 @@ function applySession(session) { const displayName = session?.viewer?.displayName || session?.viewer?.login || "Signed in"; const login = session?.viewer?.login ? `@${session.viewer.login}` : "GitHub repo writer"; + const profileUrl = session?.viewer?.profileUrl + || (session?.viewer?.login ? `https://github.com/${session.viewer.login}` : ""); viewerName.textContent = displayName; viewerName.title = login; + viewerSummary.title = login; + + if (viewerSummary instanceof HTMLAnchorElement && profileUrl) { + viewerSummary.href = profileUrl; + viewerSummary.setAttribute("aria-label", `${displayName} on GitHub`); + } const roleElement = viewerSummary.querySelector(".viewer-role"); if (roleElement) { @@ -170,6 +178,7 @@ function applySession(session) { if (viewerAvatar && session?.viewer?.avatarUrl) { viewerAvatar.src = session.viewer.avatarUrl; + viewerAvatar.alt = `${displayName} avatar`; viewerAvatar.hidden = false; } @@ -209,7 +218,7 @@ async function preparePreview() { if (response.status === 404) { applyState(buildFailureState( - payload?.failureMessage ?? "The preview host could not find a successful frontend build for this pull request yet.", + payload?.failureMessage ?? "No successful frontend build found for this PR yet.", payload ?? {}, )); return; @@ -345,11 +354,11 @@ function updateActionButtons() { const canRetry = currentState && (isTerminalState(currentState.state) || currentState.state === "Missing"); const retryLabel = currentState?.state === "Missing" ? "Check latest build" - : "Retry prep"; + : "Retry"; cancelButton.hidden = !canCancel && !cancelInFlight; cancelButton.disabled = !canCancel || cancelInFlight; - cancelButton.textContent = cancelInFlight ? "Cancelling..." : "Cancel prep"; + cancelButton.textContent = cancelInFlight ? "Cancelling..." : "Cancel"; retryButton.hidden = !canRetry && !prepareInFlight; retryButton.disabled = prepareInFlight || (!canRetry && !prepareInFlight); @@ -493,11 +502,11 @@ function getStatusLabel(state) { function getTitle(snapshot) { const number = snapshot.pullRequestNumber ?? pullRequestNumber ?? "?"; if (snapshot.state === "Failed" || snapshot.state === "Missing") { - return `PR #${number} preview unavailable`; + return `PR #${number} unavailable`; } if (snapshot.state === "Cancelled") { - return `PR #${number} prep cancelled`; + return `PR #${number} cancelled`; } if (snapshot.state === "Ready") { @@ -513,11 +522,11 @@ function getHint(snapshot) { } if (snapshot.state === "Failed") { - return "Retry to ask GitHub for the latest successful build again."; + return "Retry to fetch the latest successful build again."; } if (snapshot.state === "Cancelled") { - return "Retry when you are ready to prepare the latest build again."; + return "Retry when you're ready to prepare the latest build again."; } if (snapshot.state === "Evicted") { @@ -533,14 +542,14 @@ function getStateClassName(state) { function getStageProgressLabel(snapshot) { if (snapshot.stage === "Downloading") { - return "Downloading preview artifact"; + return "Downloading build"; } if (snapshot.stage === "Extracting" && snapshot.itemsLabel === "files") { - return "Extracting preview files"; + return "Extracting files"; } - return snapshot.stage ? `${snapshot.stage} progress` : "Stage progress"; + return snapshot.stage ? `${snapshot.stage} progress` : "Stage"; } function formatMessage(text) {