From a5cb1205985f4fa7cb666fa8f5b41f6c50301efe Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 30 Jun 2026 11:02:58 -0700 Subject: [PATCH 01/33] Add Vercel deployment integration Adds a preview Vercel hosting integration for Dockerfile-based deployments, including publish/deploy/destroy pipeline steps, execution configuration processing, TypeScript AppHost exports, docs, examples, and tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CommunityToolkit.Aspire.slnx | 2 + .../apphost.mts | 16 + .../aspire.config.json | 21 + .../ct-aspire-vercel-typescript/.gitignore | 1 + .../Dockerfile.vercel | 4 + .../ct-aspire-vercel-typescript/server.mjs | 15 + .../eslint.config.mjs | 18 + .../package-lock.json | 2037 +++++++++++++++++ .../package.json | 28 + .../tsconfig.json | 20 + ...munityToolkit.Aspire.Hosting.Vercel.csproj | 19 + .../README.md | 80 + .../VercelCliRunner.cs | 74 + .../VercelDeploymentAnnotation.cs | 10 + .../VercelDeploymentStep.cs | 643 ++++++ .../VercelEnvironmentOptionsAnnotation.cs | 16 + .../VercelEnvironmentResource.cs | 12 + ...celEnvironmentResourceBuilderExtensions.cs | 264 +++ .../VercelResourceExtensions.cs | 20 + ...Toolkit.Aspire.Hosting.Vercel.Tests.csproj | 17 + .../TypeScriptAppHostTests.cs | 18 + .../VercelEnvironmentTests.cs | 439 ++++ 22 files changed, 3774 insertions(+) create mode 100644 examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts create mode 100644 examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/aspire.config.json create mode 100644 examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/.gitignore create mode 100644 examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/Dockerfile.vercel create mode 100644 examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/server.mjs create mode 100644 examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/eslint.config.mjs create mode 100644 examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/package-lock.json create mode 100644 examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/package.json create mode 100644 examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/tsconfig.json create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/CommunityToolkit.Aspire.Hosting.Vercel.csproj create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/README.md create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentAnnotation.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentOptionsAnnotation.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceExtensions.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests.csproj create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/TypeScriptAppHostTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index f05008d80..1410f8102 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -281,6 +281,7 @@ + @@ -354,6 +355,7 @@ + diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts new file mode 100644 index 000000000..f193acd16 --- /dev/null +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts @@ -0,0 +1,16 @@ +import { createBuilder } from "./.aspire/modules/aspire.mjs"; + +const builder = await createBuilder(); + +const vercel = await builder.addVercelEnvironment("vercel"); +await vercel.withVercelCliPath("vercel"); +await vercel.withVercelProductionDeployments(); + +const api = await builder + .addContainer("api", "ct-aspire-vercel-typescript") + .withDockerfile("./ct-aspire-vercel-typescript", { dockerfilePath: "Dockerfile.vercel" }); + +await api.withEnvironment("GREETING", "hello-from-typescript-apphost"); +await api.publishAsVercel(vercel); + +await builder.build().run(); diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/aspire.config.json b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/aspire.config.json new file mode 100644 index 000000000..ae2d985cf --- /dev/null +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/aspire.config.json @@ -0,0 +1,21 @@ +{ + "appHost": { + "path": "apphost.mts", + "language": "typescript/nodejs" + }, + "sdk": { + "version": "13.4.3" + }, + "profiles": { + "https": { + "applicationUrl": "https://localhost:29750;http://localhost:28931", + "environmentVariables": { + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:10975", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:13319" + } + } + }, + "packages": { + "CommunityToolkit.Aspire.Hosting.Vercel": "../../../src/CommunityToolkit.Aspire.Hosting.Vercel/CommunityToolkit.Aspire.Hosting.Vercel.csproj" + } +} diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/.gitignore b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/.gitignore new file mode 100644 index 000000000..e985853ed --- /dev/null +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/.gitignore @@ -0,0 +1 @@ +.vercel diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/Dockerfile.vercel b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/Dockerfile.vercel new file mode 100644 index 000000000..6902b36a1 --- /dev/null +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/Dockerfile.vercel @@ -0,0 +1,4 @@ +FROM node:22-alpine +WORKDIR /app +COPY server.mjs . +CMD ["node", "server.mjs"] diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/server.mjs b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/server.mjs new file mode 100644 index 000000000..2d9848291 --- /dev/null +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/server.mjs @@ -0,0 +1,15 @@ +import http from "node:http"; + +const server = http.createServer((request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ + ok: true, + path: request.url, + greeting: process.env.GREETING ?? null + })); +}); + +const port = Number(process.env.PORT ?? 80); +server.listen(port, "0.0.0.0", () => { + console.log(`listening on ${port}`); +}); diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/eslint.config.mjs b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/eslint.config.mjs new file mode 100644 index 000000000..84d4644c1 --- /dev/null +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/eslint.config.mjs @@ -0,0 +1,18 @@ +// @ts-check + +import { defineConfig } from 'eslint/config'; +import tseslint from 'typescript-eslint'; + +export default defineConfig({ + files: ['apphost.mts'], + extends: [tseslint.configs.base], + languageOptions: { + parserOptions: { + projectService: true, + }, + }, + rules: { + '@typescript-eslint/no-floating-promises': ['error', { checkThenables: true }], + }, +}); + diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/package-lock.json b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/package-lock.json new file mode 100644 index 000000000..0618ca2db --- /dev/null +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/package-lock.json @@ -0,0 +1,2037 @@ +{ + "name": "communitytoolkit-aspire-hosting-vercel-apphost-typescript", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "communitytoolkit-aspire-hosting-vercel-apphost-typescript", + "version": "1.0.0", + "dependencies": { + "vscode-jsonrpc": "^8.2.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "eslint": "^10.0.3", + "nodemon": "^3.1.14", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.57.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", + "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/type-utils": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.62.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz", + "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", + "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.62.1", + "@typescript-eslint/types": "^8.62.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", + "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", + "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz", + "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", + "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", + "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.62.1", + "@typescript-eslint/tsconfig-utils": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", + "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", + "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", + "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz", + "integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.62.1", + "@typescript-eslint/parser": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/package.json b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/package.json new file mode 100644 index 000000000..c7da7e81a --- /dev/null +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/package.json @@ -0,0 +1,28 @@ +{ + "name": "communitytoolkit-aspire-hosting-vercel-apphost-typescript", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "lint": "eslint apphost.mts", + "predev": "npm run lint", + "dev": "aspire run", + "prebuild": "npm run lint", + "build": "tsc", + "watch": "tsc --watch" + }, + "dependencies": { + "vscode-jsonrpc": "^8.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "eslint": "^10.0.3", + "nodemon": "^3.1.14", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.57.1" + } +} diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/tsconfig.json b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/tsconfig.json new file mode 100644 index 000000000..e70fa1fa2 --- /dev/null +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "." + }, + "include": [ + "apphost.mts", + ".aspire/modules/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/CommunityToolkit.Aspire.Hosting.Vercel.csproj b/src/CommunityToolkit.Aspire.Hosting.Vercel/CommunityToolkit.Aspire.Hosting.Vercel.csproj new file mode 100644 index 000000000..b4d5d428a --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/CommunityToolkit.Aspire.Hosting.Vercel.csproj @@ -0,0 +1,19 @@ + + + + hosting deployment vercel dockerfile containers + An Aspire hosting integration for deploying Dockerfile-based services to Vercel. + true + $(NoWarn);ASPIREATS001 + + + + + + + + + + + + diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md new file mode 100644 index 000000000..4522c47b8 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md @@ -0,0 +1,80 @@ +# Vercel hosting integration + +Use this integration to model, configure, and deploy Dockerfile-based Aspire resources to Vercel. + +## Getting started + +Install the package in your AppHost project: + +```bash +aspire add CommunityToolkit.Aspire.Hosting.Vercel +``` + +## Usage example + +Add a `Dockerfile.vercel` file to the project you want Vercel to build, then publish it to a Vercel environment: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var vercel = builder.AddVercelEnvironment("vercel"); + +builder.AddProject("api") + .PublishAsVercel(vercel); + +builder.Build().Run(); +``` + +By default, `aspire deploy` runs: + +```bash +vercel --cwd deploy --yes +``` + +Use `WithVercelProductionDeployments` to add `--prod`, `WithVercelTarget` to add `--target`, and `WithVercelScope` to deploy to a team or account scope. + +Non-secret Aspire environment variables configured on the resource are processed during publish/deploy and passed to Vercel as CLI environment variables: + +```csharp +builder.AddProject("api") + .WithEnvironment("GREETING", "hello") + .PublishAsVercel(vercel); +``` + +```bash +vercel --cwd deploy --yes --env GREETING=hello +``` + +## Deployment behavior + +- `aspire start` is unchanged. The Vercel environment and `PublishAsVercel` configuration are publish/deploy-only. +- `aspire publish` writes a `vercel-deployments.json` plan for the targeted resources, including the environment variable names passed to Vercel. +- `aspire deploy` validates the Vercel CLI, validates authentication, checks that each source root contains the configured Dockerfile, and invokes `vercel deploy`. +- `aspire destroy` removes the Vercel projects recorded during deployment. The Aspire CLI destroy confirmation applies before the project removal step runs. + +## Prerequisites + +- A `Dockerfile.vercel` file in each targeted source root. Vercel expects the container to listen on `$PORT`; the default port is `80`. +- Vercel CLI installed and on `PATH`, or configured with `WithVercelCliPath`. +- Vercel authentication from an existing CLI login or the `VERCEL_TOKEN` environment variable. +- Any project linking, secrets, domains, deployment protection, or build-time environment variables configured in Vercel. + +## Known limitations + +This integration does not manage Vercel secrets, provision marketplace resources, or translate Aspire service discovery references. + +`aspire destroy` deletes the Vercel projects for resources targeted by this integration. Use a dedicated Vercel project for each Aspire resource, or do not run `aspire destroy` if the project contains deployments that are managed outside the Aspire AppHost. + +Secret Aspire environment variables, connection strings, Docker build arguments, and Docker build secrets are rejected. Configure those values in Vercel project settings instead, because Vercel builds `Dockerfile.vercel` itself and Vercel CLI `--env` would put secret values on the command line. + +Aspire command-line arguments and container entrypoint overrides are also rejected. Put runtime arguments or entrypoint behavior in `Dockerfile.vercel` instead. + +## Additional documentation + +- [Run any Dockerfile on Vercel](https://vercel.com/blog/dockerfile-on-vercel) +- [Vercel Container Images](https://vercel.com/docs/functions/container-images) +- [Vercel CLI deploy](https://vercel.com/docs/cli/deploy) + +## Feedback & contributing + +This integration is part of the .NET Aspire Community Toolkit. File issues and contribute at . diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs new file mode 100644 index 000000000..22285ca38 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs @@ -0,0 +1,74 @@ +using Aspire.Hosting; +using System.ComponentModel; +using System.Diagnostics; +using System.Text; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal interface IVercelCliRunner +{ + Task RunAsync(string fileName, IReadOnlyList arguments, string? workingDirectory, CancellationToken cancellationToken, string? standardInput = null); +} + +internal sealed class VercelCliRunner : IVercelCliRunner +{ + public async Task RunAsync(string fileName, IReadOnlyList arguments, string? workingDirectory, CancellationToken cancellationToken, string? standardInput = null) + { + ProcessStartInfo startInfo = new() + { + FileName = fileName, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = standardInput is not null, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8 + }; + + if (!string.IsNullOrWhiteSpace(workingDirectory)) + { + startInfo.WorkingDirectory = workingDirectory; + } + + startInfo.Environment["NO_COLOR"] = "1"; + + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + try + { + using var process = Process.Start(startInfo) + ?? throw new DistributedApplicationException($"The Vercel CLI process '{fileName}' could not be started."); + + var standardOutputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var standardErrorTask = process.StandardError.ReadToEndAsync(cancellationToken); + + if (standardInput is not null) + { + await process.StandardInput.WriteAsync(standardInput.AsMemory(), cancellationToken).ConfigureAwait(false); + process.StandardInput.Close(); + } + + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + var standardOutput = await standardOutputTask.ConfigureAwait(false); + var standardError = await standardErrorTask.ConfigureAwait(false); + + return new VercelCliResult(process.ExitCode, standardOutput, standardError); + } + catch (Win32Exception ex) + { + throw new DistributedApplicationException( + $"The Vercel CLI '{fileName}' could not be started. Install Vercel CLI from https://vercel.com/docs/cli or configure a custom path with WithVercelCliPath.", + ex); + } + } +} + +internal sealed record VercelCliResult(int ExitCode, string StandardOutput, string StandardError) +{ + public bool Succeeded => ExitCode == 0; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentAnnotation.cs new file mode 100644 index 000000000..3ff785f60 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentAnnotation.cs @@ -0,0 +1,10 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal sealed class VercelDeploymentAnnotation(string? sourceRoot, string dockerfilePath) : IResourceAnnotation +{ + public string? SourceRoot { get; } = sourceRoot; + + public string DockerfilePath { get; } = dockerfilePath; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs new file mode 100644 index 000000000..9c6422f13 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs @@ -0,0 +1,643 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES002 +#pragma warning disable ASPIREPIPELINES004 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.ApplicationModel.Docker; +using Aspire.Hosting.Pipelines; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal static class VercelDeploymentStep +{ + public const string PublishStepNamePrefix = "vercel-publish-"; + public const string DeployPrereqStepNamePrefix = "vercel-deploy-prereq-"; + public const string DeployStepNamePrefix = "vercel-deploy-"; + public const string DestroyPrereqStepNamePrefix = "vercel-destroy-prereq-"; + public const string DestroyStepNamePrefix = "vercel-destroy-"; + public const string DeploymentPlanFileName = "vercel-deployments.json"; + + private const string StateSectionNamePrefix = "communitytoolkit.vercel."; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + public static async Task WriteDeploymentPlanAsync(PipelineStepContext context, VercelEnvironmentResource environment) + { + var outputService = context.Services.GetRequiredService(); + string outputDirectory = outputService.GetOutputDirectory(environment); + + string planPath = await WriteDeploymentPlanAsync( + context.ExecutionContext, + context.Logger, + context.Model, + environment, + outputDirectory, + context.CancellationToken).ConfigureAwait(false); + + context.Summary.Add("Vercel deployment plan", planPath); + } + + internal static async Task WriteDeploymentPlanAsync( + DistributedApplicationModel model, + VercelEnvironmentResource environment, + string outputDirectory, + CancellationToken cancellationToken) + => await WriteDeploymentPlanAsync( + executionContext: null, + logger: null, + model, + environment, + outputDirectory, + cancellationToken).ConfigureAwait(false); + + internal static async Task WriteDeploymentPlanAsync( + DistributedApplicationExecutionContext? executionContext, + ILogger? logger, + DistributedApplicationModel model, + VercelEnvironmentResource environment, + string outputDirectory, + CancellationToken cancellationToken) + { + var entries = GetDeploymentEntries(model, environment).ToList(); + ValidateEntries(entries); + + Directory.CreateDirectory(outputDirectory); + var options = environment.GetVercelOptions(); + + var plan = new VercelDeploymentPlan( + environment.Name, + await CreateDeploymentPlanEntriesAsync( + executionContext, + logger, + options, + entries, + cancellationToken).ConfigureAwait(false)); + + string planPath = Path.Combine(outputDirectory, DeploymentPlanFileName); + await using FileStream stream = File.Create(planPath); + await JsonSerializer.SerializeAsync(stream, plan, JsonOptions, cancellationToken).ConfigureAwait(false); + + return planPath; + } + + public static async Task ValidatePrerequisitesAsync(PipelineStepContext context, VercelEnvironmentResource environment) + { + await ValidateCliPrerequisitesAsync(context, environment).ConfigureAwait(false); + + var entries = GetDeploymentEntries(context.Model, environment).ToList(); + ValidateEntries(entries); + } + + public static async Task ValidateCliPrerequisitesAsync(PipelineStepContext context, VercelEnvironmentResource environment) + { + var options = environment.GetVercelOptions(); + var runner = context.Services.GetRequiredService(); + + var versionResult = await runner.RunAsync(options.CliPath, ["--version"], workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + if (!versionResult.Succeeded) + { + throw CreateCliException("validate Vercel CLI installation", options.CliPath, versionResult); + } + + var whoamiResult = await runner.RunAsync(options.CliPath, ["whoami"], workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + if (!whoamiResult.Succeeded) + { + throw CreateCliException("validate Vercel authentication", options.CliPath, whoamiResult); + } + } + + public static async Task DeployAsync(PipelineStepContext context, VercelEnvironmentResource environment) + { + var options = environment.GetVercelOptions(); + var runner = context.Services.GetRequiredService(); + var entries = GetDeploymentEntries(context.Model, environment).ToList(); + + ValidateEntries(entries); + + List stateEntries = []; + + foreach (var entry in entries) + { + string[] arguments = await BuildDeployArgumentsAsync( + context.ExecutionContext, + context.Logger, + options, + entry, + context.CancellationToken).ConfigureAwait(false); + + var result = await runner.RunAsync(options.CliPath, arguments, entry.SourceRoot, context.CancellationToken).ConfigureAwait(false); + + if (!result.Succeeded) + { + throw CreateCliException($"deploy resource '{entry.Resource.Name}' to Vercel", options.CliPath, result); + } + + var deploymentResult = GetDeploymentResult(result.StandardOutput); + string projectName = GetVercelProjectName(entry); + + stateEntries.Add(new( + entry.Resource.Name, + projectName, + deploymentResult.DeploymentId, + deploymentResult.DeploymentUrl)); + + context.Summary.Add($"{entry.Resource.Name} Vercel deployment", deploymentResult.DeploymentUrl); + } + + await SaveDeploymentStateAsync(context, environment, stateEntries).ConfigureAwait(false); + } + + internal static string[] BuildDeployArguments(VercelEnvironmentOptionsAnnotation options, VercelDeploymentEntry entry) + => BuildDeployArguments(options, entry.SourceRoot, environmentVariables: []); + + internal static string[] BuildDestroyProjectArguments(VercelEnvironmentOptionsAnnotation options, string projectName) + { + List arguments = []; + + if (!string.IsNullOrWhiteSpace(options.Scope)) + { + arguments.Add("--scope"); + arguments.Add(options.Scope); + } + + arguments.Add("project"); + arguments.Add("remove"); + arguments.Add(projectName); + + return [.. arguments]; + } + + public static async Task DestroyAsync(PipelineStepContext context, VercelEnvironmentResource environment) + { + var options = environment.GetVercelOptions(); + var runner = context.Services.GetRequiredService(); + var stateManager = context.Services.GetRequiredService(); + var stateSection = await stateManager.AcquireSectionAsync(GetStateSectionName(environment), context.CancellationToken).ConfigureAwait(false); + VercelDeploymentState state = ReadDeploymentState(stateSection) ?? GetFallbackDeploymentState(context.Model, environment); + var projects = state.Deployments + .Select(static deployment => deployment.ProjectName) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + + if (projects.Length == 0) + { + context.Summary.Add("Vercel destroy", $"No Vercel deployments were found for environment '{environment.Name}'."); + return; + } + + foreach (string projectName in projects) + { + string[] arguments = BuildDestroyProjectArguments(options, projectName); + var result = await runner.RunAsync(options.CliPath, arguments, workingDirectory: null, context.CancellationToken, standardInput: "y\n").ConfigureAwait(false); + + if (!result.Succeeded) + { + throw CreateCliException($"destroy Vercel project '{projectName}'", options.CliPath, result); + } + + context.Summary.Add("Vercel project removed", projectName); + } + + await stateManager.DeleteSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); + } + + internal static async Task BuildDeployArgumentsAsync( + DistributedApplicationExecutionContext executionContext, + ILogger logger, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry, + CancellationToken cancellationToken) + { + var environmentVariables = await GetVercelEnvironmentVariablesAsync( + executionContext, + logger, + entry, + cancellationToken).ConfigureAwait(false); + + return BuildDeployArguments(options, entry.SourceRoot, environmentVariables); + } + + private static async Task CreateDeploymentPlanEntriesAsync( + DistributedApplicationExecutionContext? executionContext, + ILogger? logger, + VercelEnvironmentOptionsAnnotation options, + IReadOnlyList entries, + CancellationToken cancellationToken) + { + List planEntries = []; + + foreach (var entry in entries) + { + var environmentVariables = executionContext is null || logger is null + ? [] + : await GetVercelEnvironmentVariablesAsync(executionContext, logger, entry, cancellationToken).ConfigureAwait(false); + + planEntries.Add(new( + entry.Resource.Name, + entry.DockerfilePath, + BuildDisplayDeployCommand(options, entry.Resource.Name, environmentVariables), + [.. environmentVariables.Select(static variable => variable.Key).Order(StringComparer.Ordinal)])); + } + + return [.. planEntries]; + } + + private static async Task>> GetVercelEnvironmentVariablesAsync( + DistributedApplicationExecutionContext executionContext, + ILogger logger, + VercelDeploymentEntry entry, + CancellationToken cancellationToken) + { + var executionConfiguration = await ExecutionConfigurationBuilder + .Create(entry.Resource) + .WithEnvironmentVariablesConfig() + .WithArgumentsConfig() + .BuildAsync(executionContext, logger, cancellationToken) + .ConfigureAwait(false); + + if (executionConfiguration.Exception is not null) + { + throw new DistributedApplicationException($"Failed to process deployment configuration for resource '{entry.Resource.Name}'.", executionConfiguration.Exception); + } + + ValidateUnsupportedRuntimeConfiguration(entry.Resource, executionConfiguration); + + var environmentVariables = GetVercelEnvironmentVariables(entry.Resource, executionConfiguration); + + return environmentVariables; + } + + private static string[] BuildDeployArguments( + VercelEnvironmentOptionsAnnotation options, + string sourceRoot, + IReadOnlyList> environmentVariables) + { + List arguments = []; + + if (!string.IsNullOrWhiteSpace(options.Scope)) + { + arguments.Add("--scope"); + arguments.Add(options.Scope); + } + + arguments.Add("--cwd"); + arguments.Add(sourceRoot); + arguments.Add("deploy"); + arguments.Add("--yes"); + + if (options.Production) + { + arguments.Add("--prod"); + } + + if (!string.IsNullOrWhiteSpace(options.Target)) + { + arguments.Add("--target"); + arguments.Add(options.Target); + } + + foreach (var environmentVariable in environmentVariables.OrderBy(static variable => variable.Key, StringComparer.Ordinal)) + { + arguments.Add("--env"); + arguments.Add($"{environmentVariable.Key}={environmentVariable.Value}"); + } + + return [.. arguments]; + } + + private static string BuildDisplayDeployCommand( + VercelEnvironmentOptionsAnnotation options, + string resourceName, + IReadOnlyList> environmentVariables) + { + var displayEnvironmentVariables = environmentVariables + .Select(static environmentVariable => new KeyValuePair(environmentVariable.Key, "")) + .ToArray(); + + return $"vercel {string.Join(" ", BuildDeployArguments(options, $"<{resourceName}-source-root>", displayEnvironmentVariables))}"; + } + + private static async Task SaveDeploymentStateAsync( + PipelineStepContext context, + VercelEnvironmentResource environment, + IReadOnlyList deployments) + { + var stateManager = context.Services.GetRequiredService(); + var stateSection = await stateManager.AcquireSectionAsync(GetStateSectionName(environment), context.CancellationToken).ConfigureAwait(false); + var state = new VercelDeploymentState(environment.Name, [.. deployments]); + stateSection.SetValue(JsonSerializer.Serialize(state, JsonOptions)); + + await stateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); + } + + private static VercelDeploymentState? ReadDeploymentState(DeploymentStateSection stateSection) + { + if (!stateSection.Data.TryGetPropertyValue("value", out JsonNode? value) + || value is null) + { + return null; + } + + return value.GetValueKind() == JsonValueKind.String + ? JsonSerializer.Deserialize(value.GetValue(), JsonOptions) + : value.Deserialize(JsonOptions); + } + + private static VercelDeploymentState GetFallbackDeploymentState(DistributedApplicationModel model, VercelEnvironmentResource environment) + { + var deployments = GetDeploymentEntries(model, environment) + .Select(static entry => new VercelDeploymentStateEntry( + entry.Resource.Name, + GetVercelProjectName(entry), + DeploymentId: null, + DeploymentUrl: null)) + .ToArray(); + + return new(environment.Name, deployments); + } + + private static string GetStateSectionName(VercelEnvironmentResource environment) => $"{StateSectionNamePrefix}{environment.Name}"; + + private static IReadOnlyList> GetVercelEnvironmentVariables( + IResource resource, + IExecutionConfigurationResult executionConfiguration) + { + List> environmentVariables = []; + + foreach (var environmentVariable in executionConfiguration.EnvironmentVariablesWithUnprocessed) + { + string name = environmentVariable.Key; + object unprocessedValue = environmentVariable.Value.Item1; + string value = environmentVariable.Value.Item2; + + if (ContainsSecretReference(unprocessedValue)) + { + throw new DistributedApplicationException( + $"Environment variable '{name}' for resource '{resource.Name}' references a secret or connection string. Vercel CLI --env would pass the value on the command line, so configure this value in Vercel project environment variables or a Vercel secret instead."); + } + + environmentVariables.Add(new(name, value)); + } + + return environmentVariables; + } + + private static void ValidateUnsupportedRuntimeConfiguration( + IResource resource, + IExecutionConfigurationResult executionConfiguration) + { + if (resource is ContainerResource { Entrypoint: not null }) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures a container entrypoint, but Vercel Dockerfile deployments use the CMD/ENTRYPOINT from Dockerfile.vercel. Move the entrypoint into Dockerfile.vercel."); + } + + if (executionConfiguration.ArgumentsWithUnprocessed.Any()) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures Aspire command-line arguments, but Vercel Dockerfile deployments cannot override Docker CMD/ENTRYPOINT. Move these arguments into Dockerfile.vercel or express them as environment variables."); + } + + if (resource.TryGetLastAnnotation(out var dockerfile) + && (dockerfile.BuildArguments.Count > 0 || dockerfile.BuildSecrets.Count > 0)) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures Aspire Docker build arguments or build secrets. Vercel builds Dockerfile.vercel itself, so configure build-time values in Vercel instead."); + } + } + + private static bool ContainsSecretReference(object? value) + { + return value switch + { + null => false, + string => false, + ParameterResource parameter => parameter.Secret, + IResourceBuilder parameterBuilder => parameterBuilder.Resource.Secret, + IResourceWithConnectionString => true, + IResourceBuilder => true, + IValueWithReferences valueWithReferences => valueWithReferences.References.Any(ContainsSecretReference), + _ => IsConnectionStringResourceBuilder(value) + }; + } + + private static bool IsConnectionStringResourceBuilder(object value) + { + return value.GetType() + .GetInterfaces() + .Any(static interfaceType => + interfaceType.IsGenericType + && interfaceType.GetGenericTypeDefinition() == typeof(IResourceBuilder<>) + && typeof(IResourceWithConnectionString).IsAssignableFrom(interfaceType.GetGenericArguments()[0])); + } + + internal static IEnumerable GetDeploymentEntries(DistributedApplicationModel model, VercelEnvironmentResource environment) + { + foreach (var resource in model.Resources) + { + if (resource.GetComputeEnvironment() != environment) + { + continue; + } + + if (!resource.TryGetLastAnnotation(out var annotation)) + { + continue; + } + + string sourceRoot = ResolveSourceRoot(resource, annotation); + string dockerfilePath = annotation.DockerfilePath; + + yield return new(resource, sourceRoot, dockerfilePath); + } + } + + private static void ValidateEntries(IReadOnlyList entries) + { + if (entries.Count == 0) + { + throw new DistributedApplicationException("No resources are configured for Vercel deployment. Call PublishAsVercel on at least one project, executable, or Dockerfile container resource."); + } + + foreach (var entry in entries) + { + if (!Directory.Exists(entry.SourceRoot)) + { + throw new DistributedApplicationException($"The Vercel source root '{entry.SourceRoot}' for resource '{entry.Resource.Name}' does not exist."); + } + + string dockerfilePath = Path.Combine(entry.SourceRoot, entry.DockerfilePath); + if (!File.Exists(dockerfilePath)) + { + throw new DistributedApplicationException($"The Vercel Dockerfile '{dockerfilePath}' for resource '{entry.Resource.Name}' does not exist. Add a Dockerfile.vercel file to the source root or pass a custom dockerfilePath to PublishAsVercel."); + } + } + } + + private static string ResolveSourceRoot(IResource resource, VercelDeploymentAnnotation annotation) + { + if (annotation.SourceRoot is { } sourceRoot) + { + return sourceRoot; + } + + if (resource is ProjectResource project) + { + string projectPath = project.GetProjectMetadata().ProjectPath; + return Path.GetDirectoryName(projectPath) + ?? throw new DistributedApplicationException($"The project path '{projectPath}' for resource '{resource.Name}' does not have a parent directory."); + } + + if (resource is ExecutableResource executable) + { + return executable.WorkingDirectory; + } + + if (resource.TryGetLastAnnotation(out var dockerfile)) + { + return dockerfile.ContextPath; + } + + throw new DistributedApplicationException($"Resource '{resource.Name}' does not have a Vercel source root. Pass sourceRoot to PublishAsVercel or configure the container resource with WithDockerfile."); + } + + internal static string GetVercelProjectName(VercelDeploymentEntry entry) + { + string projectJsonPath = Path.Combine(entry.SourceRoot, ".vercel", "project.json"); + + if (File.Exists(projectJsonPath)) + { + using var document = JsonDocument.Parse(File.ReadAllText(projectJsonPath)); + + if (document.RootElement.TryGetProperty("projectName", out var projectName) + && projectName.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(projectName.GetString())) + { + return projectName.GetString()!; + } + } + + string sourceRoot = Path.TrimEndingDirectorySeparator(entry.SourceRoot); + string fallbackProjectName = Path.GetFileName(sourceRoot); + if (string.IsNullOrWhiteSpace(fallbackProjectName)) + { + throw new DistributedApplicationException($"Could not infer the Vercel project name for resource '{entry.Resource.Name}' from source root '{entry.SourceRoot}'."); + } + + return fallbackProjectName; + } + + internal static string GetDeploymentUrl(string standardOutput) + => GetDeploymentResult(standardOutput).DeploymentUrl; + + internal static VercelDeploymentResult GetDeploymentResult(string standardOutput) + { + if (TryGetJsonDeploymentResult(standardOutput) is { } jsonDeploymentResult) + { + return jsonDeploymentResult; + } + + string[] lines = standardOutput + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + string deploymentUrl = lines.LastOrDefault(static line => Uri.TryCreate(line, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http") + ?? standardOutput.Trim(); + + return new(DeploymentId: null, deploymentUrl); + } + + private static VercelDeploymentResult? TryGetJsonDeploymentResult(string standardOutput) + { + if (!standardOutput.AsSpan().TrimStart().StartsWith("{", StringComparison.Ordinal)) + { + return null; + } + + try + { + using var document = JsonDocument.Parse(standardOutput); + var root = document.RootElement; + + if (TryGetDeploymentResult(root, out var deploymentResult)) + { + return deploymentResult; + } + } + catch (JsonException) + { + // Older Vercel CLI output is plain text; fall back to line-based URL extraction. + } + + return null; + } + + private static bool TryGetDeploymentResult(JsonElement root, [NotNullWhen(true)] out VercelDeploymentResult? deploymentResult) + { + if (root.TryGetProperty("deployment", out var deployment) + && deployment.TryGetProperty("url", out var nestedUrl) + && TryGetHttpUrl(nestedUrl, out var nestedDeploymentUrl)) + { + string? deploymentId = deployment.TryGetProperty("id", out var nestedId) && nestedId.ValueKind == JsonValueKind.String + ? nestedId.GetString() + : null; + + deploymentResult = new(deploymentId, nestedDeploymentUrl); + return true; + } + + if (root.TryGetProperty("url", out var url) + && TryGetHttpUrl(url, out var rootDeploymentUrl)) + { + string? deploymentId = root.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String + ? id.GetString() + : null; + + deploymentResult = new(deploymentId, rootDeploymentUrl); + return true; + } + + deploymentResult = null; + return false; + } + + private static bool TryGetHttpUrl(JsonElement urlElement, [NotNullWhen(true)] out string? url) + { + url = urlElement.ValueKind == JsonValueKind.String + ? urlElement.GetString() + : null; + + return Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http"; + } + + private static DistributedApplicationException CreateCliException(string operation, string cliPath, VercelCliResult result) + { + string output = string.IsNullOrWhiteSpace(result.StandardError) + ? result.StandardOutput.Trim() + : result.StandardError.Trim(); + + return new DistributedApplicationException($"Failed to {operation} using '{cliPath}' (exit code {result.ExitCode}). {output}"); + } +} + +internal sealed record VercelDeploymentEntry(IResource Resource, string SourceRoot, string DockerfilePath); + +internal sealed record VercelDeploymentPlan(string Environment, VercelDeploymentPlanEntry[] Deployments); + +internal sealed record VercelDeploymentPlanEntry(string ResourceName, string DockerfilePath, string DeployCommand, string[] EnvironmentVariables); + +internal sealed record VercelDeploymentResult(string? DeploymentId, string DeploymentUrl); + +internal sealed record VercelDeploymentState(string Environment, VercelDeploymentStateEntry[] Deployments); + +internal sealed record VercelDeploymentStateEntry(string ResourceName, string ProjectName, string? DeploymentId, string? DeploymentUrl); diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentOptionsAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentOptionsAnnotation.cs new file mode 100644 index 000000000..11b1480f4 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentOptionsAnnotation.cs @@ -0,0 +1,16 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal sealed record VercelEnvironmentOptionsAnnotation : IResourceAnnotation +{ + public const string DefaultCliPath = "vercel"; + + public string CliPath { get; init; } = DefaultCliPath; + + public string? Scope { get; init; } + + public string? Target { get; init; } + + public bool Production { get; init; } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs new file mode 100644 index 000000000..cd2ec795a --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs @@ -0,0 +1,12 @@ +using Aspire.Hosting.ApplicationModel; +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a Vercel deployment target for Dockerfile-based services. +/// +/// The Aspire resource name for the Vercel environment. +[Experimental("CTASPIREVERCEL001")] +[AspireExport(ExposeProperties = true)] +public sealed class VercelEnvironmentResource(string name) : Resource(name), IComputeEnvironmentResource; diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs new file mode 100644 index 000000000..b88924782 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs @@ -0,0 +1,264 @@ +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; +using CommunityToolkit.Aspire.Hosting.Vercel; +using Microsoft.Extensions.DependencyInjection.Extensions; +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for deploying Dockerfile-based resources to Vercel. +/// +[Experimental("CTASPIREVERCEL001")] +public static class VercelEnvironmentResourceBuilderExtensions +{ + private const string DefaultDockerfilePath = "Dockerfile.vercel"; + + /// + /// Adds a publish/deploy-only Vercel environment resource. + /// + /// The distributed application builder. + /// The Aspire resource name for the Vercel environment. + /// A resource builder for the Vercel environment. + /// + /// The Vercel environment is not added to the local run model. During publish and deploy it validates Dockerfile-based + /// resources and, during deploy, invokes the Vercel CLI using the current login or VERCEL_TOKEN. + /// + [AspireExport] + public static IResourceBuilder AddVercelEnvironment( + this IDistributedApplicationBuilder builder, + [ResourceName] string name) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + builder.Services.TryAddSingleton(); + + var resource = new VercelEnvironmentResource(name); + var resourceBuilder = builder.ExecutionContext.IsRunMode + ? builder.CreateResourceBuilder(resource) + : builder.AddResource(resource); + + return resourceBuilder + .WithAnnotation(new VercelEnvironmentOptionsAnnotation(), ResourceAnnotationMutationBehavior.Replace) + .WithPipelineStepFactory(_ => + [ + new PipelineStep + { + Name = $"{VercelDeploymentStep.PublishStepNamePrefix}{resource.Name}", + Description = $"Generate Vercel deployment plan for '{resource.Name}'.", + Resource = resource, + RequiredBySteps = [WellKnownPipelineSteps.Publish, WellKnownPipelineSteps.Deploy], + Action = context => VercelDeploymentStep.WriteDeploymentPlanAsync(context, resource) + }, + new PipelineStep + { + Name = $"{VercelDeploymentStep.DeployPrereqStepNamePrefix}{resource.Name}", + Description = $"Validate Vercel CLI prerequisites for '{resource.Name}'.", + Resource = resource, + RequiredBySteps = [WellKnownPipelineSteps.Deploy], + Action = context => VercelDeploymentStep.ValidatePrerequisitesAsync(context, resource) + }, + new PipelineStep + { + Name = $"{VercelDeploymentStep.DeployStepNamePrefix}{resource.Name}", + Description = $"Deploy resources to Vercel environment '{resource.Name}'.", + Resource = resource, + DependsOnSteps = [$"{VercelDeploymentStep.DeployPrereqStepNamePrefix}{resource.Name}"], + RequiredBySteps = [WellKnownPipelineSteps.Deploy], + Action = context => VercelDeploymentStep.DeployAsync(context, resource) + }, + new PipelineStep + { + Name = $"{VercelDeploymentStep.DestroyPrereqStepNamePrefix}{resource.Name}", + Description = $"Validate Vercel CLI prerequisites for destroying '{resource.Name}'.", + Resource = resource, + RequiredBySteps = [WellKnownPipelineSteps.Destroy], + Action = context => VercelDeploymentStep.ValidateCliPrerequisitesAsync(context, resource) + }, + new PipelineStep + { + Name = $"{VercelDeploymentStep.DestroyStepNamePrefix}{resource.Name}", + Description = $"Destroy Vercel resources for environment '{resource.Name}'.", + Resource = resource, + DependsOnSteps = [$"{VercelDeploymentStep.DestroyPrereqStepNamePrefix}{resource.Name}"], + RequiredBySteps = [WellKnownPipelineSteps.Destroy], + Action = context => VercelDeploymentStep.DestroyAsync(context, resource) + } + ]); + } + + /// + /// Configures the Vercel CLI executable path used by deploy and destroy pipeline steps. + /// + /// The Vercel environment builder. + /// The executable name or absolute path for the Vercel CLI. + /// The Vercel environment builder. + [AspireExport] + public static IResourceBuilder WithVercelCliPath( + this IResourceBuilder builder, + string cliPath) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(cliPath); + + return builder.WithVercelOptions(options => options with { CliPath = cliPath }); + } + + /// + /// Configures the Vercel team or account scope for deployments. + /// + /// The Vercel environment builder. + /// The Vercel scope passed to vercel deploy --scope. + /// The Vercel environment builder. + [AspireExport] + public static IResourceBuilder WithVercelScope( + this IResourceBuilder builder, + string scope) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(scope); + + return builder.WithVercelOptions(options => options with { Scope = scope }); + } + + /// + /// Configures deployments to target Vercel production. + /// + /// The Vercel environment builder. + /// The Vercel environment builder. + /// This adds --prod to Vercel CLI deployments and clears any custom target configured with . + [AspireExport] + public static IResourceBuilder WithVercelProductionDeployments( + this IResourceBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.WithVercelOptions(options => options with { Production = true, Target = null }); + } + + /// + /// Configures deployments to use a Vercel target environment. + /// + /// The Vercel environment builder. + /// The target value passed to vercel deploy --target, such as preview or a custom environment. + /// The Vercel environment builder. + /// This clears production deployment mode configured with . + [AspireExport] + public static IResourceBuilder WithVercelTarget( + this IResourceBuilder builder, + string target) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(target); + + return builder.WithVercelOptions(options => options with { Production = false, Target = target }); + } + + /// + /// Configures a .NET project resource to deploy to Vercel using a Dockerfile at the project root. + /// + /// The project resource builder. + /// The Vercel environment builder. + /// The Dockerfile path relative to the project root. Defaults to Dockerfile.vercel. + /// The project resource builder. + [AspireExport("publishProjectAsVercel", MethodName = "publishAsVercel")] + public static IResourceBuilder PublishAsVercel( + this IResourceBuilder builder, + IResourceBuilder environment, + string? dockerfilePath = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(environment); + + if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + return builder; + } + + return builder.WithVercelDeployment(environment, sourceRoot: null, dockerfilePath); + } + + /// + /// Configures an executable resource to deploy to Vercel using a Dockerfile at the executable working directory. + /// + /// The executable resource builder. + /// The Vercel environment builder. + /// The Dockerfile path relative to the executable working directory. Defaults to Dockerfile.vercel. + /// The executable resource builder. + [AspireExport("publishExecutableAsVercel", MethodName = "publishAsVercel")] + public static IResourceBuilder PublishAsVercel( + this IResourceBuilder builder, + IResourceBuilder environment, + string? dockerfilePath = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(environment); + + if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + return builder; + } + + return builder.WithVercelDeployment(environment, sourceRoot: null, dockerfilePath); + } + + /// + /// Configures a container resource to deploy to Vercel using a Dockerfile-based source root. + /// + /// The container resource builder. + /// The Vercel environment builder. + /// The source root passed to vercel deploy --cwd. Defaults to the container Dockerfile build context. + /// The Dockerfile path relative to the source root. Defaults to Dockerfile.vercel. + /// The container resource builder. + [AspireExport("publishContainerAsVercel", MethodName = "publishAsVercel")] + public static IResourceBuilder PublishAsVercel( + this IResourceBuilder builder, + IResourceBuilder environment, + string? sourceRoot = null, + string? dockerfilePath = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(environment); + + if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + return builder; + } + + return builder.WithVercelDeployment(environment, sourceRoot, dockerfilePath); + } + + private static IResourceBuilder WithVercelDeployment( + this IResourceBuilder builder, + IResourceBuilder environment, + string? sourceRoot, + string? dockerfilePath) + where T : IComputeResource + { + string? normalizedSourceRoot = sourceRoot is null + ? null + : ResolvePath(builder.ApplicationBuilder, sourceRoot); + + string normalizedDockerfilePath = string.IsNullOrWhiteSpace(dockerfilePath) + ? DefaultDockerfilePath + : dockerfilePath; + + return builder + .WithComputeEnvironment(environment) + .WithAnnotation(new VercelDeploymentAnnotation(normalizedSourceRoot, normalizedDockerfilePath), ResourceAnnotationMutationBehavior.Replace); + } + + private static IResourceBuilder WithVercelOptions( + this IResourceBuilder builder, + Func configure) + { + var current = builder.Resource.GetVercelOptions(); + var updated = configure(current); + + return builder.WithAnnotation(updated, ResourceAnnotationMutationBehavior.Replace); + } + + private static string ResolvePath(IDistributedApplicationBuilder builder, string path) => + Path.GetFullPath(Path.IsPathRooted(path) ? path : Path.Combine(builder.AppHostDirectory, path)); +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceExtensions.cs new file mode 100644 index 000000000..28da9087a --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceExtensions.cs @@ -0,0 +1,20 @@ +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal static class VercelResourceExtensions +{ + [AspireExportIgnore(Reason = "Internal Vercel annotation access is not part of the generated AppHost API.")] + public static VercelEnvironmentOptionsAnnotation GetVercelOptions(this VercelEnvironmentResource resource) + { + if (resource.TryGetLastAnnotation(out var options)) + { + return options; + } + + return new VercelEnvironmentOptionsAnnotation(); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests.csproj new file mode 100644 index 000000000..efeb47ca9 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests.csproj @@ -0,0 +1,17 @@ + + + + false + true + + + + + + + + + + + + diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/TypeScriptAppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/TypeScriptAppHostTests.cs new file mode 100644 index 000000000..144faa5d6 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/TypeScriptAppHostTests.cs @@ -0,0 +1,18 @@ +using CommunityToolkit.Aspire.Testing; + +namespace CommunityToolkit.Aspire.Hosting.Vercel.Tests; + +public class TypeScriptAppHostTests +{ + [Fact] + public async Task TypeScriptAppHostCompilesAndStarts() + { + await TypeScriptAppHostTest.Run( + appHostProject: "CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript", + packageName: "CommunityToolkit.Aspire.Hosting.Vercel", + exampleName: "vercel", + waitForResources: [], + cancellationToken: TestContext.Current.CancellationToken); + } +} + diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs new file mode 100644 index 000000000..308ef4e6a --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs @@ -0,0 +1,439 @@ +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Vercel; +using Microsoft.Extensions.Logging.Abstractions; +using System.Text.Json; + +namespace CommunityToolkit.Aspire.Hosting.Vercel.Tests; + +public class VercelEnvironmentTests +{ + [Fact] + public void AddVercelEnvironmentShouldThrowWhenBuilderIsNull() + { + IDistributedApplicationBuilder builder = null!; + + var action = () => builder.AddVercelEnvironment("vercel"); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void AddVercelEnvironmentShouldThrowWhenNameIsNull() + { + IDistributedApplicationBuilder builder = new DistributedApplicationBuilder([]); + string name = null!; + + var action = () => builder.AddVercelEnvironment(name); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(name), exception.ParamName); + } + + [Fact] + public void RunModeDoesNotAddVercelEnvironmentOrDeploymentAnnotation() + { + var builder = DistributedApplication.CreateBuilder(); + + var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api").PublishAsVercel(vercel); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var api = Assert.Single(model.Resources.OfType()); + + Assert.DoesNotContain(model.Resources, resource => resource is VercelEnvironmentResource); + Assert.False(api.TryGetLastAnnotation(out _)); + } + + [Fact] + public void PublishModeAddsVercelEnvironmentAndDeploymentAnnotation() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelScope("team") + .WithVercelTarget("preview"); + + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .PublishAsVercel(vercel); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var api = Assert.Single(model.Resources.OfType()); + + Assert.Same(environment, api.GetComputeEnvironment()); + Assert.True(api.TryGetLastAnnotation(out var deployment)); + Assert.Equal("Dockerfile.vercel", deployment.DockerfilePath); + + var options = environment.GetVercelOptions(); + Assert.Equal("team", options.Scope); + Assert.Equal("preview", options.Target); + Assert.False(options.Production); + } + + [Fact] + public async Task WriteDeploymentPlanWritesExpectedJson() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .PublishAsVercel(vercel); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + string planPath = await VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken); + + Assert.Equal(Path.Combine(outputRoot.Path, VercelDeploymentStep.DeploymentPlanFileName), planPath); + using var document = JsonDocument.Parse(File.ReadAllText(planPath)); + var root = document.RootElement; + Assert.Equal("vercel", root.GetProperty("environment").GetString()); + var deployment = Assert.Single(root.GetProperty("deployments").EnumerateArray()); + Assert.Equal("api", deployment.GetProperty("resourceName").GetString()); + Assert.Equal("Dockerfile.vercel", deployment.GetProperty("dockerfilePath").GetString()); + Assert.Equal("vercel --cwd deploy --yes", deployment.GetProperty("deployCommand").GetString()); + } + + [Fact] + public async Task WriteDeploymentPlanProcessesEnvironmentVariables() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .WithEnvironment("GREETING", "hello") + .PublishAsVercel(vercel); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + string planPath = await VercelDeploymentStep.WriteDeploymentPlanAsync( + builder.ExecutionContext, + NullLogger.Instance, + model, + environment, + outputRoot.Path, + TestContext.Current.CancellationToken); + + using var document = JsonDocument.Parse(File.ReadAllText(planPath)); + var deployment = Assert.Single(document.RootElement.GetProperty("deployments").EnumerateArray()); + + Assert.Equal("vercel --cwd deploy --yes --env GREETING=", deployment.GetProperty("deployCommand").GetString()); + Assert.Equal("GREETING", Assert.Single(deployment.GetProperty("environmentVariables").EnumerateArray()).GetString()); + Assert.DoesNotContain("hello", File.ReadAllText(planPath)); + } + + [Fact] + public async Task WriteDeploymentPlanThrowsWhenDockerfileIsMissing() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .PublishAsVercel(vercel); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken)); + + Assert.Contains("Dockerfile.vercel", exception.Message); + } + + [Fact] + public void BuildDeployArgumentsIncludesConfiguredOptions() + { + var options = new VercelEnvironmentOptionsAnnotation + { + Production = true, + Scope = "team" + }; + var entry = new VercelDeploymentEntry(new ContainerResource("api"), "/repo/src/api", "Dockerfile.vercel"); + + string[] arguments = VercelDeploymentStep.BuildDeployArguments(options, entry); + + Assert.Equal(["--scope", "team", "--cwd", "/repo/src/api", "deploy", "--yes", "--prod"], arguments); + } + + [Fact] + public void BuildDestroyProjectArgumentsIncludesConfiguredOptions() + { + var options = new VercelEnvironmentOptionsAnnotation + { + Scope = "team" + }; + + string[] arguments = VercelDeploymentStep.BuildDestroyProjectArguments(options, "api"); + + Assert.Equal(["--scope", "team", "project", "remove", "api"], arguments); + } + + [Fact] + public async Task BuildDeployArgumentsProcessesEnvironmentVariables() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .WithEnvironment("GREETING", "hello") + .PublishAsVercel(vercel); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + + string[] arguments = await VercelDeploymentStep.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken); + + Assert.Contains("--env", arguments); + Assert.Contains("GREETING=hello", arguments); + } + + [Fact] + public async Task BuildDeployArgumentsThrowsForSecretEnvironmentVariables() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var secret = builder.AddParameter("api-key", "secret-value", secret: true); + var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .WithEnvironment("API_KEY", secret) + .PublishAsVercel(vercel); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken)); + + Assert.Contains("API_KEY", exception.Message); + } + + [Fact] + public async Task BuildDeployArgumentsThrowsForConnectionStringEnvironmentVariables() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var connectionString = builder.AddConnectionString("db"); + var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .WithEnvironment("DATABASE_URL", connectionString) + .PublishAsVercel(vercel); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken)); + + Assert.Contains("DATABASE_URL", exception.Message); + } + + [Fact] + public async Task BuildDeployArgumentsThrowsForCommandLineArguments() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .WithArgs("--verbose") + .PublishAsVercel(vercel); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken)); + + Assert.Contains("command-line arguments", exception.Message); + } + + [Fact] + public async Task BuildDeployArgumentsThrowsForDockerBuildArguments() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .WithBuildArg("FOO", "bar") + .PublishAsVercel(vercel); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken)); + + Assert.Contains("build arguments", exception.Message); + } + + [Fact] + public void GetDeploymentUrlReturnsPlainUrl() + { + string url = VercelDeploymentStep.GetDeploymentUrl(""" + Vercel CLI 54.18.6 + https://example.vercel.app + """); + + Assert.Equal("https://example.vercel.app", url); + } + + [Fact] + public void GetDeploymentUrlReturnsJsonDeploymentUrl() + { + string url = VercelDeploymentStep.GetDeploymentUrl(""" + { + "status": "ok", + "deployment": { + "id": "dpl_123", + "url": "https://example-json.vercel.app", + "readyState": "READY" + } + } + """); + + Assert.Equal("https://example-json.vercel.app", url); + } + + [Fact] + public void GetDeploymentResultReturnsJsonDeploymentId() + { + var result = VercelDeploymentStep.GetDeploymentResult(""" + { + "status": "ok", + "deployment": { + "id": "dpl_123", + "url": "https://example-json.vercel.app", + "readyState": "READY" + } + } + """); + + Assert.Equal("dpl_123", result.DeploymentId); + Assert.Equal("https://example-json.vercel.app", result.DeploymentUrl); + } + + [Fact] + public void GetVercelProjectNameReadsVercelProjectLink() + { + using var sourceRoot = TemporaryDirectory.Create(); + string vercelDirectory = Path.Combine(sourceRoot.Path, ".vercel"); + Directory.CreateDirectory(vercelDirectory); + File.WriteAllText(Path.Combine(vercelDirectory, "project.json"), """{"projectName":"linked-project"}"""); + var entry = new VercelDeploymentEntry(new ContainerResource("api"), sourceRoot.Path, "Dockerfile.vercel"); + + string projectName = VercelDeploymentStep.GetVercelProjectName(entry); + + Assert.Equal("linked-project", projectName); + } + + [Fact] + public void GetVercelProjectNameFallsBackToSourceRootName() + { + using var sourceRoot = TemporaryDirectory.Create("fallback-project"); + var entry = new VercelDeploymentEntry(new ContainerResource("api"), sourceRoot.Path, "Dockerfile.vercel"); + + string projectName = VercelDeploymentStep.GetVercelProjectName(entry); + + Assert.Equal("fallback-project", projectName); + } + + private sealed class TemporaryDirectory : IDisposable + { + private TemporaryDirectory(string path) + { + Path = path; + } + + public string Path { get; } + + public static TemporaryDirectory Create(string? directoryName = null) + { + string path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), directoryName ?? $"vercel-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + + return new(path); + } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + } +} From 7791adb730f1c8ebbf26310049ec4d4846e342b9 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 30 Jun 2026 12:26:40 -0700 Subject: [PATCH 02/33] Improve Vercel coverage Adds focused unit coverage for Vercel pipeline step registration, deploy and destroy flows, CLI prerequisite handling, deployment state fallback, and API overloads. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../VercelEnvironmentTests.cs | 788 ++++++++++++++++++ 1 file changed, 788 insertions(+) diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs index 308ef4e6a..fbf4f1fa1 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs @@ -1,10 +1,17 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES002 +#pragma warning disable ASPIREPIPELINES004 #pragma warning disable CTASPIREVERCEL001 using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; using CommunityToolkit.Aspire.Hosting.Vercel; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using System.Text.Json; +using System.Text.Json.Nodes; namespace CommunityToolkit.Aspire.Hosting.Vercel.Tests; @@ -80,6 +87,174 @@ public void PublishModeAddsVercelEnvironmentAndDeploymentAnnotation() Assert.False(options.Production); } + [Fact] + public void VercelEnvironmentOptionsCanBeConfigured() + { + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelCliPath("vercel-test") + .WithVercelTarget("preview") + .WithVercelProductionDeployments() + .WithVercelTarget("staging"); + + var options = vercel.Resource.GetVercelOptions(); + Assert.Equal("vercel-test", options.CliPath); + Assert.Equal("staging", options.Target); + Assert.False(options.Production); + } + + [Fact] + public async Task AddVercelEnvironmentRegistersExpectedPipelineSteps() + { + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(new FakeVercelCliRunner()); + builder.Services.AddSingleton(new FakeDeploymentStateManager()); + var vercel = builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var stepContext = CreatePipelineStepContext(builder, app); + var annotation = Assert.Single(environment.Annotations.OfType()); + + var steps = (await annotation.CreateStepsAsync(new() + { + PipelineContext = stepContext.PipelineContext, + Resource = environment + })).ToArray(); + + Assert.Collection( + steps, + step => + { + Assert.Equal("vercel-publish-vercel", step.Name); + Assert.Same(vercel.Resource, step.Resource); + Assert.Equal([WellKnownPipelineSteps.Publish, WellKnownPipelineSteps.Deploy], step.RequiredBySteps); + }, + step => + { + Assert.Equal("vercel-deploy-prereq-vercel", step.Name); + Assert.Same(vercel.Resource, step.Resource); + Assert.Equal([WellKnownPipelineSteps.Deploy], step.RequiredBySteps); + }, + step => + { + Assert.Equal("vercel-deploy-vercel", step.Name); + Assert.Same(vercel.Resource, step.Resource); + Assert.Equal(["vercel-deploy-prereq-vercel"], step.DependsOnSteps); + Assert.Equal([WellKnownPipelineSteps.Deploy], step.RequiredBySteps); + }, + step => + { + Assert.Equal("vercel-destroy-prereq-vercel", step.Name); + Assert.Same(vercel.Resource, step.Resource); + Assert.Equal([WellKnownPipelineSteps.Destroy], step.RequiredBySteps); + }, + step => + { + Assert.Equal("vercel-destroy-vercel", step.Name); + Assert.Same(vercel.Resource, step.Resource); + Assert.Equal(["vercel-destroy-prereq-vercel"], step.DependsOnSteps); + Assert.Equal([WellKnownPipelineSteps.Destroy], step.RequiredBySteps); + }); + } + + [Fact] + public void WithVercelCliPathShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + var action = () => builder.WithVercelCliPath("vercel"); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void WithVercelCliPathShouldThrowWhenCliPathIsEmpty() + { + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + var vercel = builder.AddVercelEnvironment("vercel"); + + var action = () => vercel.WithVercelCliPath(""); + + var exception = Assert.Throws(action); + Assert.Equal("cliPath", exception.ParamName); + } + + [Fact] + public void WithVercelProductionDeploymentsShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + var action = () => builder.WithVercelProductionDeployments(); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void PublishProjectAsVercelAddsDeploymentAnnotationInPublishMode() + { + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + var vercel = builder.AddVercelEnvironment("vercel"); + + var project = builder.AddResource(new ProjectResource("api")) + .PublishAsVercel(vercel, "Dockerfile.custom"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var api = Assert.Single(model.Resources.OfType()); + + Assert.Same(project.Resource, api); + Assert.Same(vercel.Resource, api.GetComputeEnvironment()); + Assert.True(api.TryGetLastAnnotation(out var annotation)); + Assert.Null(annotation.SourceRoot); + Assert.Equal("Dockerfile.custom", annotation.DockerfilePath); + } + + [Fact] + public void PublishExecutableAsVercelAddsDeploymentAnnotationInPublishMode() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + var vercel = builder.AddVercelEnvironment("vercel"); + + builder.AddExecutable("api", "node", sourceRoot.Path) + .PublishAsVercel(vercel); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + Assert.IsType(entry.Resource); + Assert.Equal(sourceRoot.Path, entry.SourceRoot); + Assert.Equal("Dockerfile.vercel", entry.DockerfilePath); + } + + [Fact] + public void PublishContainerAsVercelResolvesExplicitSourceRootAndDockerfile() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.custom"), "FROM nginx:alpine"); + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + var vercel = builder.AddVercelEnvironment("vercel"); + + builder.AddContainer("api", "api") + .PublishAsVercel(vercel, sourceRoot.Path, "Dockerfile.custom"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + Assert.Equal(sourceRoot.Path, entry.SourceRoot); + Assert.Equal("Dockerfile.custom", entry.DockerfilePath); + } + [Fact] public async Task WriteDeploymentPlanWritesExpectedJson() { @@ -109,6 +284,33 @@ public async Task WriteDeploymentPlanWritesExpectedJson() Assert.Equal("vercel --cwd deploy --yes", deployment.GetProperty("deployCommand").GetString()); } + [Fact] + public async Task WriteDeploymentPlanPipelineStepWritesSummary() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + var outputService = new FakePipelineOutputService(outputRoot.Path); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(outputService); + var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .PublishAsVercel(vercel); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.WriteDeploymentPlanAsync(context, environment); + + var summary = Assert.Single(context.Summary.Items); + Assert.Equal("Vercel deployment plan", summary.Key); + Assert.Equal(Path.Combine(outputRoot.Path, VercelDeploymentStep.DeploymentPlanFileName), summary.Value); + } + [Fact] public async Task WriteDeploymentPlanProcessesEnvironmentVariables() { @@ -165,6 +367,48 @@ public async Task WriteDeploymentPlanThrowsWhenDockerfileIsMissing() Assert.Contains("Dockerfile.vercel", exception.Message); } + [Fact] + public async Task WriteDeploymentPlanThrowsWhenSourceRootIsMissing() + { + using var outputRoot = TemporaryDirectory.Create(); + string sourceRoot = Path.Combine(Path.GetTempPath(), $"missing-vercel-tests-{Guid.NewGuid():N}"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .PublishAsVercel(vercel, sourceRoot); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken)); + + Assert.Contains(sourceRoot, exception.Message); + Assert.Contains("does not exist", exception.Message); + } + + [Fact] + public async Task WriteDeploymentPlanThrowsWhenContainerHasNoSourceRoot() + { + using var outputRoot = TemporaryDirectory.Create(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .PublishAsVercel(vercel); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken)); + + Assert.Contains("does not have a Vercel source root", exception.Message); + } + [Fact] public void BuildDeployArgumentsIncludesConfiguredOptions() { @@ -180,6 +424,20 @@ public void BuildDeployArgumentsIncludesConfiguredOptions() Assert.Equal(["--scope", "team", "--cwd", "/repo/src/api", "deploy", "--yes", "--prod"], arguments); } + [Fact] + public void BuildDeployArgumentsIncludesTarget() + { + var options = new VercelEnvironmentOptionsAnnotation + { + Target = "preview" + }; + var entry = new VercelDeploymentEntry(new ContainerResource("api"), "/repo/src/api", "Dockerfile.vercel"); + + string[] arguments = VercelDeploymentStep.BuildDeployArguments(options, entry); + + Assert.Equal(["--cwd", "/repo/src/api", "deploy", "--yes", "--target", "preview"], arguments); + } + [Fact] public void BuildDestroyProjectArgumentsIncludesConfiguredOptions() { @@ -222,6 +480,35 @@ public async Task BuildDeployArgumentsProcessesEnvironmentVariables() Assert.Contains("GREETING=hello", arguments); } + [Fact] + public async Task BuildDeployArgumentsThrowsForContainerEntrypoint() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var vercel = builder.AddVercelEnvironment("vercel"); + var api = builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .PublishAsVercel(vercel); + api.Resource.Entrypoint = "node"; + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken)); + + Assert.Contains("entrypoint", exception.Message); + } + [Fact] public async Task BuildDeployArgumentsThrowsForSecretEnvironmentVariables() { @@ -340,6 +627,290 @@ public async Task BuildDeployArgumentsThrowsForDockerBuildArguments() Assert.Contains("build arguments", exception.Message); } + [Fact] + public async Task DeployAsyncRunsVercelCliAndSavesDeploymentState() + { + using var sourceRoot = TemporaryDirectory.Create("vercel-state-project"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner(new VercelCliResult(0, """ + { + "deployment": { + "id": "dpl_123", + "url": "https://api.vercel.app" + } + } + """, "")); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelCliPath("vercel-test") + .WithVercelScope("team"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .WithEnvironment("GREETING", "hello") + .PublishAsVercel(vercel); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + var invocation = Assert.Single(runner.Invocations); + Assert.Equal("vercel-test", invocation.FileName); + Assert.Equal(sourceRoot.Path, invocation.WorkingDirectory); + Assert.Equal(["--scope", "team", "--cwd", sourceRoot.Path, "deploy", "--yes", "--env", "GREETING=hello"], invocation.Arguments); + Assert.Null(invocation.StandardInput); + + var summary = Assert.Single(context.Summary.Items); + Assert.Equal("api Vercel deployment", summary.Key); + Assert.Equal("https://api.vercel.app", summary.Value); + + var savedSection = Assert.Single(stateManager.SavedSections); + Assert.Equal("communitytoolkit.vercel.vercel", savedSection.SectionName); + string stateJson = savedSection.Data.ToJsonString(); + Assert.Contains("vercel-state-project", stateJson); + Assert.Contains("dpl_123", stateJson); + } + + [Fact] + public async Task DeployAsyncThrowsWhenVercelCliFails() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner(new VercelCliResult(1, "ignored stdout", "deploy failed")); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeDeploymentStateManager()); + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelCliPath("vercel-test"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .PublishAsVercel(vercel); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.DeployAsync(context, environment)); + + Assert.Contains("Failed to deploy resource 'api' to Vercel using 'vercel-test' (exit code 1). deploy failed", exception.Message); + } + + [Fact] + public async Task ValidateCliPrerequisitesRunsVersionAndWhoami() + { + var runner = new FakeVercelCliRunner( + new(0, "54.18.6", ""), + new(0, "davidfowl-6717", "")); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelCliPath("vercel-test"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.ValidateCliPrerequisitesAsync(context, environment); + + Assert.Collection( + runner.Invocations, + invocation => Assert.Equal(["--version"], invocation.Arguments), + invocation => Assert.Equal(["whoami"], invocation.Arguments)); + } + + [Fact] + public async Task ValidateCliPrerequisitesThrowsWhenWhoamiFails() + { + var runner = new FakeVercelCliRunner( + new(0, "54.18.6", ""), + new(1, "", "not logged in")); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelCliPath("vercel-test"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.ValidateCliPrerequisitesAsync(context, environment)); + + Assert.Contains("Failed to validate Vercel authentication using 'vercel-test' (exit code 1). not logged in", exception.Message); + } + + [Fact] + public async Task ValidateCliPrerequisitesThrowsWhenVersionFailsWithStandardOutput() + { + var runner = new FakeVercelCliRunner(new VercelCliResult(1, "missing vercel", "")); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelCliPath("vercel-test"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.ValidateCliPrerequisitesAsync(context, environment)); + + Assert.Contains("Failed to validate Vercel CLI installation using 'vercel-test' (exit code 1). missing vercel", exception.Message); + } + + [Fact] + public async Task ValidatePrerequisitesThrowsWhenNoResourcesArePublished() + { + var runner = new FakeVercelCliRunner( + new(0, "54.18.6", ""), + new(0, "davidfowl-6717", "")); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + var vercel = builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.ValidatePrerequisitesAsync(context, environment)); + + Assert.Contains("No resources are configured for Vercel deployment", exception.Message); + } + + [Fact] + public async Task DestroyAsyncDeletesProjectsFromSavedDeploymentState() + { + var runner = new FakeVercelCliRunner( + new(0, "", ""), + new(0, "", "")); + var stateManager = new FakeDeploymentStateManager(); + stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( + "vercel", + [ + new("api", "z-project", "dpl_1", "https://z-project.vercel.app"), + new("worker", "a-project", null, null), + new("api2", "z-project", null, null) + ]))); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelCliPath("vercel-test") + .WithVercelScope("team"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DestroyAsync(context, environment); + + Assert.Collection( + runner.Invocations, + invocation => + { + Assert.Equal(["--scope", "team", "project", "remove", "a-project"], invocation.Arguments); + Assert.Equal("y\n", invocation.StandardInput); + }, + invocation => + { + Assert.Equal(["--scope", "team", "project", "remove", "z-project"], invocation.Arguments); + Assert.Equal("y\n", invocation.StandardInput); + }); + Assert.Single(stateManager.DeletedSections); + } + + [Fact] + public async Task DestroyAsyncFallsBackToConfiguredDeploymentsWhenStateIsMissing() + { + using var sourceRoot = TemporaryDirectory.Create("fallback-project"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner(new VercelCliResult(0, "", "")); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeDeploymentStateManager()); + var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .PublishAsVercel(vercel); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DestroyAsync(context, environment); + + var invocation = Assert.Single(runner.Invocations); + Assert.Equal(["project", "remove", "fallback-project"], invocation.Arguments); + } + + [Fact] + public async Task DestroyAsyncAddsSummaryWhenNoDeploymentsExist() + { + var runner = new FakeVercelCliRunner(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeDeploymentStateManager()); + var vercel = builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DestroyAsync(context, environment); + + Assert.Empty(runner.Invocations); + var summary = Assert.Single(context.Summary.Items); + Assert.Equal("Vercel destroy", summary.Key); + Assert.Contains("No Vercel deployments", summary.Value); + } + + [Fact] + public async Task VercelCliRunnerRunsProcessAndCapturesOutput() + { + var runner = new VercelCliRunner(); + + var result = await runner.RunAsync("dotnet", ["--version"], Directory.GetCurrentDirectory(), TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal(0, result.ExitCode); + Assert.False(string.IsNullOrWhiteSpace(result.StandardOutput)); + } + + [Fact] + public async Task VercelCliRunnerThrowsWhenProcessCannotStart() + { + var runner = new VercelCliRunner(); + + var exception = await Assert.ThrowsAsync(() => + runner.RunAsync($"missing-vercel-cli-{Guid.NewGuid():N}", [], workingDirectory: null, TestContext.Current.CancellationToken)); + + Assert.Contains("could not be started", exception.Message); + } + [Fact] public void GetDeploymentUrlReturnsPlainUrl() { @@ -368,6 +939,55 @@ public void GetDeploymentUrlReturnsJsonDeploymentUrl() Assert.Equal("https://example-json.vercel.app", url); } + [Fact] + public void GetDeploymentResultReturnsRootJsonDeploymentUrl() + { + var result = VercelDeploymentStep.GetDeploymentResult(""" + { + "id": "dpl_root", + "url": "https://root-json.vercel.app" + } + """); + + Assert.Equal("dpl_root", result.DeploymentId); + Assert.Equal("https://root-json.vercel.app", result.DeploymentUrl); + } + + [Fact] + public void GetDeploymentResultFallsBackWhenJsonIsInvalid() + { + var result = VercelDeploymentStep.GetDeploymentResult(""" + { + "deployment": + } + https://fallback.vercel.app + """); + + Assert.Null(result.DeploymentId); + Assert.Equal("https://fallback.vercel.app", result.DeploymentUrl); + } + + [Fact] + public void GetDeploymentResultFallsBackWhenJsonHasNoUrl() + { + var result = VercelDeploymentStep.GetDeploymentResult(""" + { + "deployment": { + "id": "dpl_no_url" + } + } + """); + + Assert.Null(result.DeploymentId); + Assert.Equal(""" + { + "deployment": { + "id": "dpl_no_url" + } + } + """.Trim(), result.DeploymentUrl); + } + [Fact] public void GetDeploymentResultReturnsJsonDeploymentId() { @@ -411,6 +1031,38 @@ public void GetVercelProjectNameFallsBackToSourceRootName() Assert.Equal("fallback-project", projectName); } + [Fact] + public void GetVercelOptionsReturnsDefaultOptionsWithoutAnnotation() + { + var resource = new VercelEnvironmentResource("vercel"); + + var options = resource.GetVercelOptions(); + + Assert.Equal("vercel", options.CliPath); + Assert.Null(options.Scope); + Assert.Null(options.Target); + Assert.False(options.Production); + } + + private static PipelineStepContext CreatePipelineStepContext( + IDistributedApplicationBuilder builder, + DistributedApplication app) + { + var model = app.Services.GetRequiredService(); + var pipelineContext = new PipelineContext( + model, + builder.ExecutionContext, + app.Services, + NullLogger.Instance, + TestContext.Current.CancellationToken); + + return new() + { + PipelineContext = pipelineContext, + ReportingStep = NoopReportingStep.Instance + }; + } + private sealed class TemporaryDirectory : IDisposable { private TemporaryDirectory(string path) @@ -436,4 +1088,140 @@ public void Dispose() } } } + + private sealed class FakeVercelCliRunner(params VercelCliResult[] results) : IVercelCliRunner + { + private readonly Queue _results = new(results); + + public List Invocations { get; } = []; + + public Task RunAsync( + string fileName, + IReadOnlyList arguments, + string? workingDirectory, + CancellationToken cancellationToken, + string? standardInput = null) + { + Invocations.Add(new(fileName, [.. arguments], workingDirectory, standardInput)); + + var result = _results.Count > 0 + ? _results.Dequeue() + : new VercelCliResult(0, "", ""); + + return Task.FromResult(result); + } + } + + private sealed record VercelCliInvocation( + string FileName, + string[] Arguments, + string? WorkingDirectory, + string? StandardInput); + + private sealed class FakeDeploymentStateManager : IDeploymentStateManager + { + private readonly Dictionary _sections = new(StringComparer.Ordinal); + + public string? StateFilePath => null; + + public List SavedSections { get; } = []; + + public List DeletedSections { get; } = []; + + public void SetSection(string sectionName, string value) + => _sections[sectionName] = new(sectionName, new JsonObject { ["value"] = value }, 0); + + public Task AcquireSectionAsync(string sectionName, CancellationToken cancellationToken) + { + if (!_sections.TryGetValue(sectionName, out var section)) + { + section = new(sectionName, new JsonObject(), 0); + _sections.Add(sectionName, section); + } + + return Task.FromResult(section); + } + + public Task SaveSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken) + { + SavedSections.Add(section); + _sections[section.SectionName] = section; + return Task.CompletedTask; + } + + public Task DeleteSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken) + { + DeletedSections.Add(section); + _sections.Remove(section.SectionName); + return Task.CompletedTask; + } + + public Task ClearAllStateAsync(CancellationToken cancellationToken) + { + _sections.Clear(); + return Task.CompletedTask; + } + } + + private sealed class FakePipelineOutputService(string outputDirectory) : IPipelineOutputService + { + public string GetOutputDirectory() => outputDirectory; + + public string GetOutputDirectory(IResource resource) => outputDirectory; + + public string GetTempDirectory() => outputDirectory; + + public string GetTempDirectory(IResource resource) => outputDirectory; + } + + private sealed class NoopReportingStep : IReportingStep + { + public static NoopReportingStep Instance { get; } = new(); + + public Task CreateTaskAsync(MarkdownString statusText, CancellationToken cancellationToken = default) + => Task.FromResult(NoopReportingTask.Instance); + + public Task CreateTaskAsync(string statusText, CancellationToken cancellationToken = default) + => Task.FromResult(NoopReportingTask.Instance); + + public Task CompleteAsync(MarkdownString completionText, CompletionState completionState = CompletionState.Completed, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task CompleteAsync(string completionText, CompletionState completionState = CompletionState.Completed, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public void Log(LogLevel logLevel, MarkdownString message) + { + } + + public void Log(LogLevel logLevel, string message) + { + } + + [Obsolete("Use Log(LogLevel, string) or Log(LogLevel, MarkdownString) instead.")] + public void Log(LogLevel logLevel, string message, bool enableMarkdown) + { + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + private sealed class NoopReportingTask : IReportingTask + { + public static NoopReportingTask Instance { get; } = new(); + + public Task UpdateAsync(MarkdownString statusText, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task UpdateAsync(string statusText, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task CompleteAsync(MarkdownString completionMessage, CompletionState completionState = CompletionState.Completed, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task CompleteAsync(string? completionMessage = null, CompletionState completionState = CompletionState.Completed, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } } From 1e699753ccae31425c1adbcbd53770937e7f3c14 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 30 Jun 2026 12:54:31 -0700 Subject: [PATCH 03/33] Use Aspire Dockerfile metadata for Vercel Refactor Vercel deployments to consume Aspire Dockerfile build annotations instead of Vercel-specific source root and Dockerfile settings. Stage generated or non-default Dockerfiles as Dockerfile before invoking the Vercel CLI, and update tests/docs/examples for the new model. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../apphost.mts | 2 +- .../{Dockerfile.vercel => Dockerfile} | 0 .../README.md | 21 +- .../VercelDeploymentAnnotation.cs | 7 +- .../VercelDeploymentStep.cs | 130 +++++++-- ...celEnvironmentResourceBuilderExtensions.cs | 45 +--- .../VercelEnvironmentTests.cs | 252 ++++++++++++++---- 7 files changed, 332 insertions(+), 125 deletions(-) rename examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/{Dockerfile.vercel => Dockerfile} (100%) diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts index f193acd16..0833df4e8 100644 --- a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts @@ -8,7 +8,7 @@ await vercel.withVercelProductionDeployments(); const api = await builder .addContainer("api", "ct-aspire-vercel-typescript") - .withDockerfile("./ct-aspire-vercel-typescript", { dockerfilePath: "Dockerfile.vercel" }); + .withDockerfile("./ct-aspire-vercel-typescript"); await api.withEnvironment("GREETING", "hello-from-typescript-apphost"); await api.publishAsVercel(vercel); diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/Dockerfile.vercel b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/Dockerfile similarity index 100% rename from examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/Dockerfile.vercel rename to examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/Dockerfile diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md index 4522c47b8..ed326dc3e 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md @@ -12,7 +12,7 @@ aspire add CommunityToolkit.Aspire.Hosting.Vercel ## Usage example -Add a `Dockerfile.vercel` file to the project you want Vercel to build, then publish it to a Vercel environment: +Add a Vercel environment and publish the project to it. Project and executable resources use Aspire's existing Dockerfile publishing support, so the Dockerfile that Aspire would publish is the Dockerfile Vercel builds: ```csharp var builder = DistributedApplication.CreateBuilder(args); @@ -33,6 +33,16 @@ vercel --cwd deploy --yes Use `WithVercelProductionDeployments` to add `--prod`, `WithVercelTarget` to add `--target`, and `WithVercelScope` to deploy to a team or account scope. +For existing container resources, configure Aspire Dockerfile build metadata before publishing to Vercel: + +```csharp +builder.AddContainer("web", "web") + .WithDockerfile("../web") + .PublishAsVercel(vercel); +``` + +`WithDockerfile`, `WithDockerfileFactory`, and `WithDockerfileBuilder` are supported. If Aspire generates the Dockerfile, or if the configured Dockerfile is not named `Dockerfile` in the context root, the integration stages the source root and materializes the Vercel-facing `Dockerfile` before running `vercel deploy`. + Non-secret Aspire environment variables configured on the resource are processed during publish/deploy and passed to Vercel as CLI environment variables: ```csharp @@ -49,12 +59,13 @@ vercel --cwd deploy --yes --env GREETING=hello - `aspire start` is unchanged. The Vercel environment and `PublishAsVercel` configuration are publish/deploy-only. - `aspire publish` writes a `vercel-deployments.json` plan for the targeted resources, including the environment variable names passed to Vercel. -- `aspire deploy` validates the Vercel CLI, validates authentication, checks that each source root contains the configured Dockerfile, and invokes `vercel deploy`. +- `aspire deploy` validates the Vercel CLI, validates authentication, materializes any Aspire-generated Dockerfile, and invokes `vercel deploy`. - `aspire destroy` removes the Vercel projects recorded during deployment. The Aspire CLI destroy confirmation applies before the project removal step runs. ## Prerequisites -- A `Dockerfile.vercel` file in each targeted source root. Vercel expects the container to listen on `$PORT`; the default port is `80`. +- A Dockerfile-backed Aspire resource. Projects and executables can use `PublishAsVercel` directly. Existing container resources must be configured with `WithDockerfile`, `WithDockerfileFactory`, or `WithDockerfileBuilder` before `PublishAsVercel`. +- The deployed container should listen on `$PORT`; the default port is `80`. - Vercel CLI installed and on `PATH`, or configured with `WithVercelCliPath`. - Vercel authentication from an existing CLI login or the `VERCEL_TOKEN` environment variable. - Any project linking, secrets, domains, deployment protection, or build-time environment variables configured in Vercel. @@ -65,9 +76,9 @@ This integration does not manage Vercel secrets, provision marketplace resources `aspire destroy` deletes the Vercel projects for resources targeted by this integration. Use a dedicated Vercel project for each Aspire resource, or do not run `aspire destroy` if the project contains deployments that are managed outside the Aspire AppHost. -Secret Aspire environment variables, connection strings, Docker build arguments, and Docker build secrets are rejected. Configure those values in Vercel project settings instead, because Vercel builds `Dockerfile.vercel` itself and Vercel CLI `--env` would put secret values on the command line. +Secret Aspire environment variables, connection strings, Docker build arguments, and Docker build secrets are rejected. Configure those values in Vercel project settings instead, because Vercel builds `Dockerfile` itself and Vercel CLI `--env` would put secret values on the command line. -Aspire command-line arguments and container entrypoint overrides are also rejected. Put runtime arguments or entrypoint behavior in `Dockerfile.vercel` instead. +Aspire command-line arguments and container entrypoint overrides are also rejected. Put runtime arguments or entrypoint behavior in `Dockerfile` instead. ## Additional documentation diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentAnnotation.cs index 3ff785f60..0e85183d8 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentAnnotation.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentAnnotation.cs @@ -2,9 +2,4 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; -internal sealed class VercelDeploymentAnnotation(string? sourceRoot, string dockerfilePath) : IResourceAnnotation -{ - public string? SourceRoot { get; } = sourceRoot; - - public string DockerfilePath { get; } = dockerfilePath; -} +internal sealed class VercelDeploymentAnnotation : IResourceAnnotation; diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs index 9c6422f13..3a367d121 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs @@ -128,14 +128,15 @@ public static async Task DeployAsync(PipelineStepContext context, VercelEnvironm foreach (var entry in entries) { + var preparedEntry = await PrepareDeploymentEntryAsync(context, entry).ConfigureAwait(false); string[] arguments = await BuildDeployArgumentsAsync( context.ExecutionContext, context.Logger, options, - entry, + preparedEntry, context.CancellationToken).ConfigureAwait(false); - var result = await runner.RunAsync(options.CliPath, arguments, entry.SourceRoot, context.CancellationToken).ConfigureAwait(false); + var result = await runner.RunAsync(options.CliPath, arguments, preparedEntry.SourceRoot, context.CancellationToken).ConfigureAwait(false); if (!result.Succeeded) { @@ -143,7 +144,7 @@ public static async Task DeployAsync(PipelineStepContext context, VercelEnvironm } var deploymentResult = GetDeploymentResult(result.StandardOutput); - string projectName = GetVercelProjectName(entry); + string projectName = GetVercelProjectName(preparedEntry); stateEntries.Add(new( entry.Resource.Name, @@ -245,7 +246,7 @@ private static async Task CreateDeploymentPlanEntri planEntries.Add(new( entry.Resource.Name, - entry.DockerfilePath, + GetDisplayDockerfilePath(entry), BuildDisplayDeployCommand(options, entry.Resource.Name, environmentVariables), [.. environmentVariables.Select(static variable => variable.Key).Order(StringComparer.Ordinal)])); } @@ -400,20 +401,20 @@ private static void ValidateUnsupportedRuntimeConfiguration( if (resource is ContainerResource { Entrypoint: not null }) { throw new DistributedApplicationException( - $"Resource '{resource.Name}' configures a container entrypoint, but Vercel Dockerfile deployments use the CMD/ENTRYPOINT from Dockerfile.vercel. Move the entrypoint into Dockerfile.vercel."); + $"Resource '{resource.Name}' configures a container entrypoint, but Vercel Dockerfile deployments use the CMD/ENTRYPOINT from the Dockerfile. Move the entrypoint into the Dockerfile."); } if (executionConfiguration.ArgumentsWithUnprocessed.Any()) { throw new DistributedApplicationException( - $"Resource '{resource.Name}' configures Aspire command-line arguments, but Vercel Dockerfile deployments cannot override Docker CMD/ENTRYPOINT. Move these arguments into Dockerfile.vercel or express them as environment variables."); + $"Resource '{resource.Name}' configures Aspire command-line arguments, but Vercel Dockerfile deployments cannot override Docker CMD/ENTRYPOINT. Move these arguments into the Dockerfile or express them as environment variables."); } if (resource.TryGetLastAnnotation(out var dockerfile) && (dockerfile.BuildArguments.Count > 0 || dockerfile.BuildSecrets.Count > 0)) { throw new DistributedApplicationException( - $"Resource '{resource.Name}' configures Aspire Docker build arguments or build secrets. Vercel builds Dockerfile.vercel itself, so configure build-time values in Vercel instead."); + $"Resource '{resource.Name}' configures Aspire Docker build arguments or build secrets. Vercel builds the Dockerfile itself, so configure build-time values in Vercel instead."); } } @@ -451,15 +452,17 @@ internal static IEnumerable GetDeploymentEntries(Distribu continue; } - if (!resource.TryGetLastAnnotation(out var annotation)) + if (!resource.TryGetLastAnnotation(out _)) { continue; } - string sourceRoot = ResolveSourceRoot(resource, annotation); - string dockerfilePath = annotation.DockerfilePath; + if (!resource.TryGetLastAnnotation(out var dockerfile)) + { + throw new DistributedApplicationException($"Resource '{resource.Name}' is published to Vercel but does not have Aspire Dockerfile build metadata. Configure the resource with PublishAsDockerFile, WithDockerfile, WithDockerfileFactory, or WithDockerfileBuilder before calling PublishAsVercel."); + } - yield return new(resource, sourceRoot, dockerfilePath); + yield return new(resource, dockerfile.ContextPath, dockerfile.DockerfilePath, dockerfile); } } @@ -477,41 +480,114 @@ private static void ValidateEntries(IReadOnlyList entries throw new DistributedApplicationException($"The Vercel source root '{entry.SourceRoot}' for resource '{entry.Resource.Name}' does not exist."); } - string dockerfilePath = Path.Combine(entry.SourceRoot, entry.DockerfilePath); - if (!File.Exists(dockerfilePath)) + if (entry.Dockerfile.DockerfileFactory is null && !File.Exists(entry.DockerfilePath)) { - throw new DistributedApplicationException($"The Vercel Dockerfile '{dockerfilePath}' for resource '{entry.Resource.Name}' does not exist. Add a Dockerfile.vercel file to the source root or pass a custom dockerfilePath to PublishAsVercel."); + throw new DistributedApplicationException($"The Vercel Dockerfile '{entry.DockerfilePath}' for resource '{entry.Resource.Name}' does not exist. Configure the resource with an existing Dockerfile or an Aspire generated Dockerfile before calling PublishAsVercel."); } } } - private static string ResolveSourceRoot(IResource resource, VercelDeploymentAnnotation annotation) + private static async Task PrepareDeploymentEntryAsync(PipelineStepContext context, VercelDeploymentEntry entry) + { + if (!RequiresStaging(entry)) + { + return entry; + } + + var outputService = context.Services.GetRequiredService(); + string stagingRoot = GetStagingSourceRoot(outputService.GetTempDirectory(entry.Resource), entry); + + if (Directory.Exists(stagingRoot)) + { + Directory.Delete(stagingRoot, recursive: true); + } + + CopyDirectory(entry.SourceRoot, stagingRoot, context.CancellationToken); + + string stagedDockerfilePath = Path.Combine(stagingRoot, "Dockerfile"); + DockerfileFactoryContext dockerfileContext = new() + { + CancellationToken = context.CancellationToken, + Resource = entry.Resource, + Services = context.Services + }; + + await entry.Dockerfile.EmitDockerfileArtifactsAsync(dockerfileContext, stagedDockerfilePath).ConfigureAwait(false); + + return entry with + { + SourceRoot = stagingRoot, + DockerfilePath = stagedDockerfilePath + }; + } + + private static bool RequiresStaging(VercelDeploymentEntry entry) { - if (annotation.SourceRoot is { } sourceRoot) + if (entry.Dockerfile.DockerfileFactory is not null) { - return sourceRoot; + return true; } - if (resource is ProjectResource project) + string dockerfileDirectory = Path.GetDirectoryName(Path.GetFullPath(entry.DockerfilePath)) ?? string.Empty; + + return !PathEquals(dockerfileDirectory, entry.SourceRoot) + || !string.Equals(Path.GetFileName(entry.DockerfilePath), "Dockerfile", GetPathStringComparison()); + } + + private static string GetDisplayDockerfilePath(VercelDeploymentEntry entry) + { + if (RequiresStaging(entry)) { - string projectPath = project.GetProjectMetadata().ProjectPath; - return Path.GetDirectoryName(projectPath) - ?? throw new DistributedApplicationException($"The project path '{projectPath}' for resource '{resource.Name}' does not have a parent directory."); + return "Dockerfile"; } - if (resource is ExecutableResource executable) + return Path.GetRelativePath(entry.SourceRoot, entry.DockerfilePath); + } + + private static string GetStagingSourceRoot(string tempDirectory, VercelDeploymentEntry entry) + { + string sourceRoot = Path.TrimEndingDirectorySeparator(entry.SourceRoot); + string sourceRootName = Path.GetFileName(sourceRoot); + + if (string.IsNullOrWhiteSpace(sourceRootName)) { - return executable.WorkingDirectory; + sourceRootName = entry.Resource.Name; } - if (resource.TryGetLastAnnotation(out var dockerfile)) + return Path.Combine(tempDirectory, sourceRootName); + } + + private static void CopyDirectory(string sourceDirectory, string destinationDirectory, CancellationToken cancellationToken) + { + Directory.CreateDirectory(destinationDirectory); + + foreach (string directory in Directory.EnumerateDirectories(sourceDirectory, "*", SearchOption.AllDirectories)) { - return dockerfile.ContextPath; + cancellationToken.ThrowIfCancellationRequested(); + + string relativePath = Path.GetRelativePath(sourceDirectory, directory); + Directory.CreateDirectory(Path.Combine(destinationDirectory, relativePath)); } - throw new DistributedApplicationException($"Resource '{resource.Name}' does not have a Vercel source root. Pass sourceRoot to PublishAsVercel or configure the container resource with WithDockerfile."); + foreach (string file in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)) + { + cancellationToken.ThrowIfCancellationRequested(); + + string relativePath = Path.GetRelativePath(sourceDirectory, file); + string destinationFile = Path.Combine(destinationDirectory, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(destinationFile)!); + File.Copy(file, destinationFile, overwrite: true); + } } + private static bool PathEquals(string path, string otherPath) + => string.Equals(Path.GetFullPath(path), Path.GetFullPath(otherPath), GetPathStringComparison()); + + private static StringComparison GetPathStringComparison() + => OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + internal static string GetVercelProjectName(VercelDeploymentEntry entry) { string projectJsonPath = Path.Combine(entry.SourceRoot, ".vercel", "project.json"); @@ -630,7 +706,7 @@ private static DistributedApplicationException CreateCliException(string operati } } -internal sealed record VercelDeploymentEntry(IResource Resource, string SourceRoot, string DockerfilePath); +internal sealed record VercelDeploymentEntry(IResource Resource, string SourceRoot, string DockerfilePath, DockerfileBuildAnnotation Dockerfile); internal sealed record VercelDeploymentPlan(string Environment, VercelDeploymentPlanEntry[] Deployments); diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs index b88924782..9fd975141 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs @@ -12,8 +12,6 @@ namespace Aspire.Hosting; [Experimental("CTASPIREVERCEL001")] public static class VercelEnvironmentResourceBuilderExtensions { - private const string DefaultDockerfilePath = "Dockerfile.vercel"; - /// /// Adds a publish/deploy-only Vercel environment resource. /// @@ -156,17 +154,15 @@ public static IResourceBuilder WithVercelTarget( } /// - /// Configures a .NET project resource to deploy to Vercel using a Dockerfile at the project root. + /// Configures a .NET project resource to deploy to Vercel using Aspire's Dockerfile publishing support. /// /// The project resource builder. /// The Vercel environment builder. - /// The Dockerfile path relative to the project root. Defaults to Dockerfile.vercel. /// The project resource builder. [AspireExport("publishProjectAsVercel", MethodName = "publishAsVercel")] public static IResourceBuilder PublishAsVercel( this IResourceBuilder builder, - IResourceBuilder environment, - string? dockerfilePath = null) + IResourceBuilder environment) { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(environment); @@ -176,21 +172,19 @@ public static IResourceBuilder PublishAsVercel( return builder; } - return builder.WithVercelDeployment(environment, sourceRoot: null, dockerfilePath); + return builder.PublishAsDockerFile(container => container.WithVercelDeployment(environment)); } /// - /// Configures an executable resource to deploy to Vercel using a Dockerfile at the executable working directory. + /// Configures an executable resource to deploy to Vercel using Aspire's Dockerfile publishing support. /// /// The executable resource builder. /// The Vercel environment builder. - /// The Dockerfile path relative to the executable working directory. Defaults to Dockerfile.vercel. /// The executable resource builder. [AspireExport("publishExecutableAsVercel", MethodName = "publishAsVercel")] public static IResourceBuilder PublishAsVercel( this IResourceBuilder builder, - IResourceBuilder environment, - string? dockerfilePath = null) + IResourceBuilder environment) { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(environment); @@ -200,23 +194,20 @@ public static IResourceBuilder PublishAsVercel( return builder; } - return builder.WithVercelDeployment(environment, sourceRoot: null, dockerfilePath); + return builder.PublishAsDockerFile(container => container.WithVercelDeployment(environment)); } /// - /// Configures a container resource to deploy to Vercel using a Dockerfile-based source root. + /// Configures a container resource to deploy to Vercel using its Aspire Dockerfile build metadata. /// /// The container resource builder. /// The Vercel environment builder. - /// The source root passed to vercel deploy --cwd. Defaults to the container Dockerfile build context. - /// The Dockerfile path relative to the source root. Defaults to Dockerfile.vercel. /// The container resource builder. + /// Configure the container with WithDockerfile, WithDockerfileFactory, or WithDockerfileBuilder before publishing it to Vercel. [AspireExport("publishContainerAsVercel", MethodName = "publishAsVercel")] public static IResourceBuilder PublishAsVercel( this IResourceBuilder builder, - IResourceBuilder environment, - string? sourceRoot = null, - string? dockerfilePath = null) + IResourceBuilder environment) { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(environment); @@ -226,27 +217,17 @@ public static IResourceBuilder PublishAsVercel( return builder; } - return builder.WithVercelDeployment(environment, sourceRoot, dockerfilePath); + return builder.WithVercelDeployment(environment); } private static IResourceBuilder WithVercelDeployment( this IResourceBuilder builder, - IResourceBuilder environment, - string? sourceRoot, - string? dockerfilePath) + IResourceBuilder environment) where T : IComputeResource { - string? normalizedSourceRoot = sourceRoot is null - ? null - : ResolvePath(builder.ApplicationBuilder, sourceRoot); - - string normalizedDockerfilePath = string.IsNullOrWhiteSpace(dockerfilePath) - ? DefaultDockerfilePath - : dockerfilePath; - return builder .WithComputeEnvironment(environment) - .WithAnnotation(new VercelDeploymentAnnotation(normalizedSourceRoot, normalizedDockerfilePath), ResourceAnnotationMutationBehavior.Replace); + .WithAnnotation(new VercelDeploymentAnnotation(), ResourceAnnotationMutationBehavior.Replace); } private static IResourceBuilder WithVercelOptions( @@ -259,6 +240,4 @@ private static IResourceBuilder WithVercelOptions( return builder.WithAnnotation(updated, ResourceAnnotationMutationBehavior.Replace); } - private static string ResolvePath(IDistributedApplicationBuilder builder, string path) => - Path.GetFullPath(Path.IsPathRooted(path) ? path : Path.Combine(builder.AppHostDirectory, path)); } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs index fbf4f1fa1..982790e36 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs @@ -56,11 +56,46 @@ public void RunModeDoesNotAddVercelEnvironmentOrDeploymentAnnotation() Assert.False(api.TryGetLastAnnotation(out _)); } + [Fact] + public void RunModeProjectPublishAsVercelLeavesProjectResourceUnchanged() + { + var builder = DistributedApplication.CreateBuilder(); + var vercel = builder.AddVercelEnvironment("vercel"); + var projectResource = new ProjectResource("api"); + + builder.AddResource(projectResource) + .PublishAsVercel(vercel); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var api = Assert.Single(model.Resources.OfType()); + + Assert.Same(projectResource, api); + Assert.False(api.TryGetLastAnnotation(out _)); + } + + [Fact] + public void RunModeExecutablePublishAsVercelLeavesExecutableResourceUnchanged() + { + using var sourceRoot = TemporaryDirectory.Create(); + var builder = DistributedApplication.CreateBuilder(); + var vercel = builder.AddVercelEnvironment("vercel"); + + builder.AddExecutable("api", "node", sourceRoot.Path) + .PublishAsVercel(vercel); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var api = Assert.Single(model.Resources.OfType()); + + Assert.False(api.TryGetLastAnnotation(out _)); + } + [Fact] public void PublishModeAddsVercelEnvironmentAndDeploymentAnnotation() { using var sourceRoot = TemporaryDirectory.Create(); - File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); @@ -69,7 +104,7 @@ public void PublishModeAddsVercelEnvironmentAndDeploymentAnnotation() .WithVercelTarget("preview"); builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .WithDockerfile(sourceRoot.Path, "Dockerfile") .PublishAsVercel(vercel); using var app = builder.Build(); @@ -78,8 +113,10 @@ public void PublishModeAddsVercelEnvironmentAndDeploymentAnnotation() var api = Assert.Single(model.Resources.OfType()); Assert.Same(environment, api.GetComputeEnvironment()); - Assert.True(api.TryGetLastAnnotation(out var deployment)); - Assert.Equal("Dockerfile.vercel", deployment.DockerfilePath); + Assert.True(api.TryGetLastAnnotation(out _)); + Assert.True(api.TryGetLastAnnotation(out var dockerfile)); + Assert.Equal(sourceRoot.Path, dockerfile.ContextPath); + Assert.Equal(Path.Combine(sourceRoot.Path, "Dockerfile"), dockerfile.DockerfilePath); var options = environment.GetVercelOptions(); Assert.Equal("team", options.Scope); @@ -197,28 +234,34 @@ public void WithVercelProductionDeploymentsShouldThrowWhenBuilderIsNull() [Fact] public void PublishProjectAsVercelAddsDeploymentAnnotationInPublishMode() { + using var sourceRoot = TemporaryDirectory.Create(); + string projectPath = Path.Combine(sourceRoot.Path, "Api.csproj"); + File.WriteAllText(projectPath, ""); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM mcr.microsoft.com/dotnet/aspnet:10.0"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); var vercel = builder.AddVercelEnvironment("vercel"); + var projectResource = new ProjectResource("api"); + var project = builder.AddResource(projectResource) + .WithAnnotation(new FakeProjectMetadata(projectPath)); - var project = builder.AddResource(new ProjectResource("api")) - .PublishAsVercel(vercel, "Dockerfile.custom"); + project.PublishAsVercel(vercel); using var app = builder.Build(); var model = app.Services.GetRequiredService(); - var api = Assert.Single(model.Resources.OfType()); + var api = Assert.Single(model.Resources.OfType(), resource => resource.Name == "api"); - Assert.Same(project.Resource, api); Assert.Same(vercel.Resource, api.GetComputeEnvironment()); - Assert.True(api.TryGetLastAnnotation(out var annotation)); - Assert.Null(annotation.SourceRoot); - Assert.Equal("Dockerfile.custom", annotation.DockerfilePath); + Assert.True(api.TryGetLastAnnotation(out _)); + Assert.True(api.TryGetLastAnnotation(out var dockerfile)); + Assert.Equal(sourceRoot.Path, dockerfile.ContextPath); + Assert.Equal(Path.Combine(sourceRoot.Path, "Dockerfile"), dockerfile.DockerfilePath); } [Fact] public void PublishExecutableAsVercelAddsDeploymentAnnotationInPublishMode() { using var sourceRoot = TemporaryDirectory.Create(); - File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); var vercel = builder.AddVercelEnvironment("vercel"); @@ -230,13 +273,13 @@ public void PublishExecutableAsVercelAddsDeploymentAnnotationInPublishMode() var environment = Assert.Single(model.Resources.OfType()); var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); - Assert.IsType(entry.Resource); + Assert.IsAssignableFrom(entry.Resource); Assert.Equal(sourceRoot.Path, entry.SourceRoot); - Assert.Equal("Dockerfile.vercel", entry.DockerfilePath); + Assert.Equal(Path.Combine(sourceRoot.Path, "Dockerfile"), entry.DockerfilePath); } [Fact] - public void PublishContainerAsVercelResolvesExplicitSourceRootAndDockerfile() + public void PublishContainerAsVercelUsesDockerfileAnnotation() { using var sourceRoot = TemporaryDirectory.Create(); File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.custom"), "FROM nginx:alpine"); @@ -244,7 +287,8 @@ public void PublishContainerAsVercelResolvesExplicitSourceRootAndDockerfile() var vercel = builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") - .PublishAsVercel(vercel, sourceRoot.Path, "Dockerfile.custom"); + .WithDockerfile(sourceRoot.Path, "Dockerfile.custom") + .PublishAsVercel(vercel); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -252,7 +296,7 @@ public void PublishContainerAsVercelResolvesExplicitSourceRootAndDockerfile() var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); Assert.Equal(sourceRoot.Path, entry.SourceRoot); - Assert.Equal("Dockerfile.custom", entry.DockerfilePath); + Assert.Equal(Path.Combine(sourceRoot.Path, "Dockerfile.custom"), entry.DockerfilePath); } [Fact] @@ -260,12 +304,12 @@ public async Task WriteDeploymentPlanWritesExpectedJson() { using var sourceRoot = TemporaryDirectory.Create(); using var outputRoot = TemporaryDirectory.Create(); - File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); var vercel = builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .WithDockerfile(sourceRoot.Path, "Dockerfile") .PublishAsVercel(vercel); using var app = builder.Build(); @@ -280,7 +324,7 @@ public async Task WriteDeploymentPlanWritesExpectedJson() Assert.Equal("vercel", root.GetProperty("environment").GetString()); var deployment = Assert.Single(root.GetProperty("deployments").EnumerateArray()); Assert.Equal("api", deployment.GetProperty("resourceName").GetString()); - Assert.Equal("Dockerfile.vercel", deployment.GetProperty("dockerfilePath").GetString()); + Assert.Equal("Dockerfile", deployment.GetProperty("dockerfilePath").GetString()); Assert.Equal("vercel --cwd deploy --yes", deployment.GetProperty("deployCommand").GetString()); } @@ -289,14 +333,14 @@ public async Task WriteDeploymentPlanPipelineStepWritesSummary() { using var sourceRoot = TemporaryDirectory.Create(); using var outputRoot = TemporaryDirectory.Create(); - File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var outputService = new FakePipelineOutputService(outputRoot.Path); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(outputService); var vercel = builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .WithDockerfile(sourceRoot.Path, "Dockerfile") .PublishAsVercel(vercel); using var app = builder.Build(); @@ -316,12 +360,12 @@ public async Task WriteDeploymentPlanProcessesEnvironmentVariables() { using var sourceRoot = TemporaryDirectory.Create(); using var outputRoot = TemporaryDirectory.Create(); - File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); var vercel = builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .WithDockerfile(sourceRoot.Path, "Dockerfile") .WithEnvironment("GREETING", "hello") .PublishAsVercel(vercel); @@ -354,7 +398,7 @@ public async Task WriteDeploymentPlanThrowsWhenDockerfileIsMissing() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); var vercel = builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .WithDockerfile(sourceRoot.Path, "Dockerfile") .PublishAsVercel(vercel); using var app = builder.Build(); @@ -364,7 +408,7 @@ public async Task WriteDeploymentPlanThrowsWhenDockerfileIsMissing() var exception = await Assert.ThrowsAsync(() => VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken)); - Assert.Contains("Dockerfile.vercel", exception.Message); + Assert.Contains("Dockerfile", exception.Message); } [Fact] @@ -376,7 +420,8 @@ public async Task WriteDeploymentPlanThrowsWhenSourceRootIsMissing() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); var vercel = builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") - .PublishAsVercel(vercel, sourceRoot); + .WithDockerfile(sourceRoot) + .PublishAsVercel(vercel); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -406,7 +451,8 @@ public async Task WriteDeploymentPlanThrowsWhenContainerHasNoSourceRoot() var exception = await Assert.ThrowsAsync(() => VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken)); - Assert.Contains("does not have a Vercel source root", exception.Message); + Assert.Contains("does not have Aspire Dockerfile build metadata", exception.Message); + Assert.Contains("WithDockerfile", exception.Message); } [Fact] @@ -417,7 +463,7 @@ public void BuildDeployArgumentsIncludesConfiguredOptions() Production = true, Scope = "team" }; - var entry = new VercelDeploymentEntry(new ContainerResource("api"), "/repo/src/api", "Dockerfile.vercel"); + var entry = CreateDeploymentEntry("/repo/src/api"); string[] arguments = VercelDeploymentStep.BuildDeployArguments(options, entry); @@ -431,7 +477,7 @@ public void BuildDeployArgumentsIncludesTarget() { Target = "preview" }; - var entry = new VercelDeploymentEntry(new ContainerResource("api"), "/repo/src/api", "Dockerfile.vercel"); + var entry = CreateDeploymentEntry("/repo/src/api"); string[] arguments = VercelDeploymentStep.BuildDeployArguments(options, entry); @@ -455,12 +501,12 @@ public void BuildDestroyProjectArgumentsIncludesConfiguredOptions() public async Task BuildDeployArgumentsProcessesEnvironmentVariables() { using var sourceRoot = TemporaryDirectory.Create(); - File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); var vercel = builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .WithDockerfile(sourceRoot.Path, "Dockerfile") .WithEnvironment("GREETING", "hello") .PublishAsVercel(vercel); @@ -484,12 +530,12 @@ public async Task BuildDeployArgumentsProcessesEnvironmentVariables() public async Task BuildDeployArgumentsThrowsForContainerEntrypoint() { using var sourceRoot = TemporaryDirectory.Create(); - File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); var vercel = builder.AddVercelEnvironment("vercel"); var api = builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .WithDockerfile(sourceRoot.Path, "Dockerfile") .PublishAsVercel(vercel); api.Resource.Entrypoint = "node"; @@ -513,13 +559,13 @@ public async Task BuildDeployArgumentsThrowsForContainerEntrypoint() public async Task BuildDeployArgumentsThrowsForSecretEnvironmentVariables() { using var sourceRoot = TemporaryDirectory.Create(); - File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); var secret = builder.AddParameter("api-key", "secret-value", secret: true); var vercel = builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .WithDockerfile(sourceRoot.Path, "Dockerfile") .WithEnvironment("API_KEY", secret) .PublishAsVercel(vercel); @@ -543,13 +589,13 @@ public async Task BuildDeployArgumentsThrowsForSecretEnvironmentVariables() public async Task BuildDeployArgumentsThrowsForConnectionStringEnvironmentVariables() { using var sourceRoot = TemporaryDirectory.Create(); - File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); var connectionString = builder.AddConnectionString("db"); var vercel = builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .WithDockerfile(sourceRoot.Path, "Dockerfile") .WithEnvironment("DATABASE_URL", connectionString) .PublishAsVercel(vercel); @@ -573,12 +619,12 @@ public async Task BuildDeployArgumentsThrowsForConnectionStringEnvironmentVariab public async Task BuildDeployArgumentsThrowsForCommandLineArguments() { using var sourceRoot = TemporaryDirectory.Create(); - File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); var vercel = builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .WithDockerfile(sourceRoot.Path, "Dockerfile") .WithArgs("--verbose") .PublishAsVercel(vercel); @@ -602,12 +648,12 @@ public async Task BuildDeployArgumentsThrowsForCommandLineArguments() public async Task BuildDeployArgumentsThrowsForDockerBuildArguments() { using var sourceRoot = TemporaryDirectory.Create(); - File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); var vercel = builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .WithDockerfile(sourceRoot.Path, "Dockerfile") .WithBuildArg("FOO", "bar") .PublishAsVercel(vercel); @@ -631,7 +677,7 @@ public async Task BuildDeployArgumentsThrowsForDockerBuildArguments() public async Task DeployAsyncRunsVercelCliAndSavesDeploymentState() { using var sourceRoot = TemporaryDirectory.Create("vercel-state-project"); - File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var runner = new FakeVercelCliRunner(new VercelCliResult(0, """ { "deployment": { @@ -649,7 +695,7 @@ public async Task DeployAsyncRunsVercelCliAndSavesDeploymentState() .WithVercelCliPath("vercel-test") .WithVercelScope("team"); builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .WithDockerfile(sourceRoot.Path, "Dockerfile") .WithEnvironment("GREETING", "hello") .PublishAsVercel(vercel); @@ -677,11 +723,88 @@ public async Task DeployAsyncRunsVercelCliAndSavesDeploymentState() Assert.Contains("dpl_123", stateJson); } + [Fact] + public async Task DeployAsyncStagesGeneratedDockerfileBeforeRunningVercelCli() + { + using var sourceRoot = TemporaryDirectory.Create("generated-vercel-project"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "server.mjs"), "console.log('hello');"); + var runner = new FakeVercelCliRunner(new VercelCliResult(0, "https://generated.vercel.app", "")); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfileFactory(sourceRoot.Path, _ => Task.FromResult(""" + FROM node:22-alpine + WORKDIR /app + COPY server.mjs . + CMD ["node", "server.mjs"] + """)) + .PublishAsVercel(vercel); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + var invocation = Assert.Single(runner.Invocations); + string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", "generated-vercel-project"); + Assert.Equal(expectedStagingRoot, invocation.WorkingDirectory); + Assert.Equal(["--cwd", expectedStagingRoot, "deploy", "--yes"], invocation.Arguments); + Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "Dockerfile"))); + Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "server.mjs"))); + Assert.Contains("FROM node:22-alpine", File.ReadAllText(Path.Combine(expectedStagingRoot, "Dockerfile"))); + + var savedSection = Assert.Single(stateManager.SavedSections); + Assert.Contains("generated-vercel-project", savedSection.Data.ToJsonString()); + } + + [Fact] + public async Task DeployAsyncStagesCustomDockerfileNameBeforeRunningVercelCli() + { + using var sourceRoot = TemporaryDirectory.Create("custom-vercel-project"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.custom"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "index.html"), "hello"); + var runner = new FakeVercelCliRunner(new VercelCliResult(0, "https://custom.vercel.app", "")); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile.custom") + .PublishAsVercel(vercel); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + var invocation = Assert.Single(runner.Invocations); + string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", "custom-vercel-project"); + Assert.Equal(expectedStagingRoot, invocation.WorkingDirectory); + Assert.Equal(["--cwd", expectedStagingRoot, "deploy", "--yes"], invocation.Arguments); + Assert.Equal("FROM nginx:alpine", File.ReadAllText(Path.Combine(expectedStagingRoot, "Dockerfile"))); + Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "index.html"))); + } + [Fact] public async Task DeployAsyncThrowsWhenVercelCliFails() { using var sourceRoot = TemporaryDirectory.Create(); - File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var runner = new FakeVercelCliRunner(new VercelCliResult(1, "ignored stdout", "deploy failed")); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); @@ -690,7 +813,7 @@ public async Task DeployAsyncThrowsWhenVercelCliFails() var vercel = builder.AddVercelEnvironment("vercel") .WithVercelCliPath("vercel-test"); builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .WithDockerfile(sourceRoot.Path, "Dockerfile") .PublishAsVercel(vercel); using var app = builder.Build(); @@ -843,7 +966,7 @@ public async Task DestroyAsyncDeletesProjectsFromSavedDeploymentState() public async Task DestroyAsyncFallsBackToConfiguredDeploymentsWhenStateIsMissing() { using var sourceRoot = TemporaryDirectory.Create("fallback-project"); - File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.vercel"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var runner = new FakeVercelCliRunner(new VercelCliResult(0, "", "")); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); @@ -851,7 +974,7 @@ public async Task DestroyAsyncFallsBackToConfiguredDeploymentsWhenStateIsMissing builder.Services.AddSingleton(new FakeDeploymentStateManager()); var vercel = builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile.vercel") + .WithDockerfile(sourceRoot.Path, "Dockerfile") .PublishAsVercel(vercel); using var app = builder.Build(); @@ -1013,7 +1136,7 @@ public void GetVercelProjectNameReadsVercelProjectLink() string vercelDirectory = Path.Combine(sourceRoot.Path, ".vercel"); Directory.CreateDirectory(vercelDirectory); File.WriteAllText(Path.Combine(vercelDirectory, "project.json"), """{"projectName":"linked-project"}"""); - var entry = new VercelDeploymentEntry(new ContainerResource("api"), sourceRoot.Path, "Dockerfile.vercel"); + var entry = CreateDeploymentEntry(sourceRoot.Path); string projectName = VercelDeploymentStep.GetVercelProjectName(entry); @@ -1024,7 +1147,7 @@ public void GetVercelProjectNameReadsVercelProjectLink() public void GetVercelProjectNameFallsBackToSourceRootName() { using var sourceRoot = TemporaryDirectory.Create("fallback-project"); - var entry = new VercelDeploymentEntry(new ContainerResource("api"), sourceRoot.Path, "Dockerfile.vercel"); + var entry = CreateDeploymentEntry(sourceRoot.Path); string projectName = VercelDeploymentStep.GetVercelProjectName(entry); @@ -1063,6 +1186,27 @@ private static PipelineStepContext CreatePipelineStepContext( }; } + private static VercelDeploymentEntry CreateDeploymentEntry(string sourceRoot) + { + string dockerfilePath = Path.Combine(sourceRoot, "Dockerfile"); + return new( + new ContainerResource("api"), + sourceRoot, + dockerfilePath, + new DockerfileBuildAnnotation(sourceRoot, dockerfilePath, stage: null)); + } + + private sealed class FakeProjectMetadata(string projectPath) : IProjectMetadata + { + public bool IsFileBasedApp => false; + + public LaunchSettings? LaunchSettings => null; + + public string ProjectPath => projectPath; + + public bool SuppressBuild => false; + } + private sealed class TemporaryDirectory : IDisposable { private TemporaryDirectory(string path) @@ -1163,15 +1307,17 @@ public Task ClearAllStateAsync(CancellationToken cancellationToken) } } - private sealed class FakePipelineOutputService(string outputDirectory) : IPipelineOutputService + private sealed class FakePipelineOutputService(string outputDirectory, string? tempDirectory = null) : IPipelineOutputService { + private readonly string _tempDirectory = tempDirectory ?? outputDirectory; + public string GetOutputDirectory() => outputDirectory; public string GetOutputDirectory(IResource resource) => outputDirectory; - public string GetTempDirectory() => outputDirectory; + public string GetTempDirectory() => _tempDirectory; - public string GetTempDirectory(IResource resource) => outputDirectory; + public string GetTempDirectory(IResource resource) => Path.Combine(_tempDirectory, resource.Name); } private sealed class NoopReportingStep : IReportingStep From 3435abe4b6b45684d513fb21c7dad1b59965b3d6 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 30 Jun 2026 13:03:41 -0700 Subject: [PATCH 04/33] Use language generated Dockerfiles for Vercel Update the Vercel example and docs to use Aspire language workload integrations instead of requiring app-authored Dockerfiles. Make PublishAsVercel work for executable-derived language resources that already registered generated Dockerfile metadata. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../apphost.mts | 3 +- .../aspire.config.json | 1 + .../ct-aspire-vercel-typescript/Dockerfile | 4 --- ...munityToolkit.Aspire.Hosting.Vercel.csproj | 2 +- .../README.md | 21 ++++++++---- .../VercelDeploymentStep.cs | 6 ++-- .../VercelEnvironmentResource.cs | 2 +- ...celEnvironmentResourceBuilderExtensions.cs | 10 +++--- .../VercelEnvironmentTests.cs | 32 +++++++++++++++++++ 9 files changed, 59 insertions(+), 22 deletions(-) delete mode 100644 examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/Dockerfile diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts index 0833df4e8..87c01d056 100644 --- a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts @@ -7,8 +7,7 @@ await vercel.withVercelCliPath("vercel"); await vercel.withVercelProductionDeployments(); const api = await builder - .addContainer("api", "ct-aspire-vercel-typescript") - .withDockerfile("./ct-aspire-vercel-typescript"); + .addNodeApp("api", "./ct-aspire-vercel-typescript", "server.mjs"); await api.withEnvironment("GREETING", "hello-from-typescript-apphost"); await api.publishAsVercel(vercel); diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/aspire.config.json b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/aspire.config.json index ae2d985cf..e6a080f1d 100644 --- a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/aspire.config.json +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/aspire.config.json @@ -16,6 +16,7 @@ } }, "packages": { + "Aspire.Hosting.JavaScript": "13.4.3", "CommunityToolkit.Aspire.Hosting.Vercel": "../../../src/CommunityToolkit.Aspire.Hosting.Vercel/CommunityToolkit.Aspire.Hosting.Vercel.csproj" } } diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/Dockerfile b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/Dockerfile deleted file mode 100644 index 6902b36a1..000000000 --- a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/Dockerfile +++ /dev/null @@ -1,4 +0,0 @@ -FROM node:22-alpine -WORKDIR /app -COPY server.mjs . -CMD ["node", "server.mjs"] diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/CommunityToolkit.Aspire.Hosting.Vercel.csproj b/src/CommunityToolkit.Aspire.Hosting.Vercel/CommunityToolkit.Aspire.Hosting.Vercel.csproj index b4d5d428a..e610bebb3 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/CommunityToolkit.Aspire.Hosting.Vercel.csproj +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/CommunityToolkit.Aspire.Hosting.Vercel.csproj @@ -2,7 +2,7 @@ hosting deployment vercel dockerfile containers - An Aspire hosting integration for deploying Dockerfile-based services to Vercel. + An Aspire hosting integration for deploying Aspire workloads to Vercel Dockerfile hosting. true $(NoWarn);ASPIREATS001 diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md index ed326dc3e..93c845a88 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md @@ -1,6 +1,6 @@ # Vercel hosting integration -Use this integration to model, configure, and deploy Dockerfile-based Aspire resources to Vercel. +Use this integration to model, configure, and deploy Aspire workloads to Vercel's Dockerfile-based hosting. ## Getting started @@ -12,7 +12,7 @@ aspire add CommunityToolkit.Aspire.Hosting.Vercel ## Usage example -Add a Vercel environment and publish the project to it. Project and executable resources use Aspire's existing Dockerfile publishing support, so the Dockerfile that Aspire would publish is the Dockerfile Vercel builds: +Add a Vercel environment and publish the workload to it. Projects and language-specific app integrations use Aspire's existing publish support, so generated Dockerfiles come from the workload integration instead of Vercel-specific files: ```csharp var builder = DistributedApplication.CreateBuilder(args); @@ -25,6 +25,13 @@ builder.AddProject("api") builder.Build().Run(); ``` +Language integrations work the same way: + +```csharp +builder.AddNodeApp("api", "../api", "server.mjs") + .PublishAsVercel(vercel); +``` + By default, `aspire deploy` runs: ```bash @@ -33,7 +40,7 @@ vercel --cwd deploy --yes Use `WithVercelProductionDeployments` to add `--prod`, `WithVercelTarget` to add `--target`, and `WithVercelScope` to deploy to a team or account scope. -For existing container resources, configure Aspire Dockerfile build metadata before publishing to Vercel: +For low-level container resources, publish them to Vercel only after they already have Aspire Dockerfile build metadata. Prefer the workload-specific `Add*App` integration when one exists: ```csharp builder.AddContainer("web", "web") @@ -41,7 +48,7 @@ builder.AddContainer("web", "web") .PublishAsVercel(vercel); ``` -`WithDockerfile`, `WithDockerfileFactory`, and `WithDockerfileBuilder` are supported. If Aspire generates the Dockerfile, or if the configured Dockerfile is not named `Dockerfile` in the context root, the integration stages the source root and materializes the Vercel-facing `Dockerfile` before running `vercel deploy`. +`WithDockerfile`, `WithDockerfileFactory`, and `WithDockerfileBuilder` are supported for this low-level container path. If Aspire generates the Dockerfile, or if the configured Dockerfile is not named `Dockerfile` in the context root, the integration stages the source root and materializes the Vercel-facing `Dockerfile` before running `vercel deploy`. Non-secret Aspire environment variables configured on the resource are processed during publish/deploy and passed to Vercel as CLI environment variables: @@ -64,7 +71,7 @@ vercel --cwd deploy --yes --env GREETING=hello ## Prerequisites -- A Dockerfile-backed Aspire resource. Projects and executables can use `PublishAsVercel` directly. Existing container resources must be configured with `WithDockerfile`, `WithDockerfileFactory`, or `WithDockerfileBuilder` before `PublishAsVercel`. +- An Aspire workload that can publish as a Dockerfile. Projects and language app resources can use `PublishAsVercel` directly. Existing low-level container resources must already be configured with `WithDockerfile`, `WithDockerfileFactory`, or `WithDockerfileBuilder` before `PublishAsVercel`. - The deployed container should listen on `$PORT`; the default port is `80`. - Vercel CLI installed and on `PATH`, or configured with `WithVercelCliPath`. - Vercel authentication from an existing CLI login or the `VERCEL_TOKEN` environment variable. @@ -76,9 +83,9 @@ This integration does not manage Vercel secrets, provision marketplace resources `aspire destroy` deletes the Vercel projects for resources targeted by this integration. Use a dedicated Vercel project for each Aspire resource, or do not run `aspire destroy` if the project contains deployments that are managed outside the Aspire AppHost. -Secret Aspire environment variables, connection strings, Docker build arguments, and Docker build secrets are rejected. Configure those values in Vercel project settings instead, because Vercel builds `Dockerfile` itself and Vercel CLI `--env` would put secret values on the command line. +Secret Aspire environment variables, connection strings, Docker build arguments, and Docker build secrets are rejected. Configure those values in Vercel project settings instead, because Vercel runs the Dockerfile build itself and Vercel CLI `--env` would put secret values on the command line. -Aspire command-line arguments and container entrypoint overrides are also rejected. Put runtime arguments or entrypoint behavior in `Dockerfile` instead. +Aspire command-line arguments and container entrypoint overrides are also rejected. Configure runtime command behavior through the workload's publish support or Vercel project settings instead. ## Additional documentation diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs index 3a367d121..80464e21d 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs @@ -401,20 +401,20 @@ private static void ValidateUnsupportedRuntimeConfiguration( if (resource is ContainerResource { Entrypoint: not null }) { throw new DistributedApplicationException( - $"Resource '{resource.Name}' configures a container entrypoint, but Vercel Dockerfile deployments use the CMD/ENTRYPOINT from the Dockerfile. Move the entrypoint into the Dockerfile."); + $"Resource '{resource.Name}' configures a container entrypoint, but Vercel Dockerfile deployments use the CMD/ENTRYPOINT from Aspire's publish output. Configure the workload's publish behavior or Vercel project settings instead."); } if (executionConfiguration.ArgumentsWithUnprocessed.Any()) { throw new DistributedApplicationException( - $"Resource '{resource.Name}' configures Aspire command-line arguments, but Vercel Dockerfile deployments cannot override Docker CMD/ENTRYPOINT. Move these arguments into the Dockerfile or express them as environment variables."); + $"Resource '{resource.Name}' configures Aspire command-line arguments, but Vercel Dockerfile deployments cannot override Docker CMD/ENTRYPOINT. Configure the workload's publish behavior or express the values as environment variables."); } if (resource.TryGetLastAnnotation(out var dockerfile) && (dockerfile.BuildArguments.Count > 0 || dockerfile.BuildSecrets.Count > 0)) { throw new DistributedApplicationException( - $"Resource '{resource.Name}' configures Aspire Docker build arguments or build secrets. Vercel builds the Dockerfile itself, so configure build-time values in Vercel instead."); + $"Resource '{resource.Name}' configures Aspire Docker build arguments or build secrets. Vercel runs the Dockerfile build itself, so configure build-time values in Vercel instead."); } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs index cd2ec795a..f8b5863bd 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs @@ -4,7 +4,7 @@ namespace Aspire.Hosting.ApplicationModel; /// -/// Represents a Vercel deployment target for Dockerfile-based services. +/// Represents a Vercel deployment target for Aspire workloads that publish as Dockerfile builds. /// /// The Aspire resource name for the Vercel environment. [Experimental("CTASPIREVERCEL001")] diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs index 9fd975141..c8a78cb05 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs @@ -7,7 +7,7 @@ namespace Aspire.Hosting; /// -/// Provides extension methods for deploying Dockerfile-based resources to Vercel. +/// Provides extension methods for deploying Aspire workloads to Vercel Dockerfile hosting. /// [Experimental("CTASPIREVERCEL001")] public static class VercelEnvironmentResourceBuilderExtensions @@ -19,7 +19,7 @@ public static class VercelEnvironmentResourceBuilderExtensions /// The Aspire resource name for the Vercel environment. /// A resource builder for the Vercel environment. /// - /// The Vercel environment is not added to the local run model. During publish and deploy it validates Dockerfile-based + /// The Vercel environment is not added to the local run model. During publish and deploy it validates Dockerfile build metadata for /// resources and, during deploy, invokes the Vercel CLI using the current login or VERCEL_TOKEN. /// [AspireExport] @@ -178,13 +178,15 @@ public static IResourceBuilder PublishAsVercel( /// /// Configures an executable resource to deploy to Vercel using Aspire's Dockerfile publishing support. /// + /// The executable resource type. /// The executable resource builder. /// The Vercel environment builder. /// The executable resource builder. [AspireExport("publishExecutableAsVercel", MethodName = "publishAsVercel")] - public static IResourceBuilder PublishAsVercel( - this IResourceBuilder builder, + public static IResourceBuilder PublishAsVercel( + this IResourceBuilder builder, IResourceBuilder environment) + where T : ExecutableResource { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(environment); diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs index 982790e36..67d36217d 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs @@ -278,6 +278,35 @@ public void PublishExecutableAsVercelAddsDeploymentAnnotationInPublishMode() Assert.Equal(Path.Combine(sourceRoot.Path, "Dockerfile"), entry.DockerfilePath); } + [Fact] + public void PublishLanguageExecutableAsVercelUsesExistingGeneratedDockerfileMetadata() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "server.mjs"), "console.log('hello');"); + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + var vercel = builder.AddVercelEnvironment("vercel"); + var resource = new TestLanguageAppResource("api", "node", sourceRoot.Path); + + builder.AddResource(resource) + .PublishAsDockerFile(container => container.WithDockerfileFactory(sourceRoot.Path, _ => Task.FromResult(""" + FROM node:22-alpine + WORKDIR /app + COPY server.mjs . + CMD ["node", "server.mjs"] + """))) + .PublishAsVercel(vercel); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + Assert.IsAssignableFrom(entry.Resource); + Assert.Equal(sourceRoot.Path, entry.SourceRoot); + Assert.NotNull(entry.Dockerfile.DockerfileFactory); + Assert.True(entry.Resource.TryGetLastAnnotation(out _)); + } + [Fact] public void PublishContainerAsVercelUsesDockerfileAnnotation() { @@ -1207,6 +1236,9 @@ private sealed class FakeProjectMetadata(string projectPath) : IProjectMetadata public bool SuppressBuild => false; } + private sealed class TestLanguageAppResource(string name, string command, string workingDirectory) + : ExecutableResource(name, command, workingDirectory); + private sealed class TemporaryDirectory : IDisposable { private TemporaryDirectory(string path) From 703db50d35b3d6eb28291a3502d2bcf4dfe966b2 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 30 Jun 2026 13:18:22 -0700 Subject: [PATCH 05/33] Use compute environments for Vercel deployments Remove the Vercel-specific PublishAsVercel marker API and discover Dockerfile-backed compute resources through Aspire's compute environment targeting model. Vercel now uses the single compute environment default and WithComputeEnvironment for ambiguous AppHosts while continuing to consume existing Dockerfile publish metadata from language and project integrations. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../apphost.mts | 1 - .../README.md | 33 ++- .../VercelDeploymentAnnotation.cs | 5 - .../VercelDeploymentStep.cs | 21 +- ...celEnvironmentResourceBuilderExtensions.cs | 81 +------ .../VercelEnvironmentTests.cs | 200 ++++++++---------- 6 files changed, 117 insertions(+), 224 deletions(-) delete mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentAnnotation.cs diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts index 87c01d056..5e8d8e9f2 100644 --- a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts @@ -10,6 +10,5 @@ const api = await builder .addNodeApp("api", "./ct-aspire-vercel-typescript", "server.mjs"); await api.withEnvironment("GREETING", "hello-from-typescript-apphost"); -await api.publishAsVercel(vercel); await builder.build().run(); diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md index 93c845a88..031f563e9 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md @@ -12,24 +12,23 @@ aspire add CommunityToolkit.Aspire.Hosting.Vercel ## Usage example -Add a Vercel environment and publish the workload to it. Projects and language-specific app integrations use Aspire's existing publish support, so generated Dockerfiles come from the workload integration instead of Vercel-specific files: +Add a Vercel environment and a workload with Aspire Dockerfile publish metadata. When Vercel is the only compute environment, Dockerfile-backed workloads target it by default: ```csharp var builder = DistributedApplication.CreateBuilder(args); -var vercel = builder.AddVercelEnvironment("vercel"); +builder.AddVercelEnvironment("vercel"); -builder.AddProject("api") - .PublishAsVercel(vercel); +builder.AddNodeApp("api", "../api", "server.mjs"); builder.Build().Run(); ``` -Language integrations work the same way: +.NET projects use Aspire's existing Dockerfile publish support: ```csharp -builder.AddNodeApp("api", "../api", "server.mjs") - .PublishAsVercel(vercel); +builder.AddProject("api") + .PublishAsDockerFile(); ``` By default, `aspire deploy` runs: @@ -40,22 +39,22 @@ vercel --cwd deploy --yes Use `WithVercelProductionDeployments` to add `--prod`, `WithVercelTarget` to add `--target`, and `WithVercelScope` to deploy to a team or account scope. -For low-level container resources, publish them to Vercel only after they already have Aspire Dockerfile build metadata. Prefer the workload-specific `Add*App` integration when one exists: +When an AppHost contains multiple compute environments, use Aspire's standard `WithComputeEnvironment` API to choose Vercel for a workload: ```csharp -builder.AddContainer("web", "web") - .WithDockerfile("../web") - .PublishAsVercel(vercel); +var vercel = builder.AddVercelEnvironment("vercel"); + +builder.AddNodeApp("api", "../api", "server.mjs") + .WithComputeEnvironment(vercel); ``` -`WithDockerfile`, `WithDockerfileFactory`, and `WithDockerfileBuilder` are supported for this low-level container path. If Aspire generates the Dockerfile, or if the configured Dockerfile is not named `Dockerfile` in the context root, the integration stages the source root and materializes the Vercel-facing `Dockerfile` before running `vercel deploy`. +For low-level container resources, Vercel requires existing Aspire Dockerfile build metadata. Prefer the workload-specific `Add*App` integration when one exists. `WithDockerfile`, `WithDockerfileFactory`, and `WithDockerfileBuilder` are supported as advanced escape hatches. If Aspire generates the Dockerfile, or if the configured Dockerfile is not named `Dockerfile` in the context root, the integration stages the source root and materializes the Vercel-facing `Dockerfile` before running `vercel deploy`. Non-secret Aspire environment variables configured on the resource are processed during publish/deploy and passed to Vercel as CLI environment variables: ```csharp -builder.AddProject("api") - .WithEnvironment("GREETING", "hello") - .PublishAsVercel(vercel); +builder.AddNodeApp("api", "../api", "server.mjs") + .WithEnvironment("GREETING", "hello"); ``` ```bash @@ -64,14 +63,14 @@ vercel --cwd deploy --yes --env GREETING=hello ## Deployment behavior -- `aspire start` is unchanged. The Vercel environment and `PublishAsVercel` configuration are publish/deploy-only. +- `aspire start` is unchanged. The Vercel environment is publish/deploy-only. - `aspire publish` writes a `vercel-deployments.json` plan for the targeted resources, including the environment variable names passed to Vercel. - `aspire deploy` validates the Vercel CLI, validates authentication, materializes any Aspire-generated Dockerfile, and invokes `vercel deploy`. - `aspire destroy` removes the Vercel projects recorded during deployment. The Aspire CLI destroy confirmation applies before the project removal step runs. ## Prerequisites -- An Aspire workload that can publish as a Dockerfile. Projects and language app resources can use `PublishAsVercel` directly. Existing low-level container resources must already be configured with `WithDockerfile`, `WithDockerfileFactory`, or `WithDockerfileBuilder` before `PublishAsVercel`. +- An Aspire workload with Dockerfile publish metadata, such as a language app resource that emits Dockerfile metadata or a project configured with `PublishAsDockerFile`. - The deployed container should listen on `$PORT`; the default port is `80`. - Vercel CLI installed and on `PATH`, or configured with `WithVercelCliPath`. - Vercel authentication from an existing CLI login or the `VERCEL_TOKEN` environment variable. diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentAnnotation.cs deleted file mode 100644 index 0e85183d8..000000000 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentAnnotation.cs +++ /dev/null @@ -1,5 +0,0 @@ -using Aspire.Hosting.ApplicationModel; - -namespace CommunityToolkit.Aspire.Hosting.Vercel; - -internal sealed class VercelDeploymentAnnotation : IResourceAnnotation; diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs index 80464e21d..420c28350 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs @@ -445,32 +445,33 @@ private static bool IsConnectionStringResourceBuilder(object value) internal static IEnumerable GetDeploymentEntries(DistributedApplicationModel model, VercelEnvironmentResource environment) { - foreach (var resource in model.Resources) + foreach (var resource in model.Resources.OfType()) { - if (resource.GetComputeEnvironment() != environment) - { - continue; - } - - if (!resource.TryGetLastAnnotation(out _)) + if (!IsTargetedToEnvironment(resource, environment)) { continue; } if (!resource.TryGetLastAnnotation(out var dockerfile)) { - throw new DistributedApplicationException($"Resource '{resource.Name}' is published to Vercel but does not have Aspire Dockerfile build metadata. Configure the resource with PublishAsDockerFile, WithDockerfile, WithDockerfileFactory, or WithDockerfileBuilder before calling PublishAsVercel."); + throw new DistributedApplicationException($"Resource '{resource.Name}' targets Vercel but does not have Aspire Dockerfile build metadata. Use a workload integration that publishes Dockerfile metadata, call PublishAsDockerFile, or configure the resource with WithDockerfile, WithDockerfileFactory, or WithDockerfileBuilder."); } yield return new(resource, dockerfile.ContextPath, dockerfile.DockerfilePath, dockerfile); } } + private static bool IsTargetedToEnvironment(IResource resource, VercelEnvironmentResource environment) + { + var computeEnvironment = resource.GetComputeEnvironment(); + return computeEnvironment is null || ReferenceEquals(computeEnvironment, environment); + } + private static void ValidateEntries(IReadOnlyList entries) { if (entries.Count == 0) { - throw new DistributedApplicationException("No resources are configured for Vercel deployment. Call PublishAsVercel on at least one project, executable, or Dockerfile container resource."); + throw new DistributedApplicationException("No Dockerfile-backed compute resources target Vercel. Add a workload with Aspire Dockerfile publish metadata, or use WithComputeEnvironment to target Vercel when multiple compute environments are present."); } foreach (var entry in entries) @@ -482,7 +483,7 @@ private static void ValidateEntries(IReadOnlyList entries if (entry.Dockerfile.DockerfileFactory is null && !File.Exists(entry.DockerfilePath)) { - throw new DistributedApplicationException($"The Vercel Dockerfile '{entry.DockerfilePath}' for resource '{entry.Resource.Name}' does not exist. Configure the resource with an existing Dockerfile or an Aspire generated Dockerfile before calling PublishAsVercel."); + throw new DistributedApplicationException($"The Vercel Dockerfile '{entry.DockerfilePath}' for resource '{entry.Resource.Name}' does not exist. Configure the resource with an existing Dockerfile or Aspire-generated Dockerfile metadata."); } } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs index c8a78cb05..7afe687ae 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs @@ -46,6 +46,7 @@ public static IResourceBuilder AddVercelEnvironment( Name = $"{VercelDeploymentStep.PublishStepNamePrefix}{resource.Name}", Description = $"Generate Vercel deployment plan for '{resource.Name}'.", Resource = resource, + DependsOnSteps = [WellKnownPipelineSteps.ValidateComputeEnvironments], RequiredBySteps = [WellKnownPipelineSteps.Publish, WellKnownPipelineSteps.Deploy], Action = context => VercelDeploymentStep.WriteDeploymentPlanAsync(context, resource) }, @@ -54,6 +55,7 @@ public static IResourceBuilder AddVercelEnvironment( Name = $"{VercelDeploymentStep.DeployPrereqStepNamePrefix}{resource.Name}", Description = $"Validate Vercel CLI prerequisites for '{resource.Name}'.", Resource = resource, + DependsOnSteps = [WellKnownPipelineSteps.ValidateComputeEnvironments], RequiredBySteps = [WellKnownPipelineSteps.Deploy], Action = context => VercelDeploymentStep.ValidatePrerequisitesAsync(context, resource) }, @@ -153,85 +155,6 @@ public static IResourceBuilder WithVercelTarget( return builder.WithVercelOptions(options => options with { Production = false, Target = target }); } - /// - /// Configures a .NET project resource to deploy to Vercel using Aspire's Dockerfile publishing support. - /// - /// The project resource builder. - /// The Vercel environment builder. - /// The project resource builder. - [AspireExport("publishProjectAsVercel", MethodName = "publishAsVercel")] - public static IResourceBuilder PublishAsVercel( - this IResourceBuilder builder, - IResourceBuilder environment) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(environment); - - if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) - { - return builder; - } - - return builder.PublishAsDockerFile(container => container.WithVercelDeployment(environment)); - } - - /// - /// Configures an executable resource to deploy to Vercel using Aspire's Dockerfile publishing support. - /// - /// The executable resource type. - /// The executable resource builder. - /// The Vercel environment builder. - /// The executable resource builder. - [AspireExport("publishExecutableAsVercel", MethodName = "publishAsVercel")] - public static IResourceBuilder PublishAsVercel( - this IResourceBuilder builder, - IResourceBuilder environment) - where T : ExecutableResource - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(environment); - - if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) - { - return builder; - } - - return builder.PublishAsDockerFile(container => container.WithVercelDeployment(environment)); - } - - /// - /// Configures a container resource to deploy to Vercel using its Aspire Dockerfile build metadata. - /// - /// The container resource builder. - /// The Vercel environment builder. - /// The container resource builder. - /// Configure the container with WithDockerfile, WithDockerfileFactory, or WithDockerfileBuilder before publishing it to Vercel. - [AspireExport("publishContainerAsVercel", MethodName = "publishAsVercel")] - public static IResourceBuilder PublishAsVercel( - this IResourceBuilder builder, - IResourceBuilder environment) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(environment); - - if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) - { - return builder; - } - - return builder.WithVercelDeployment(environment); - } - - private static IResourceBuilder WithVercelDeployment( - this IResourceBuilder builder, - IResourceBuilder environment) - where T : IComputeResource - { - return builder - .WithComputeEnvironment(environment) - .WithAnnotation(new VercelDeploymentAnnotation(), ResourceAnnotationMutationBehavior.Replace); - } - private static IResourceBuilder WithVercelOptions( this IResourceBuilder builder, Func configure) diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs index 67d36217d..850505ff8 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs @@ -41,58 +41,21 @@ public void AddVercelEnvironmentShouldThrowWhenNameIsNull() } [Fact] - public void RunModeDoesNotAddVercelEnvironmentOrDeploymentAnnotation() + public void RunModeDoesNotAddVercelEnvironment() { var builder = DistributedApplication.CreateBuilder(); - var vercel = builder.AddVercelEnvironment("vercel"); - builder.AddContainer("api", "api").PublishAsVercel(vercel); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api"); using var app = builder.Build(); var model = app.Services.GetRequiredService(); - var api = Assert.Single(model.Resources.OfType()); Assert.DoesNotContain(model.Resources, resource => resource is VercelEnvironmentResource); - Assert.False(api.TryGetLastAnnotation(out _)); - } - - [Fact] - public void RunModeProjectPublishAsVercelLeavesProjectResourceUnchanged() - { - var builder = DistributedApplication.CreateBuilder(); - var vercel = builder.AddVercelEnvironment("vercel"); - var projectResource = new ProjectResource("api"); - - builder.AddResource(projectResource) - .PublishAsVercel(vercel); - - using var app = builder.Build(); - var model = app.Services.GetRequiredService(); - var api = Assert.Single(model.Resources.OfType()); - - Assert.Same(projectResource, api); - Assert.False(api.TryGetLastAnnotation(out _)); } [Fact] - public void RunModeExecutablePublishAsVercelLeavesExecutableResourceUnchanged() - { - using var sourceRoot = TemporaryDirectory.Create(); - var builder = DistributedApplication.CreateBuilder(); - var vercel = builder.AddVercelEnvironment("vercel"); - - builder.AddExecutable("api", "node", sourceRoot.Path) - .PublishAsVercel(vercel); - - using var app = builder.Build(); - var model = app.Services.GetRequiredService(); - var api = Assert.Single(model.Resources.OfType()); - - Assert.False(api.TryGetLastAnnotation(out _)); - } - - [Fact] - public void PublishModeAddsVercelEnvironmentAndDeploymentAnnotation() + public void PublishModeAddsVercelEnvironmentAndDiscoversDockerfileResource() { using var sourceRoot = TemporaryDirectory.Create(); File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); @@ -104,19 +67,19 @@ public void PublishModeAddsVercelEnvironmentAndDeploymentAnnotation() .WithVercelTarget("preview"); builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile") - .PublishAsVercel(vercel); + .WithDockerfile(sourceRoot.Path, "Dockerfile"); using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); var api = Assert.Single(model.Resources.OfType()); - Assert.Same(environment, api.GetComputeEnvironment()); - Assert.True(api.TryGetLastAnnotation(out _)); + Assert.Null(api.GetComputeEnvironment()); Assert.True(api.TryGetLastAnnotation(out var dockerfile)); Assert.Equal(sourceRoot.Path, dockerfile.ContextPath); Assert.Equal(Path.Combine(sourceRoot.Path, "Dockerfile"), dockerfile.DockerfilePath); + var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + Assert.Same(api, entry.Resource); var options = environment.GetVercelOptions(); Assert.Equal("team", options.Scope); @@ -167,12 +130,14 @@ public async Task AddVercelEnvironmentRegistersExpectedPipelineSteps() { Assert.Equal("vercel-publish-vercel", step.Name); Assert.Same(vercel.Resource, step.Resource); + Assert.Equal([WellKnownPipelineSteps.ValidateComputeEnvironments], step.DependsOnSteps); Assert.Equal([WellKnownPipelineSteps.Publish, WellKnownPipelineSteps.Deploy], step.RequiredBySteps); }, step => { Assert.Equal("vercel-deploy-prereq-vercel", step.Name); Assert.Same(vercel.Resource, step.Resource); + Assert.Equal([WellKnownPipelineSteps.ValidateComputeEnvironments], step.DependsOnSteps); Assert.Equal([WellKnownPipelineSteps.Deploy], step.RequiredBySteps); }, step => @@ -232,41 +197,42 @@ public void WithVercelProductionDeploymentsShouldThrowWhenBuilderIsNull() } [Fact] - public void PublishProjectAsVercelAddsDeploymentAnnotationInPublishMode() + public void PublishProjectAsDockerFileIsDiscoveredInPublishMode() { using var sourceRoot = TemporaryDirectory.Create(); string projectPath = Path.Combine(sourceRoot.Path, "Api.csproj"); File.WriteAllText(projectPath, ""); File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM mcr.microsoft.com/dotnet/aspnet:10.0"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); - var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddVercelEnvironment("vercel"); var projectResource = new ProjectResource("api"); var project = builder.AddResource(projectResource) .WithAnnotation(new FakeProjectMetadata(projectPath)); - project.PublishAsVercel(vercel); + project.PublishAsDockerFile(); using var app = builder.Build(); var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); var api = Assert.Single(model.Resources.OfType(), resource => resource.Name == "api"); - Assert.Same(vercel.Resource, api.GetComputeEnvironment()); - Assert.True(api.TryGetLastAnnotation(out _)); + Assert.Null(api.GetComputeEnvironment()); Assert.True(api.TryGetLastAnnotation(out var dockerfile)); Assert.Equal(sourceRoot.Path, dockerfile.ContextPath); Assert.Equal(Path.Combine(sourceRoot.Path, "Dockerfile"), dockerfile.DockerfilePath); + Assert.Same(api, Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)).Resource); } [Fact] - public void PublishExecutableAsVercelAddsDeploymentAnnotationInPublishMode() + public void PublishExecutableAsDockerFileIsDiscoveredInPublishMode() { using var sourceRoot = TemporaryDirectory.Create(); File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); - var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddVercelEnvironment("vercel"); builder.AddExecutable("api", "node", sourceRoot.Path) - .PublishAsVercel(vercel); + .PublishAsDockerFile(); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -279,12 +245,12 @@ public void PublishExecutableAsVercelAddsDeploymentAnnotationInPublishMode() } [Fact] - public void PublishLanguageExecutableAsVercelUsesExistingGeneratedDockerfileMetadata() + public void LanguageExecutableUsesExistingGeneratedDockerfileMetadata() { using var sourceRoot = TemporaryDirectory.Create(); File.WriteAllText(Path.Combine(sourceRoot.Path, "server.mjs"), "console.log('hello');"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); - var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddVercelEnvironment("vercel"); var resource = new TestLanguageAppResource("api", "node", sourceRoot.Path); builder.AddResource(resource) @@ -293,8 +259,7 @@ public void PublishLanguageExecutableAsVercelUsesExistingGeneratedDockerfileMeta WORKDIR /app COPY server.mjs . CMD ["node", "server.mjs"] - """))) - .PublishAsVercel(vercel); + """))); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -304,20 +269,18 @@ COPY server.mjs . Assert.IsAssignableFrom(entry.Resource); Assert.Equal(sourceRoot.Path, entry.SourceRoot); Assert.NotNull(entry.Dockerfile.DockerfileFactory); - Assert.True(entry.Resource.TryGetLastAnnotation(out _)); } [Fact] - public void PublishContainerAsVercelUsesDockerfileAnnotation() + public void DockerfileContainerIsDiscoveredInPublishMode() { using var sourceRoot = TemporaryDirectory.Create(); File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.custom"), "FROM nginx:alpine"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); - var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile.custom") - .PublishAsVercel(vercel); + .WithDockerfile(sourceRoot.Path, "Dockerfile.custom"); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -328,6 +291,36 @@ public void PublishContainerAsVercelUsesDockerfileAnnotation() Assert.Equal(Path.Combine(sourceRoot.Path, "Dockerfile.custom"), entry.DockerfilePath); } + [Fact] + public void ExplicitComputeEnvironmentSelectsVercelResourceWhenMultipleExist() + { + using var apiRoot = TemporaryDirectory.Create("vercel-api-project"); + using var workerRoot = TemporaryDirectory.Create("vercel-worker-project"); + File.WriteAllText(Path.Combine(apiRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(workerRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + var vercel = builder.AddVercelEnvironment("vercel"); + var other = builder.AddVercelEnvironment("other"); + + builder.AddContainer("api", "api") + .WithDockerfile(apiRoot.Path, "Dockerfile") + .WithComputeEnvironment(vercel); + builder.AddContainer("worker", "worker") + .WithDockerfile(workerRoot.Path, "Dockerfile") + .WithComputeEnvironment(other); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var vercelEnvironment = Assert.Single(model.Resources.OfType(), resource => resource.Name == "vercel"); + var otherEnvironment = Assert.Single(model.Resources.OfType(), resource => resource.Name == "other"); + + var vercelEntry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, vercelEnvironment)); + var otherEntry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, otherEnvironment)); + + Assert.Equal("api", vercelEntry.Resource.Name); + Assert.Equal("worker", otherEntry.Resource.Name); + } + [Fact] public async Task WriteDeploymentPlanWritesExpectedJson() { @@ -336,10 +329,9 @@ public async Task WriteDeploymentPlanWritesExpectedJson() File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); - var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile") - .PublishAsVercel(vercel); + .WithDockerfile(sourceRoot.Path, "Dockerfile"); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -367,10 +359,9 @@ public async Task WriteDeploymentPlanPipelineStepWritesSummary() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(outputService); - var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile") - .PublishAsVercel(vercel); + .WithDockerfile(sourceRoot.Path, "Dockerfile"); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -392,11 +383,10 @@ public async Task WriteDeploymentPlanProcessesEnvironmentVariables() File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); - var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") .WithDockerfile(sourceRoot.Path, "Dockerfile") - .WithEnvironment("GREETING", "hello") - .PublishAsVercel(vercel); + .WithEnvironment("GREETING", "hello"); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -425,10 +415,9 @@ public async Task WriteDeploymentPlanThrowsWhenDockerfileIsMissing() using var outputRoot = TemporaryDirectory.Create(); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); - var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile") - .PublishAsVercel(vercel); + .WithDockerfile(sourceRoot.Path, "Dockerfile"); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -447,10 +436,9 @@ public async Task WriteDeploymentPlanThrowsWhenSourceRootIsMissing() string sourceRoot = Path.Combine(Path.GetTempPath(), $"missing-vercel-tests-{Guid.NewGuid():N}"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); - var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot) - .PublishAsVercel(vercel); + .WithDockerfile(sourceRoot); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -469,9 +457,8 @@ public async Task WriteDeploymentPlanThrowsWhenContainerHasNoSourceRoot() using var outputRoot = TemporaryDirectory.Create(); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); - var vercel = builder.AddVercelEnvironment("vercel"); - builder.AddContainer("api", "api") - .PublishAsVercel(vercel); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api"); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -533,11 +520,10 @@ public async Task BuildDeployArgumentsProcessesEnvironmentVariables() File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); - var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") .WithDockerfile(sourceRoot.Path, "Dockerfile") - .WithEnvironment("GREETING", "hello") - .PublishAsVercel(vercel); + .WithEnvironment("GREETING", "hello"); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -562,10 +548,9 @@ public async Task BuildDeployArgumentsThrowsForContainerEntrypoint() File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); - var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddVercelEnvironment("vercel"); var api = builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile") - .PublishAsVercel(vercel); + .WithDockerfile(sourceRoot.Path, "Dockerfile"); api.Resource.Entrypoint = "node"; using var app = builder.Build(); @@ -592,11 +577,10 @@ public async Task BuildDeployArgumentsThrowsForSecretEnvironmentVariables() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); var secret = builder.AddParameter("api-key", "secret-value", secret: true); - var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") .WithDockerfile(sourceRoot.Path, "Dockerfile") - .WithEnvironment("API_KEY", secret) - .PublishAsVercel(vercel); + .WithEnvironment("API_KEY", secret); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -622,11 +606,10 @@ public async Task BuildDeployArgumentsThrowsForConnectionStringEnvironmentVariab var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); var connectionString = builder.AddConnectionString("db"); - var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") .WithDockerfile(sourceRoot.Path, "Dockerfile") - .WithEnvironment("DATABASE_URL", connectionString) - .PublishAsVercel(vercel); + .WithEnvironment("DATABASE_URL", connectionString); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -651,11 +634,10 @@ public async Task BuildDeployArgumentsThrowsForCommandLineArguments() File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); - var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") .WithDockerfile(sourceRoot.Path, "Dockerfile") - .WithArgs("--verbose") - .PublishAsVercel(vercel); + .WithArgs("--verbose"); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -680,11 +662,10 @@ public async Task BuildDeployArgumentsThrowsForDockerBuildArguments() File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); - var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") .WithDockerfile(sourceRoot.Path, "Dockerfile") - .WithBuildArg("FOO", "bar") - .PublishAsVercel(vercel); + .WithBuildArg("FOO", "bar"); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -725,8 +706,7 @@ public async Task DeployAsyncRunsVercelCliAndSavesDeploymentState() .WithVercelScope("team"); builder.AddContainer("api", "api") .WithDockerfile(sourceRoot.Path, "Dockerfile") - .WithEnvironment("GREETING", "hello") - .PublishAsVercel(vercel); + .WithEnvironment("GREETING", "hello"); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -766,15 +746,14 @@ public async Task DeployAsyncStagesGeneratedDockerfileBeforeRunningVercelCli() builder.Services.AddSingleton(runner); builder.Services.AddSingleton(stateManager); builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); - var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") .WithDockerfileFactory(sourceRoot.Path, _ => Task.FromResult(""" FROM node:22-alpine WORKDIR /app COPY server.mjs . CMD ["node", "server.mjs"] - """)) - .PublishAsVercel(vercel); + """)); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -810,10 +789,9 @@ public async Task DeployAsyncStagesCustomDockerfileNameBeforeRunningVercelCli() builder.Services.AddSingleton(runner); builder.Services.AddSingleton(stateManager); builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); - var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile.custom") - .PublishAsVercel(vercel); + .WithDockerfile(sourceRoot.Path, "Dockerfile.custom"); using var app = builder.Build(); var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); @@ -842,8 +820,7 @@ public async Task DeployAsyncThrowsWhenVercelCliFails() var vercel = builder.AddVercelEnvironment("vercel") .WithVercelCliPath("vercel-test"); builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile") - .PublishAsVercel(vercel); + .WithDockerfile(sourceRoot.Path, "Dockerfile"); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -944,7 +921,7 @@ public async Task ValidatePrerequisitesThrowsWhenNoResourcesArePublished() var exception = await Assert.ThrowsAsync(() => VercelDeploymentStep.ValidatePrerequisitesAsync(context, environment)); - Assert.Contains("No resources are configured for Vercel deployment", exception.Message); + Assert.Contains("No Dockerfile-backed compute resources target Vercel", exception.Message); } [Fact] @@ -1001,10 +978,9 @@ public async Task DestroyAsyncFallsBackToConfiguredDeploymentsWhenStateIsMissing var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); builder.Services.AddSingleton(new FakeDeploymentStateManager()); - var vercel = builder.AddVercelEnvironment("vercel"); + builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile") - .PublishAsVercel(vercel); + .WithDockerfile(sourceRoot.Path, "Dockerfile"); using var app = builder.Build(); var model = app.Services.GetRequiredService(); From ad8cddd4496e3f15bae9ca55af0522cebd38dce5 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 30 Jun 2026 13:30:19 -0700 Subject: [PATCH 06/33] Remove Vercel CLI path API Use the standard vercel executable from PATH for deploy and destroy instead of modeling CLI path configuration in the AppHost API. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../apphost.mts | 1 - .../README.md | 2 +- .../VercelCliRunner.cs | 2 +- .../VercelDeploymentStep.cs | 18 +++---- .../VercelEnvironmentOptionsAnnotation.cs | 4 -- ...celEnvironmentResourceBuilderExtensions.cs | 17 ------- .../VercelEnvironmentTests.cs | 48 ++++--------------- 7 files changed, 19 insertions(+), 73 deletions(-) diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts index 5e8d8e9f2..47d000be9 100644 --- a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts @@ -3,7 +3,6 @@ import { createBuilder } from "./.aspire/modules/aspire.mjs"; const builder = await createBuilder(); const vercel = await builder.addVercelEnvironment("vercel"); -await vercel.withVercelCliPath("vercel"); await vercel.withVercelProductionDeployments(); const api = await builder diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md index 031f563e9..85105cd02 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md @@ -72,7 +72,7 @@ vercel --cwd deploy --yes --env GREETING=hello - An Aspire workload with Dockerfile publish metadata, such as a language app resource that emits Dockerfile metadata or a project configured with `PublishAsDockerFile`. - The deployed container should listen on `$PORT`; the default port is `80`. -- Vercel CLI installed and on `PATH`, or configured with `WithVercelCliPath`. +- Vercel CLI installed and available on `PATH`. - Vercel authentication from an existing CLI login or the `VERCEL_TOKEN` environment variable. - Any project linking, secrets, domains, deployment protection, or build-time environment variables configured in Vercel. diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs index 22285ca38..7e1980fe4 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs @@ -62,7 +62,7 @@ public async Task RunAsync(string fileName, IReadOnlyList(); - var versionResult = await runner.RunAsync(options.CliPath, ["--version"], workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + var versionResult = await runner.RunAsync(VercelCliFileName, ["--version"], workingDirectory: null, context.CancellationToken).ConfigureAwait(false); if (!versionResult.Succeeded) { - throw CreateCliException("validate Vercel CLI installation", options.CliPath, versionResult); + throw CreateCliException("validate Vercel CLI installation", VercelCliFileName, versionResult); } - var whoamiResult = await runner.RunAsync(options.CliPath, ["whoami"], workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + var whoamiResult = await runner.RunAsync(VercelCliFileName, ["whoami"], workingDirectory: null, context.CancellationToken).ConfigureAwait(false); if (!whoamiResult.Succeeded) { - throw CreateCliException("validate Vercel authentication", options.CliPath, whoamiResult); + throw CreateCliException("validate Vercel authentication", VercelCliFileName, whoamiResult); } } @@ -136,11 +136,11 @@ public static async Task DeployAsync(PipelineStepContext context, VercelEnvironm preparedEntry, context.CancellationToken).ConfigureAwait(false); - var result = await runner.RunAsync(options.CliPath, arguments, preparedEntry.SourceRoot, context.CancellationToken).ConfigureAwait(false); + var result = await runner.RunAsync(VercelCliFileName, arguments, preparedEntry.SourceRoot, context.CancellationToken).ConfigureAwait(false); if (!result.Succeeded) { - throw CreateCliException($"deploy resource '{entry.Resource.Name}' to Vercel", options.CliPath, result); + throw CreateCliException($"deploy resource '{entry.Resource.Name}' to Vercel", VercelCliFileName, result); } var deploymentResult = GetDeploymentResult(result.StandardOutput); @@ -200,11 +200,11 @@ public static async Task DestroyAsync(PipelineStepContext context, VercelEnviron foreach (string projectName in projects) { string[] arguments = BuildDestroyProjectArguments(options, projectName); - var result = await runner.RunAsync(options.CliPath, arguments, workingDirectory: null, context.CancellationToken, standardInput: "y\n").ConfigureAwait(false); + var result = await runner.RunAsync(VercelCliFileName, arguments, workingDirectory: null, context.CancellationToken, standardInput: "y\n").ConfigureAwait(false); if (!result.Succeeded) { - throw CreateCliException($"destroy Vercel project '{projectName}'", options.CliPath, result); + throw CreateCliException($"destroy Vercel project '{projectName}'", VercelCliFileName, result); } context.Summary.Add("Vercel project removed", projectName); diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentOptionsAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentOptionsAnnotation.cs index 11b1480f4..55f6c999a 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentOptionsAnnotation.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentOptionsAnnotation.cs @@ -4,10 +4,6 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; internal sealed record VercelEnvironmentOptionsAnnotation : IResourceAnnotation { - public const string DefaultCliPath = "vercel"; - - public string CliPath { get; init; } = DefaultCliPath; - public string? Scope { get; init; } public string? Target { get; init; } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs index 7afe687ae..cd51041b6 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs @@ -88,23 +88,6 @@ public static IResourceBuilder AddVercelEnvironment( ]); } - /// - /// Configures the Vercel CLI executable path used by deploy and destroy pipeline steps. - /// - /// The Vercel environment builder. - /// The executable name or absolute path for the Vercel CLI. - /// The Vercel environment builder. - [AspireExport] - public static IResourceBuilder WithVercelCliPath( - this IResourceBuilder builder, - string cliPath) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentException.ThrowIfNullOrWhiteSpace(cliPath); - - return builder.WithVercelOptions(options => options with { CliPath = cliPath }); - } - /// /// Configures the Vercel team or account scope for deployments. /// diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs index 850505ff8..e6627184a 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs @@ -93,13 +93,11 @@ public void VercelEnvironmentOptionsCanBeConfigured() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); var vercel = builder.AddVercelEnvironment("vercel") - .WithVercelCliPath("vercel-test") .WithVercelTarget("preview") .WithVercelProductionDeployments() .WithVercelTarget("staging"); var options = vercel.Resource.GetVercelOptions(); - Assert.Equal("vercel-test", options.CliPath); Assert.Equal("staging", options.Target); Assert.False(options.Production); } @@ -162,29 +160,6 @@ public async Task AddVercelEnvironmentRegistersExpectedPipelineSteps() }); } - [Fact] - public void WithVercelCliPathShouldThrowWhenBuilderIsNull() - { - IResourceBuilder builder = null!; - - var action = () => builder.WithVercelCliPath("vercel"); - - var exception = Assert.Throws(action); - Assert.Equal(nameof(builder), exception.ParamName); - } - - [Fact] - public void WithVercelCliPathShouldThrowWhenCliPathIsEmpty() - { - var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); - var vercel = builder.AddVercelEnvironment("vercel"); - - var action = () => vercel.WithVercelCliPath(""); - - var exception = Assert.Throws(action); - Assert.Equal("cliPath", exception.ParamName); - } - [Fact] public void WithVercelProductionDeploymentsShouldThrowWhenBuilderIsNull() { @@ -702,7 +677,6 @@ public async Task DeployAsyncRunsVercelCliAndSavesDeploymentState() builder.Services.AddSingleton(runner); builder.Services.AddSingleton(stateManager); var vercel = builder.AddVercelEnvironment("vercel") - .WithVercelCliPath("vercel-test") .WithVercelScope("team"); builder.AddContainer("api", "api") .WithDockerfile(sourceRoot.Path, "Dockerfile") @@ -716,7 +690,7 @@ public async Task DeployAsyncRunsVercelCliAndSavesDeploymentState() await VercelDeploymentStep.DeployAsync(context, environment); var invocation = Assert.Single(runner.Invocations); - Assert.Equal("vercel-test", invocation.FileName); + Assert.Equal("vercel", invocation.FileName); Assert.Equal(sourceRoot.Path, invocation.WorkingDirectory); Assert.Equal(["--scope", "team", "--cwd", sourceRoot.Path, "deploy", "--yes", "--env", "GREETING=hello"], invocation.Arguments); Assert.Null(invocation.StandardInput); @@ -817,8 +791,7 @@ public async Task DeployAsyncThrowsWhenVercelCliFails() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); builder.Services.AddSingleton(new FakeDeploymentStateManager()); - var vercel = builder.AddVercelEnvironment("vercel") - .WithVercelCliPath("vercel-test"); + builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") .WithDockerfile(sourceRoot.Path, "Dockerfile"); @@ -830,7 +803,7 @@ public async Task DeployAsyncThrowsWhenVercelCliFails() var exception = await Assert.ThrowsAsync(() => VercelDeploymentStep.DeployAsync(context, environment)); - Assert.Contains("Failed to deploy resource 'api' to Vercel using 'vercel-test' (exit code 1). deploy failed", exception.Message); + Assert.Contains("Failed to deploy resource 'api' to Vercel using 'vercel' (exit code 1). deploy failed", exception.Message); } [Fact] @@ -842,8 +815,7 @@ public async Task ValidateCliPrerequisitesRunsVersionAndWhoami() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); - var vercel = builder.AddVercelEnvironment("vercel") - .WithVercelCliPath("vercel-test"); + builder.AddVercelEnvironment("vercel"); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -867,8 +839,7 @@ public async Task ValidateCliPrerequisitesThrowsWhenWhoamiFails() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); - var vercel = builder.AddVercelEnvironment("vercel") - .WithVercelCliPath("vercel-test"); + builder.AddVercelEnvironment("vercel"); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -878,7 +849,7 @@ public async Task ValidateCliPrerequisitesThrowsWhenWhoamiFails() var exception = await Assert.ThrowsAsync(() => VercelDeploymentStep.ValidateCliPrerequisitesAsync(context, environment)); - Assert.Contains("Failed to validate Vercel authentication using 'vercel-test' (exit code 1). not logged in", exception.Message); + Assert.Contains("Failed to validate Vercel authentication using 'vercel' (exit code 1). not logged in", exception.Message); } [Fact] @@ -888,8 +859,7 @@ public async Task ValidateCliPrerequisitesThrowsWhenVersionFailsWithStandardOutp var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); - var vercel = builder.AddVercelEnvironment("vercel") - .WithVercelCliPath("vercel-test"); + builder.AddVercelEnvironment("vercel"); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -899,7 +869,7 @@ public async Task ValidateCliPrerequisitesThrowsWhenVersionFailsWithStandardOutp var exception = await Assert.ThrowsAsync(() => VercelDeploymentStep.ValidateCliPrerequisitesAsync(context, environment)); - Assert.Contains("Failed to validate Vercel CLI installation using 'vercel-test' (exit code 1). missing vercel", exception.Message); + Assert.Contains("Failed to validate Vercel CLI installation using 'vercel' (exit code 1). missing vercel", exception.Message); } [Fact] @@ -943,7 +913,6 @@ public async Task DestroyAsyncDeletesProjectsFromSavedDeploymentState() builder.Services.AddSingleton(runner); builder.Services.AddSingleton(stateManager); var vercel = builder.AddVercelEnvironment("vercel") - .WithVercelCliPath("vercel-test") .WithVercelScope("team"); using var app = builder.Build(); @@ -1166,7 +1135,6 @@ public void GetVercelOptionsReturnsDefaultOptionsWithoutAnnotation() var options = resource.GetVercelOptions(); - Assert.Equal("vercel", options.CliPath); Assert.Null(options.Scope); Assert.Null(options.Target); Assert.False(options.Production); From b14672ad22152132fef164ca61323f0e1d1c16a0 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 30 Jun 2026 16:21:31 -0700 Subject: [PATCH 07/33] Harden Vercel deployment integration Add state-safe deploy/destroy behavior, Vercel inspect verification, staging safeguards, explicit unsupported-feature validation, TypeScript publish coverage, and CI matrix compatibility for the Vercel deployment target. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../generate-test-list-for-workflow.sh | 8 +- .../apphost.mts | 2 + .../aspire.config.json | 1 + .../README.md | 37 +- .../VercelDeploymentStep.cs | 577 +++++++++- .../TypeScriptAppHostTests.cs | 37 +- .../VercelEnvironmentTests.cs | 987 ++++++++++++++++-- 7 files changed, 1479 insertions(+), 170 deletions(-) diff --git a/eng/testing/generate-test-list-for-workflow.sh b/eng/testing/generate-test-list-for-workflow.sh index 91f1afba4..1934e292e 100755 --- a/eng/testing/generate-test-list-for-workflow.sh +++ b/eng/testing/generate-test-list-for-workflow.sh @@ -22,13 +22,7 @@ case "${MODE}" in get_test_names ;; --json) - mapfile -t tests < <(get_test_names) - python - <<'PY' "${tests[@]}" -import json -import sys - -print(json.dumps(sys.argv[1:])) -PY + get_test_names | python3 -c 'import json, sys; print(json.dumps([line.rstrip("\n") for line in sys.stdin if line.rstrip("\n")]))' ;; --workflow) echo "${INDENT}# Hosting integration tests" diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts index 47d000be9..0060f506b 100644 --- a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts @@ -4,10 +4,12 @@ const builder = await createBuilder(); const vercel = await builder.addVercelEnvironment("vercel"); await vercel.withVercelProductionDeployments(); +await builder.addDockerComposeEnvironment("docker"); const api = await builder .addNodeApp("api", "./ct-aspire-vercel-typescript", "server.mjs"); +await api.withComputeEnvironment(vercel); await api.withEnvironment("GREETING", "hello-from-typescript-apphost"); await builder.build().run(); diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/aspire.config.json b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/aspire.config.json index e6a080f1d..cbf58e69d 100644 --- a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/aspire.config.json +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/aspire.config.json @@ -17,6 +17,7 @@ }, "packages": { "Aspire.Hosting.JavaScript": "13.4.3", + "Aspire.Hosting.Docker": "13.4.3", "CommunityToolkit.Aspire.Hosting.Vercel": "../../../src/CommunityToolkit.Aspire.Hosting.Vercel/CommunityToolkit.Aspire.Hosting.Vercel.csproj" } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md index 85105cd02..ef508466a 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md @@ -34,7 +34,7 @@ builder.AddProject("api") By default, `aspire deploy` runs: ```bash -vercel --cwd deploy --yes +vercel --cwd deploy --yes ``` Use `WithVercelProductionDeployments` to add `--prod`, `WithVercelTarget` to add `--target`, and `WithVercelScope` to deploy to a team or account scope. @@ -48,7 +48,7 @@ builder.AddNodeApp("api", "../api", "server.mjs") .WithComputeEnvironment(vercel); ``` -For low-level container resources, Vercel requires existing Aspire Dockerfile build metadata. Prefer the workload-specific `Add*App` integration when one exists. `WithDockerfile`, `WithDockerfileFactory`, and `WithDockerfileBuilder` are supported as advanced escape hatches. If Aspire generates the Dockerfile, or if the configured Dockerfile is not named `Dockerfile` in the context root, the integration stages the source root and materializes the Vercel-facing `Dockerfile` before running `vercel deploy`. +For low-level container resources, Vercel requires existing Aspire Dockerfile build metadata. Prefer the workload-specific `Add*App` integration when one exists. `WithDockerfile`, `WithDockerfileFactory`, and `WithDockerfileBuilder` are supported as advanced escape hatches. The integration always stages the source root into Aspire's deploy-time temp directory, materializes the Vercel-facing `Dockerfile` there, and runs `vercel deploy` from the staged directory so the Vercel CLI does not write `.vercel` metadata into the source tree. Staging skips `.git`, `node_modules`, and unmanaged `.vercel` metadata; linked projects keep only `.vercel/project.json`. Non-secret Aspire environment variables configured on the resource are processed during publish/deploy and passed to Vercel as CLI environment variables: @@ -58,15 +58,15 @@ builder.AddNodeApp("api", "../api", "server.mjs") ``` ```bash -vercel --cwd deploy --yes --env GREETING=hello +vercel --cwd deploy --yes --env GREETING=hello ``` ## Deployment behavior - `aspire start` is unchanged. The Vercel environment is publish/deploy-only. -- `aspire publish` writes a `vercel-deployments.json` plan for the targeted resources, including the environment variable names passed to Vercel. -- `aspire deploy` validates the Vercel CLI, validates authentication, materializes any Aspire-generated Dockerfile, and invokes `vercel deploy`. -- `aspire destroy` removes the Vercel projects recorded during deployment. The Aspire CLI destroy confirmation applies before the project removal step runs. +- `aspire publish` writes a deterministic `vercel-deployments.json` plan for the targeted resources, including the environment variable names passed to Vercel. It does not require a container registry. +- `aspire deploy` validates the Vercel CLI, validates authentication and configured scope, stages the source root, materializes the Dockerfile, invokes `vercel deploy`, and verifies the resulting deployment with `vercel inspect --wait --format=json` before saving deployment state. Vercel performs the remote source upload/build; Aspire does not build or push container images for this environment. The deployment summary and state record the deployment URL, and production deployments also record the deterministic `https://.vercel.app` production URL. +- `aspire destroy` reads Aspire deployment state and removes only Vercel projects that were created by this integration for the same environment/scope. Missing state is treated as a no-op, and state is preserved if project removal fails. ## Prerequisites @@ -76,15 +76,26 @@ vercel --cwd deploy --yes --env GREETING=hello - Vercel authentication from an existing CLI login or the `VERCEL_TOKEN` environment variable. - Any project linking, secrets, domains, deployment protection, or build-time environment variables configured in Vercel. -## Known limitations - -This integration does not manage Vercel secrets, provision marketplace resources, or translate Aspire service discovery references. +Vercel supports project environment variables through `vercel env add/update`, including sensitive production and preview variables supplied on stdin. This preview integration does not mutate project environment settings yet because those values are project-scoped and require explicit ownership/update semantics. Secret Aspire values are therefore rejected instead of being passed through `vercel deploy --env`. -`aspire destroy` deletes the Vercel projects for resources targeted by this integration. Use a dedicated Vercel project for each Aspire resource, or do not run `aspire destroy` if the project contains deployments that are managed outside the Aspire AppHost. - -Secret Aspire environment variables, connection strings, Docker build arguments, and Docker build secrets are rejected. Configure those values in Vercel project settings instead, because Vercel runs the Dockerfile build itself and Vercel CLI `--env` would put secret values on the command line. +## Known limitations -Aspire command-line arguments and container entrypoint overrides are also rejected. Configure runtime command behavior through the workload's publish support or Vercel project settings instead. +This preview integration intentionally supports a narrow Vercel Dockerfile deployment contract: + +| Aspire concept | Preview behavior | +| --- | --- | +| Dockerfile-backed compute resources | Supported through existing Aspire Dockerfile metadata and Vercel remote builds. | +| Container registries, image build, image push | Not used. Vercel uploads and builds the staged source tree. | +| Non-secret environment variables | Passed with `vercel deploy --env KEY=value`; names must use letters, digits, and underscores and values are redacted from publish output. | +| Secret parameters, connection strings, Docker build args/secrets | Rejected, including composite values that contain secrets. Configure them in Vercel project environment variables or Vercel secrets. | +| Service discovery, endpoint references, `WithReference` to another resource | Rejected because preview deployments only expose post-deploy output URLs and do not provide stable pre-deploy endpoint expressions. | +| Endpoints | HTTP and HTTPS endpoints are accepted when they map to one target port. Non-HTTP(S) endpoints or multiple target ports are rejected. | +| Volumes, bind mounts, Aspire container files | Rejected. Include required files in the source tree or use a Vercel-supported external service for state. | +| Health checks, probes, replicas, wait/dependency ordering | Rejected until they are mapped to Vercel-native behavior. | +| Container entrypoint overrides and Aspire command-line args | Rejected. Configure runtime command behavior through the Dockerfile/workload publish output or Vercel project settings. | +| Existing linked Vercel projects | Supported for deploy. Destroy preserves projects that were linked before deploy and only deletes Aspire-created projects recorded in state. | + +Managed Vercel project names are inferred from the source directory name when no `.vercel/project.json` link exists. The integration slugifies the inferred name to Vercel's lowercase, hyphenated project-name form before staging and deployment. This preview intentionally does not expose resource-level project naming, alias, framework/output, or per-resource target APIs; link a Vercel project before deploy when you need an exact existing project, and add future per-resource settings through resource-specific annotations rather than overloading the environment. ## Additional documentation diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs index ccf4c1182..bd19a9147 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs @@ -1,6 +1,7 @@ #pragma warning disable ASPIREPIPELINES001 #pragma warning disable ASPIREPIPELINES002 #pragma warning disable ASPIREPIPELINES004 +#pragma warning disable ASPIREPROBES001 #pragma warning disable CTASPIREVERCEL001 using Aspire.Hosting; @@ -10,6 +11,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System.Diagnostics.CodeAnalysis; +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -25,6 +27,8 @@ internal static class VercelDeploymentStep public const string DeploymentPlanFileName = "vercel-deployments.json"; private const string StateSectionNamePrefix = "communitytoolkit.vercel."; + private const int DeploymentStateSchemaVersion = 1; + private const int VercelProjectNameMaxLength = 100; private const string VercelCliFileName = "vercel"; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) @@ -101,6 +105,7 @@ public static async Task ValidatePrerequisitesAsync(PipelineStepContext context, public static async Task ValidateCliPrerequisitesAsync(PipelineStepContext context, VercelEnvironmentResource environment) { + var options = environment.GetVercelOptions(); var runner = context.Services.GetRequiredService(); var versionResult = await runner.RunAsync(VercelCliFileName, ["--version"], workingDirectory: null, context.CancellationToken).ConfigureAwait(false); @@ -114,6 +119,15 @@ public static async Task ValidateCliPrerequisitesAsync(PipelineStepContext conte { throw CreateCliException("validate Vercel authentication", VercelCliFileName, whoamiResult); } + + if (!string.IsNullOrWhiteSpace(options.Scope)) + { + var scopeResult = await runner.RunAsync(VercelCliFileName, BuildValidateScopeArguments(options), workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + if (!scopeResult.Succeeded) + { + throw CreateCliException($"validate Vercel scope '{options.Scope}'", VercelCliFileName, scopeResult); + } + } } public static async Task DeployAsync(PipelineStepContext context, VercelEnvironmentResource environment) @@ -129,6 +143,7 @@ public static async Task DeployAsync(PipelineStepContext context, VercelEnvironm foreach (var entry in entries) { var preparedEntry = await PrepareDeploymentEntryAsync(context, entry).ConfigureAwait(false); + bool managedByAspire = !HasVercelProjectLinkFile(preparedEntry.SourceRoot); string[] arguments = await BuildDeployArgumentsAsync( context.ExecutionContext, context.Logger, @@ -144,18 +159,31 @@ public static async Task DeployAsync(PipelineStepContext context, VercelEnvironm } var deploymentResult = GetDeploymentResult(result.StandardOutput); - string projectName = GetVercelProjectName(preparedEntry); + await VerifyDeploymentAsync(context, runner, options, entry.Resource.Name, deploymentResult).ConfigureAwait(false); + + var projectLink = GetVercelProjectLink(preparedEntry); + string? productionUrl = GetProductionUrl(options, projectLink.ProjectName); stateEntries.Add(new( entry.Resource.Name, - projectName, + projectLink.ProjectName, + projectLink.ProjectId, deploymentResult.DeploymentId, - deploymentResult.DeploymentUrl)); + deploymentResult.DeploymentUrl, + entry.SourceRoot, + managedByAspire) + { + ProductionUrl = productionUrl + }); context.Summary.Add($"{entry.Resource.Name} Vercel deployment", deploymentResult.DeploymentUrl); + if (productionUrl is not null) + { + context.Summary.Add($"{entry.Resource.Name} Vercel production URL", productionUrl); + } } - await SaveDeploymentStateAsync(context, environment, stateEntries).ConfigureAwait(false); + await SaveDeploymentStateAsync(context, environment, options, stateEntries).ConfigureAwait(false); } internal static string[] BuildDeployArguments(VercelEnvironmentOptionsAnnotation options, VercelDeploymentEntry entry) @@ -178,14 +206,83 @@ internal static string[] BuildDestroyProjectArguments(VercelEnvironmentOptionsAn return [.. arguments]; } + internal static string[] BuildInspectDeploymentArguments(VercelEnvironmentOptionsAnnotation options, string deploymentUrl) + { + List arguments = []; + + if (!string.IsNullOrWhiteSpace(options.Scope)) + { + arguments.Add("--scope"); + arguments.Add(options.Scope); + } + + arguments.Add("inspect"); + arguments.Add(deploymentUrl); + arguments.Add("--wait"); + arguments.Add("--timeout"); + arguments.Add("120s"); + arguments.Add("--format=json"); + + return [.. arguments]; + } + + internal static string[] BuildValidateScopeArguments(VercelEnvironmentOptionsAnnotation options) + { + List arguments = []; + + if (!string.IsNullOrWhiteSpace(options.Scope)) + { + arguments.Add("--scope"); + arguments.Add(options.Scope); + } + + arguments.Add("project"); + arguments.Add("ls"); + arguments.Add("--format=json"); + + return [.. arguments]; + } + + private static async Task VerifyDeploymentAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + string resourceName, + VercelDeploymentResult deploymentResult) + { + string[] arguments = BuildInspectDeploymentArguments(options, deploymentResult.DeploymentUrl); + var result = await runner.RunAsync(VercelCliFileName, arguments, workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + + if (!result.Succeeded) + { + throw CreateCliException($"verify Vercel deployment for resource '{resourceName}'", VercelCliFileName, result); + } + + var inspection = GetDeploymentInspection(result.StandardOutput); + if (inspection.ReadyState is not null + && !string.Equals(inspection.ReadyState, "READY", StringComparison.OrdinalIgnoreCase)) + { + throw new DistributedApplicationException($"Vercel deployment for resource '{resourceName}' finished with state '{inspection.ReadyState}' instead of 'READY'."); + } + } + public static async Task DestroyAsync(PipelineStepContext context, VercelEnvironmentResource environment) { var options = environment.GetVercelOptions(); var runner = context.Services.GetRequiredService(); var stateManager = context.Services.GetRequiredService(); var stateSection = await stateManager.AcquireSectionAsync(GetStateSectionName(environment), context.CancellationToken).ConfigureAwait(false); - VercelDeploymentState state = ReadDeploymentState(stateSection) ?? GetFallbackDeploymentState(context.Model, environment); + var state = ReadDeploymentState(stateSection); + if (state is null) + { + context.Summary.Add("Vercel destroy", $"No Vercel deployment state was found for environment '{environment.Name}'. Nothing to destroy."); + return; + } + + ValidateDeploymentState(environment, options, state); + var projects = state.Deployments + .Where(static deployment => deployment.ManagedByAspire) .Select(static deployment => deployment.ProjectName) .Distinct(StringComparer.Ordinal) .Order(StringComparer.Ordinal) @@ -193,7 +290,8 @@ public static async Task DestroyAsync(PipelineStepContext context, VercelEnviron if (projects.Length == 0) { - context.Summary.Add("Vercel destroy", $"No Vercel deployments were found for environment '{environment.Name}'."); + context.Summary.Add("Vercel destroy", $"No Aspire-managed Vercel deployments were found for environment '{environment.Name}'."); + await stateManager.DeleteSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); return; } @@ -204,10 +302,21 @@ public static async Task DestroyAsync(PipelineStepContext context, VercelEnviron if (!result.Succeeded) { - throw CreateCliException($"destroy Vercel project '{projectName}'", VercelCliFileName, result); + if (!IsMissingProjectResult(result, projectName)) + { + throw CreateCliException($"destroy Vercel project '{projectName}'", VercelCliFileName, result); + } + + context.Summary.Add("Vercel project already absent", projectName); + } + else + { + context.Summary.Add("Vercel project removed", projectName); } - context.Summary.Add("Vercel project removed", projectName); + state = RemoveManagedProjectFromDeploymentState(state, projectName); + stateSection.SetValue(JsonSerializer.Serialize(state, JsonOptions)); + await stateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); } await stateManager.DeleteSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); @@ -332,11 +441,18 @@ private static string BuildDisplayDeployCommand( private static async Task SaveDeploymentStateAsync( PipelineStepContext context, VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, IReadOnlyList deployments) { var stateManager = context.Services.GetRequiredService(); var stateSection = await stateManager.AcquireSectionAsync(GetStateSectionName(environment), context.CancellationToken).ConfigureAwait(false); - var state = new VercelDeploymentState(environment.Name, [.. deployments]); + var state = new VercelDeploymentState( + DeploymentStateSchemaVersion, + environment.Name, + NormalizeScope(options.Scope), + NormalizeTarget(options.Target), + options.Production, + [.. deployments]); stateSection.SetValue(JsonSerializer.Serialize(state, JsonOptions)); await stateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); @@ -344,28 +460,82 @@ private static async Task SaveDeploymentStateAsync( private static VercelDeploymentState? ReadDeploymentState(DeploymentStateSection stateSection) { - if (!stateSection.Data.TryGetPropertyValue("value", out JsonNode? value) - || value is null) + if (stateSection.Data.TryGetPropertyValue("value", out JsonNode? value) + && value is not null) { - return null; + return DeserializeDeploymentState(value); + } + + value = stateSection.Data.FirstOrDefault().Value; + if (value is not null) + { + return DeserializeDeploymentState(value); } + if (stateSection.Data.ContainsKey("schemaVersion")) + { + return stateSection.Data.Deserialize(JsonOptions); + } + + return null; + } + + private static VercelDeploymentState? DeserializeDeploymentState(JsonNode value) + { return value.GetValueKind() == JsonValueKind.String ? JsonSerializer.Deserialize(value.GetValue(), JsonOptions) : value.Deserialize(JsonOptions); } - private static VercelDeploymentState GetFallbackDeploymentState(DistributedApplicationModel model, VercelEnvironmentResource environment) + private static void ValidateDeploymentState( + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentState state) { - var deployments = GetDeploymentEntries(model, environment) - .Select(static entry => new VercelDeploymentStateEntry( - entry.Resource.Name, - GetVercelProjectName(entry), - DeploymentId: null, - DeploymentUrl: null)) - .ToArray(); + if (state.SchemaVersion != DeploymentStateSchemaVersion) + { + throw new DistributedApplicationException($"Vercel deployment state for environment '{environment.Name}' uses unsupported schema version '{state.SchemaVersion}'. Redeploy the environment before destroying it."); + } + + if (!string.Equals(state.Environment, environment.Name, StringComparison.Ordinal)) + { + throw new DistributedApplicationException($"Vercel deployment state for environment '{state.Environment}' cannot be used to destroy environment '{environment.Name}'."); + } - return new(environment.Name, deployments); + string? configuredScope = NormalizeScope(options.Scope); + if (!string.Equals(state.Scope, configuredScope, StringComparison.Ordinal)) + { + string stateScope = string.IsNullOrWhiteSpace(state.Scope) ? "" : state.Scope; + string requestedScope = string.IsNullOrWhiteSpace(configuredScope) ? "" : configuredScope; + throw new DistributedApplicationException($"Vercel deployment state for environment '{environment.Name}' was created for scope '{stateScope}', but destroy is configured for scope '{requestedScope}'. Use the same Vercel scope that created the deployment state."); + } + } + + private static string? NormalizeScope(string? scope) + => string.IsNullOrWhiteSpace(scope) ? null : scope; + + private static string? NormalizeTarget(string? target) + => string.IsNullOrWhiteSpace(target) ? null : target; + + private static string? GetProductionUrl(VercelEnvironmentOptionsAnnotation options, string projectName) + => options.Production ? $"https://{projectName}.vercel.app" : null; + + private static VercelDeploymentState RemoveManagedProjectFromDeploymentState(VercelDeploymentState state, string projectName) + => state with + { + Deployments = state.Deployments + .Where(deployment => !deployment.ManagedByAspire || !string.Equals(deployment.ProjectName, projectName, StringComparison.Ordinal)) + .ToArray() + }; + + private static bool IsMissingProjectResult(VercelCliResult result, string projectName) + { + string output = $"{result.StandardOutput}{Environment.NewLine}{result.StandardError}"; + return output.Contains(projectName, StringComparison.OrdinalIgnoreCase) + && (output.Contains("not found", StringComparison.OrdinalIgnoreCase) + || output.Contains("could not find", StringComparison.OrdinalIgnoreCase) + || output.Contains("does not exist", StringComparison.OrdinalIgnoreCase) + || output.Contains("doesn't exist", StringComparison.OrdinalIgnoreCase)); } private static string GetStateSectionName(VercelEnvironmentResource environment) => $"{StateSectionNamePrefix}{environment.Name}"; @@ -375,6 +545,7 @@ private static IReadOnlyList> GetVercelEnvironmentV IExecutionConfigurationResult executionConfiguration) { List> environmentVariables = []; + HashSet names = new(StringComparer.Ordinal); foreach (var environmentVariable in executionConfiguration.EnvironmentVariablesWithUnprocessed) { @@ -382,18 +553,42 @@ private static IReadOnlyList> GetVercelEnvironmentV object unprocessedValue = environmentVariable.Value.Item1; string value = environmentVariable.Value.Item2; + ValidateEnvironmentVariableName(resource, name); + if (!names.Add(name)) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures environment variable '{name}' more than once. Vercel project environment variable names must be unique."); + } + if (ContainsSecretReference(unprocessedValue)) { throw new DistributedApplicationException( $"Environment variable '{name}' for resource '{resource.Name}' references a secret or connection string. Vercel CLI --env would pass the value on the command line, so configure this value in Vercel project environment variables or a Vercel secret instead."); } + if (ContainsRemoteResourceReference(resource, unprocessedValue)) + { + throw new DistributedApplicationException( + $"Environment variable '{name}' for resource '{resource.Name}' references another Aspire resource endpoint or service. Vercel deployment URLs are not available until after deployment, so cross-resource endpoint and service discovery references are not supported by the Vercel preview integration."); + } + environmentVariables.Add(new(name, value)); } return environmentVariables; } + private static void ValidateEnvironmentVariableName(IResource resource, string name) + { + if (string.IsNullOrWhiteSpace(name) + || (!char.IsAsciiLetter(name[0]) && name[0] != '_') + || name.Any(static character => !char.IsAsciiLetterOrDigit(character) && character != '_')) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures invalid Vercel environment variable name '{name}'. Use letters, digits, and underscores, and start with a letter or underscore."); + } + } + private static void ValidateUnsupportedRuntimeConfiguration( IResource resource, IExecutionConfigurationResult executionConfiguration) @@ -433,6 +628,22 @@ private static bool ContainsSecretReference(object? value) }; } + private static bool ContainsRemoteResourceReference(IResource resource, object? value) + { + return value switch + { + null => false, + string => false, + ParameterResource => false, + IResourceBuilder => false, + EndpointReference => true, + EndpointReferenceExpression => true, + IResource referencedResource => !ReferenceEquals(referencedResource, resource), + IValueWithReferences valueWithReferences => valueWithReferences.References.Any(reference => ContainsRemoteResourceReference(resource, reference)), + _ => IsResourceBuilder(value, resource) + }; + } + private static bool IsConnectionStringResourceBuilder(object value) { return value.GetType() @@ -443,6 +654,26 @@ private static bool IsConnectionStringResourceBuilder(object value) && typeof(IResourceWithConnectionString).IsAssignableFrom(interfaceType.GetGenericArguments()[0])); } + private static bool IsResourceBuilder(object value, IResource resource) + { + return value.GetType() + .GetInterfaces() + .Any(interfaceType => + interfaceType.IsGenericType + && interfaceType.GetGenericTypeDefinition() == typeof(IResourceBuilder<>) + && typeof(IResource).IsAssignableFrom(interfaceType.GetGenericArguments()[0]) + && !typeof(ParameterResource).IsAssignableFrom(interfaceType.GetGenericArguments()[0]) + && TryGetResource(value, out var referencedResource) + && !ReferenceEquals(referencedResource, resource)); + } + + private static bool TryGetResource(object value, [NotNullWhen(true)] out IResource? resource) + { + resource = value.GetType().GetProperty(nameof(IResourceBuilder.Resource))?.GetValue(value) as IResource; + return resource is not null; + } + + internal static IEnumerable GetDeploymentEntries(DistributedApplicationModel model, VercelEnvironmentResource environment) { foreach (var resource in model.Resources.OfType()) @@ -485,16 +716,97 @@ private static void ValidateEntries(IReadOnlyList entries { throw new DistributedApplicationException($"The Vercel Dockerfile '{entry.DockerfilePath}' for resource '{entry.Resource.Name}' does not exist. Configure the resource with an existing Dockerfile or Aspire-generated Dockerfile metadata."); } + + ValidateUnsupportedResourceModel(entry); } } - private static async Task PrepareDeploymentEntryAsync(PipelineStepContext context, VercelDeploymentEntry entry) + private static void ValidateUnsupportedResourceModel(VercelDeploymentEntry entry) { - if (!RequiresStaging(entry)) + IResource resource = entry.Resource; + + if (resource.Annotations.OfType().Any()) { - return entry; + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures Aspire container volumes or bind mounts, but Vercel Dockerfile deployments do not support Aspire-managed container mounts. Move persistent state to a Vercel-supported external service or remove the mount."); + } + + if (resource.Annotations.OfType().Any() + || resource.Annotations.OfType().Any() + || resource.Annotations.OfType().Any()) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures Aspire container file mounts, but Vercel Dockerfile deployments upload the source tree and Dockerfile only. Include required files in the source tree or generated Dockerfile output instead."); + } + + if (resource.Annotations.OfType().Any() + || resource.Annotations.OfType().Any() + || resource.Annotations.OfType().Any()) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures Aspire health checks or container probes, but the Vercel preview integration does not map them to Vercel-native checks. Remove the Aspire probes or configure health behavior in Vercel."); + } + + if (resource.Annotations.OfType().Any()) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures Aspire replicas or scale, but the Vercel preview integration does not map replica counts to Vercel-native scaling. Configure scaling in Vercel instead."); + } + + if (resource.Annotations.OfType().Any()) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures Aspire wait/dependency ordering, but Vercel deploys each project independently and the preview integration does not preserve Aspire startup ordering. Remove the wait relationship or deploy dependent services separately."); + } + + ValidateEndpointModel(entry); + ValidateProjectName(entry); + } + + private static void ValidateEndpointModel(VercelDeploymentEntry entry) + { + var endpoints = entry.Resource.Annotations.OfType().ToArray(); + + if (endpoints.Length == 0) + { + return; + } + + var unsupportedEndpoint = endpoints.FirstOrDefault(static endpoint => + !endpoint.UriScheme.Equals("http", StringComparison.OrdinalIgnoreCase) + && !endpoint.UriScheme.Equals("https", StringComparison.OrdinalIgnoreCase)); + if (unsupportedEndpoint is not null) + { + throw new DistributedApplicationException( + $"Resource '{entry.Resource.Name}' configures endpoint '{unsupportedEndpoint.Name}' with scheme '{unsupportedEndpoint.UriScheme}', but Vercel Dockerfile deployments support only HTTP or HTTPS endpoints."); } + var targetPorts = endpoints + .Select(static endpoint => endpoint.TargetPort) + .Where(static targetPort => targetPort.HasValue) + .Select(static targetPort => targetPort!.Value) + .Distinct() + .ToArray(); + + if (targetPorts.Length > 1) + { + throw new DistributedApplicationException( + $"Resource '{entry.Resource.Name}' configures multiple Aspire endpoint target ports, but Vercel Dockerfile deployments support only one HTTP listening port exposed through the $PORT environment variable."); + } + } + + private static void ValidateProjectName(VercelDeploymentEntry entry) + { + if (HasVercelProjectLinkFile(entry.SourceRoot)) + { + return; + } + + _ = GetVercelProjectName(entry); + } + + private static async Task PrepareDeploymentEntryAsync(PipelineStepContext context, VercelDeploymentEntry entry) + { var outputService = context.Services.GetRequiredService(); string stagingRoot = GetStagingSourceRoot(outputService.GetTempDirectory(entry.Resource), entry); @@ -503,7 +815,11 @@ private static async Task PrepareDeploymentEntryAsync(Pip Directory.Delete(stagingRoot, recursive: true); } - CopyDirectory(entry.SourceRoot, stagingRoot, context.CancellationToken); + CopyDirectory( + entry.SourceRoot, + stagingRoot, + preserveVercelProjectLink: HasVercelProjectLinkFile(entry.SourceRoot), + context.CancellationToken); string stagedDockerfilePath = Path.Combine(stagingRoot, "Dockerfile"); DockerfileFactoryContext dockerfileContext = new() @@ -548,7 +864,9 @@ private static string GetDisplayDockerfilePath(VercelDeploymentEntry entry) private static string GetStagingSourceRoot(string tempDirectory, VercelDeploymentEntry entry) { string sourceRoot = Path.TrimEndingDirectorySeparator(entry.SourceRoot); - string sourceRootName = Path.GetFileName(sourceRoot); + string sourceRootName = HasVercelProjectLinkFile(entry.SourceRoot) + ? Path.GetFileName(sourceRoot) + : GetManagedVercelProjectName(entry); if (string.IsNullOrWhiteSpace(sourceRootName)) { @@ -558,29 +876,75 @@ private static string GetStagingSourceRoot(string tempDirectory, VercelDeploymen return Path.Combine(tempDirectory, sourceRootName); } - private static void CopyDirectory(string sourceDirectory, string destinationDirectory, CancellationToken cancellationToken) + private static void CopyDirectory( + string sourceDirectory, + string destinationDirectory, + bool preserveVercelProjectLink, + CancellationToken cancellationToken) { Directory.CreateDirectory(destinationDirectory); - foreach (string directory in Directory.EnumerateDirectories(sourceDirectory, "*", SearchOption.AllDirectories)) + foreach (string directory in Directory.EnumerateDirectories(sourceDirectory)) { cancellationToken.ThrowIfCancellationRequested(); - string relativePath = Path.GetRelativePath(sourceDirectory, directory); - Directory.CreateDirectory(Path.Combine(destinationDirectory, relativePath)); + string directoryName = Path.GetFileName(directory); + if (ShouldSkipStagingDirectory(directoryName, preserveVercelProjectLink)) + { + continue; + } + + if (IsVercelDirectory(directoryName)) + { + CopyVercelProjectLink(directory, Path.Combine(destinationDirectory, directoryName), cancellationToken); + continue; + } + + CopyDirectory( + directory, + Path.Combine(destinationDirectory, directoryName), + preserveVercelProjectLink: false, + cancellationToken); } - foreach (string file in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)) + foreach (string file in Directory.EnumerateFiles(sourceDirectory)) { cancellationToken.ThrowIfCancellationRequested(); - string relativePath = Path.GetRelativePath(sourceDirectory, file); - string destinationFile = Path.Combine(destinationDirectory, relativePath); + string destinationFile = Path.Combine(destinationDirectory, Path.GetFileName(file)); Directory.CreateDirectory(Path.GetDirectoryName(destinationFile)!); File.Copy(file, destinationFile, overwrite: true); } } + private static bool ShouldSkipStagingDirectory(string directoryName, bool preserveVercelProjectLink) + => IsGitDirectory(directoryName) + || IsNodeModulesDirectory(directoryName) + || (IsVercelDirectory(directoryName) && !preserveVercelProjectLink); + + private static void CopyVercelProjectLink(string sourceDirectory, string destinationDirectory, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + string projectJsonPath = Path.Combine(sourceDirectory, "project.json"); + if (!File.Exists(projectJsonPath)) + { + return; + } + + Directory.CreateDirectory(destinationDirectory); + File.Copy(projectJsonPath, Path.Combine(destinationDirectory, "project.json"), overwrite: true); + } + + private static bool IsGitDirectory(string directoryName) + => string.Equals(directoryName, ".git", GetPathStringComparison()); + + private static bool IsNodeModulesDirectory(string directoryName) + => string.Equals(directoryName, "node_modules", GetPathStringComparison()); + + private static bool IsVercelDirectory(string directoryName) + => string.Equals(directoryName, ".vercel", GetPathStringComparison()); + private static bool PathEquals(string path, string otherPath) => string.Equals(Path.GetFullPath(path), Path.GetFullPath(otherPath), GetPathStringComparison()); @@ -590,31 +954,111 @@ private static StringComparison GetPathStringComparison() : StringComparison.Ordinal; internal static string GetVercelProjectName(VercelDeploymentEntry entry) + => GetVercelProjectLink(entry).ProjectName; + + private static VercelProjectLink GetVercelProjectLink(VercelDeploymentEntry entry) + { + if (TryReadVercelProjectLink(entry.SourceRoot, out var projectLink)) + { + return projectLink; + } + + return new(GetManagedVercelProjectName(entry), ProjectId: null); + } + + private static string GetManagedVercelProjectName(VercelDeploymentEntry entry) { - string projectJsonPath = Path.Combine(entry.SourceRoot, ".vercel", "project.json"); + string sourceRoot = Path.TrimEndingDirectorySeparator(entry.SourceRoot); + string sourceRootName = Path.GetFileName(sourceRoot); - if (File.Exists(projectJsonPath)) + if (TryCreateVercelProjectName(sourceRootName, out string? projectName) + || TryCreateVercelProjectName(entry.Resource.Name, out projectName)) { - using var document = JsonDocument.Parse(File.ReadAllText(projectJsonPath)); + return projectName; + } + + throw new DistributedApplicationException($"Could not infer a valid Vercel project name for resource '{entry.Resource.Name}' from source root '{entry.SourceRoot}'. Rename the source directory or link the source root to an existing Vercel project."); + } + + private static bool TryCreateVercelProjectName(string? value, [NotNullWhen(true)] out string? projectName) + { + if (string.IsNullOrWhiteSpace(value)) + { + projectName = null; + return false; + } + + var builder = new StringBuilder(value.Length); + bool previousWasSeparator = false; - if (document.RootElement.TryGetProperty("projectName", out var projectName) - && projectName.ValueKind == JsonValueKind.String - && !string.IsNullOrWhiteSpace(projectName.GetString())) + foreach (char character in value) + { + if (IsAsciiLetterOrDigit(character)) + { + builder.Append(char.ToLowerInvariant(character)); + previousWasSeparator = false; + } + else if (!previousWasSeparator && builder.Length > 0) { - return projectName.GetString()!; + builder.Append('-'); + previousWasSeparator = true; } } - string sourceRoot = Path.TrimEndingDirectorySeparator(entry.SourceRoot); - string fallbackProjectName = Path.GetFileName(sourceRoot); - if (string.IsNullOrWhiteSpace(fallbackProjectName)) + projectName = builder + .ToString() + .Trim('-'); + + if (projectName.Length > VercelProjectNameMaxLength) + { + projectName = projectName[..VercelProjectNameMaxLength].Trim('-'); + } + + if (projectName.Length == 0) { - throw new DistributedApplicationException($"Could not infer the Vercel project name for resource '{entry.Resource.Name}' from source root '{entry.SourceRoot}'."); + projectName = null; + return false; } - return fallbackProjectName; + return true; } + private static bool IsAsciiLetterOrDigit(char character) + => character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9'; + + private static bool HasVercelProjectLinkFile(string sourceRoot) + => File.Exists(GetVercelProjectJsonPath(sourceRoot)); + + private static bool TryReadVercelProjectLink(string sourceRoot, [NotNullWhen(true)] out VercelProjectLink? projectLink) + { + string projectJsonPath = GetVercelProjectJsonPath(sourceRoot); + + if (File.Exists(projectJsonPath)) + { + using var document = JsonDocument.Parse(File.ReadAllText(projectJsonPath)); + string? projectName = GetJsonStringProperty(document.RootElement, "projectName"); + + if (!string.IsNullOrWhiteSpace(projectName)) + { + projectLink = new(projectName, GetJsonStringProperty(document.RootElement, "projectId")); + return true; + } + } + + projectLink = null; + return false; + } + + private static string? GetJsonStringProperty(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out var property) + && property.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(property.GetString()) + ? property.GetString() + : null; + + private static string GetVercelProjectJsonPath(string sourceRoot) + => Path.Combine(sourceRoot, ".vercel", "project.json"); + internal static string GetDeploymentUrl(string standardOutput) => GetDeploymentResult(standardOutput).DeploymentUrl; @@ -634,6 +1078,25 @@ internal static VercelDeploymentResult GetDeploymentResult(string standardOutput return new(DeploymentId: null, deploymentUrl); } + internal static VercelDeploymentInspection GetDeploymentInspection(string standardOutput) + { + try + { + using var document = JsonDocument.Parse(standardOutput); + var root = document.RootElement; + string? readyState = GetJsonStringProperty(root, "readyState") + ?? GetJsonStringProperty(root, "state") + ?? (root.TryGetProperty("deployment", out var deployment) ? GetJsonStringProperty(deployment, "readyState") : null) + ?? (root.TryGetProperty("deployment", out deployment) ? GetJsonStringProperty(deployment, "state") : null); + + return new(readyState); + } + catch (JsonException ex) + { + throw new DistributedApplicationException("Failed to parse JSON output from 'vercel inspect'.", ex); + } + } + private static VercelDeploymentResult? TryGetJsonDeploymentResult(string standardOutput) { if (!standardOutput.AsSpan().TrimStart().StartsWith("{", StringComparison.Ordinal)) @@ -715,6 +1178,26 @@ internal sealed record VercelDeploymentPlanEntry(string ResourceName, string Doc internal sealed record VercelDeploymentResult(string? DeploymentId, string DeploymentUrl); -internal sealed record VercelDeploymentState(string Environment, VercelDeploymentStateEntry[] Deployments); +internal sealed record VercelDeploymentInspection(string? ReadyState); + +internal sealed record VercelDeploymentState( + int SchemaVersion, + string Environment, + string? Scope, + string? Target, + bool Production, + VercelDeploymentStateEntry[] Deployments); + +internal sealed record VercelDeploymentStateEntry( + string ResourceName, + string ProjectName, + string? ProjectId, + string? DeploymentId, + string? DeploymentUrl, + string SourceRoot, + bool ManagedByAspire) +{ + public string? ProductionUrl { get; init; } +} -internal sealed record VercelDeploymentStateEntry(string ResourceName, string ProjectName, string? DeploymentId, string? DeploymentUrl); +internal sealed record VercelProjectLink(string ProjectName, string? ProjectId); diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/TypeScriptAppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/TypeScriptAppHostTests.cs index 144faa5d6..8dbf554d9 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/TypeScriptAppHostTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/TypeScriptAppHostTests.cs @@ -1,4 +1,5 @@ using CommunityToolkit.Aspire.Testing; +using System.Text.Json; namespace CommunityToolkit.Aspire.Hosting.Vercel.Tests; @@ -14,5 +15,39 @@ await TypeScriptAppHostTest.Run( waitForResources: [], cancellationToken: TestContext.Current.CancellationToken); } -} + [Fact] + public async Task TypeScriptAppHostPublishesWithExplicitComputeEnvironment() + { + string repoRoot = Path.GetFullPath(Path.Combine("..", "..", "..", "..", "..")); + string appHostRoot = Path.Combine(repoRoot, "examples", "vercel", "CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript"); + string outputRoot = Path.Combine(appHostRoot, "aspire-output"); + string distRoot = Path.Combine(appHostRoot, "dist"); + + try + { + await ProcessTestUtilities.RunProcessAsync("aspire", ["restore"], appHostRoot, TestContext.Current.CancellationToken); + await ProcessTestUtilities.RunProcessAsync("npm", ["run", "build"], appHostRoot, TestContext.Current.CancellationToken); + await ProcessTestUtilities.RunProcessAsync("aspire", ["publish", "--non-interactive"], appHostRoot, TestContext.Current.CancellationToken); + + string planPath = Path.Combine(outputRoot, "vercel", "vercel-deployments.json"); + using var document = JsonDocument.Parse(File.ReadAllText(planPath)); + JsonElement deployment = Assert.Single(document.RootElement.GetProperty("deployments").EnumerateArray()); + + Assert.Equal("api", deployment.GetProperty("resourceName").GetString()); + Assert.True(File.Exists(Path.Combine(outputRoot, "docker", "docker-compose.yaml"))); + } + finally + { + if (Directory.Exists(outputRoot)) + { + Directory.Delete(outputRoot, recursive: true); + } + + if (Directory.Exists(distRoot)) + { + Directory.Delete(distRoot, recursive: true); + } + } + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs index e6627184a..d4e304637 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs @@ -1,11 +1,14 @@ #pragma warning disable ASPIREPIPELINES001 #pragma warning disable ASPIREPIPELINES002 +#pragma warning disable ASPIREPIPELINES003 #pragma warning disable ASPIREPIPELINES004 +#pragma warning disable ASPIREPROBES001 #pragma warning disable CTASPIREVERCEL001 using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Publishing; using CommunityToolkit.Aspire.Hosting.Vercel; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -122,6 +125,12 @@ public async Task AddVercelEnvironmentRegistersExpectedPipelineSteps() Resource = environment })).ToArray(); + foreach (var step in steps) + { + Assert.DoesNotContain(WellKnownPipelineSteps.Build, step.DependsOnSteps); + Assert.DoesNotContain(WellKnownPipelineSteps.Push, step.DependsOnSteps); + } + Assert.Collection( steps, step => @@ -446,6 +455,128 @@ public async Task WriteDeploymentPlanThrowsWhenContainerHasNoSourceRoot() Assert.Contains("WithDockerfile", exception.Message); } + [Fact] + public async Task WriteDeploymentPlanThrowsForContainerMounts() + { + var exception = await AssertWriteDeploymentPlanThrowsAsync(static api => + api.WithVolume("/data")); + + Assert.Contains("volumes or bind mounts", exception.Message); + } + + [Fact] + public async Task WriteDeploymentPlanThrowsForContainerFiles() + { + var exception = await AssertWriteDeploymentPlanThrowsAsync(static api => + api.Resource.Annotations.Add(new ContainerFilesSourceAnnotation { SourcePath = "." })); + + Assert.Contains("container file mounts", exception.Message); + } + + [Fact] + public async Task WriteDeploymentPlanThrowsForHealthChecksAndProbes() + { + var exception = await AssertWriteDeploymentPlanThrowsAsync(static api => + api.WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true) + .WithHttpProbe(ProbeType.Readiness, path: "/health")); + + Assert.Contains("health checks or container probes", exception.Message); + } + + [Fact] + public async Task WriteDeploymentPlanThrowsForReplicas() + { + var exception = await AssertWriteDeploymentPlanThrowsAsync(static api => + api.Resource.Annotations.Add(new ReplicaAnnotation(2))); + + Assert.Contains("replicas or scale", exception.Message); + } + + [Fact] + public async Task WriteDeploymentPlanThrowsForWaitDependencies() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + builder.AddVercelEnvironment("vercel"); + var backend = builder.AddContainer("backend", "backend") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WaitFor(backend); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken)); + + Assert.Contains("wait/dependency ordering", exception.Message); + } + + [Fact] + public async Task WriteDeploymentPlanThrowsForMultipleEndpointTargetPorts() + { + var exception = await AssertWriteDeploymentPlanThrowsAsync(static api => + api.WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true) + .WithEndpoint(targetPort: 8081, scheme: "http", name: "admin", isExternal: true)); + + Assert.Contains("multiple Aspire endpoint target ports", exception.Message); + } + + [Fact] + public async Task WriteDeploymentPlanThrowsForNonHttpEndpoints() + { + var exception = await AssertWriteDeploymentPlanThrowsAsync(static api => + api.WithEndpoint(targetPort: 6379, scheme: "tcp", name: "tcp", isExternal: true)); + + Assert.Contains("support only HTTP or HTTPS endpoints", exception.Message); + } + + [Fact] + public async Task WriteDeploymentPlanAllowsInternalHttpEndpoints() + { + await AssertWriteDeploymentPlanSucceedsAsync(static api => + api.WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: false)); + } + + [Fact] + public async Task DeployAsyncStagesManagedProjectUsingSlugifiedProjectName() + { + using var sourceRoot = TemporaryDirectory.Create("Invalid_Project"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, "https://invalid-project.vercel.app", ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", "invalid-project"); + var invocation = runner.Invocations[0]; + Assert.Equal(expectedStagingRoot, invocation.WorkingDirectory); + + var deployment = Assert.Single(ReadSavedState(Assert.Single(stateManager.SavedSections)).Deployments); + Assert.Equal("invalid-project", deployment.ProjectName); + } + [Fact] public void BuildDeployArgumentsIncludesConfiguredOptions() { @@ -488,6 +619,32 @@ public void BuildDestroyProjectArgumentsIncludesConfiguredOptions() Assert.Equal(["--scope", "team", "project", "remove", "api"], arguments); } + [Fact] + public void BuildValidateScopeArgumentsIncludesConfiguredOptions() + { + var options = new VercelEnvironmentOptionsAnnotation + { + Scope = "team" + }; + + string[] arguments = VercelDeploymentStep.BuildValidateScopeArguments(options); + + Assert.Equal(["--scope", "team", "project", "ls", "--format=json"], arguments); + } + + [Fact] + public void BuildInspectDeploymentArgumentsIncludesConfiguredOptions() + { + var options = new VercelEnvironmentOptionsAnnotation + { + Scope = "team" + }; + + string[] arguments = VercelDeploymentStep.BuildInspectDeploymentArguments(options, "https://api.vercel.app"); + + Assert.Equal(["--scope", "team", "inspect", "https://api.vercel.app", "--wait", "--timeout", "120s", "--format=json"], arguments); + } + [Fact] public async Task BuildDeployArgumentsProcessesEnvironmentVariables() { @@ -516,6 +673,35 @@ public async Task BuildDeployArgumentsProcessesEnvironmentVariables() Assert.Contains("GREETING=hello", arguments); } + [Fact] + public async Task BuildDeployArgumentsAllowsNonSecretParameterEnvironmentVariables() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var region = builder.AddParameter("region", "iad"); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithEnvironment("REGION", region); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + + string[] arguments = await VercelDeploymentStep.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken); + + Assert.Contains("--env", arguments); + Assert.Contains(arguments, argument => argument.StartsWith("REGION=", StringComparison.Ordinal)); + } + [Fact] public async Task BuildDeployArgumentsThrowsForContainerEntrypoint() { @@ -574,17 +760,17 @@ public async Task BuildDeployArgumentsThrowsForSecretEnvironmentVariables() } [Fact] - public async Task BuildDeployArgumentsThrowsForConnectionStringEnvironmentVariables() + public async Task BuildDeployArgumentsThrowsForCompositeSecretEnvironmentVariables() { using var sourceRoot = TemporaryDirectory.Create(); File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); - var connectionString = builder.AddConnectionString("db"); + var secret = builder.AddParameter("api-key", "secret-value", secret: true); builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") .WithDockerfile(sourceRoot.Path, "Dockerfile") - .WithEnvironment("DATABASE_URL", connectionString); + .WithEnvironment("AUTH_HEADER", ReferenceExpression.Create($"Bearer {secret.Resource}")); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -599,20 +785,21 @@ public async Task BuildDeployArgumentsThrowsForConnectionStringEnvironmentVariab entry, TestContext.Current.CancellationToken)); - Assert.Contains("DATABASE_URL", exception.Message); + Assert.Contains("AUTH_HEADER", exception.Message); } [Fact] - public async Task BuildDeployArgumentsThrowsForCommandLineArguments() + public async Task BuildDeployArgumentsThrowsForConnectionStringEnvironmentVariables() { using var sourceRoot = TemporaryDirectory.Create(); File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var connectionString = builder.AddConnectionString("db"); builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") .WithDockerfile(sourceRoot.Path, "Dockerfile") - .WithArgs("--verbose"); + .WithEnvironment("DATABASE_URL", connectionString); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -627,11 +814,11 @@ public async Task BuildDeployArgumentsThrowsForCommandLineArguments() entry, TestContext.Current.CancellationToken)); - Assert.Contains("command-line arguments", exception.Message); + Assert.Contains("DATABASE_URL", exception.Message); } [Fact] - public async Task BuildDeployArgumentsThrowsForDockerBuildArguments() + public async Task BuildDeployArgumentsThrowsForInvalidEnvironmentVariableNames() { using var sourceRoot = TemporaryDirectory.Create(); File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); @@ -640,7 +827,7 @@ public async Task BuildDeployArgumentsThrowsForDockerBuildArguments() builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") .WithDockerfile(sourceRoot.Path, "Dockerfile") - .WithBuildArg("FOO", "bar"); + .WithEnvironment("INVALID-NAME", "value"); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -655,125 +842,392 @@ public async Task BuildDeployArgumentsThrowsForDockerBuildArguments() entry, TestContext.Current.CancellationToken)); - Assert.Contains("build arguments", exception.Message); + Assert.Contains("INVALID-NAME", exception.Message); + Assert.Contains("invalid Vercel environment variable name", exception.Message); } [Fact] - public async Task DeployAsyncRunsVercelCliAndSavesDeploymentState() + public async Task BuildDeployArgumentsThrowsForEndpointEnvironmentVariables() { - using var sourceRoot = TemporaryDirectory.Create("vercel-state-project"); + using var sourceRoot = TemporaryDirectory.Create(); File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); - var runner = new FakeVercelCliRunner(new VercelCliResult(0, """ - { - "deployment": { - "id": "dpl_123", - "url": "https://api.vercel.app" - } - } - """, "")); - var stateManager = new FakeDeploymentStateManager(); - var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); - builder.Services.AddSingleton(runner); - builder.Services.AddSingleton(stateManager); - var vercel = builder.AddVercelEnvironment("vercel") - .WithVercelScope("team"); - builder.AddContainer("api", "api") + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + builder.AddVercelEnvironment("vercel"); + var api = builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + var backend = builder.AddContainer("backend", "backend") .WithDockerfile(sourceRoot.Path, "Dockerfile") - .WithEnvironment("GREETING", "hello"); + .WithEndpoint(port: 8080, targetPort: 8080, scheme: "http", name: "http"); + api.WithEnvironment("BACKEND_URL", backend.GetEndpoint("http")); using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var context = CreatePipelineStepContext(builder, app); - - await VercelDeploymentStep.DeployAsync(context, environment); - - var invocation = Assert.Single(runner.Invocations); - Assert.Equal("vercel", invocation.FileName); - Assert.Equal(sourceRoot.Path, invocation.WorkingDirectory); - Assert.Equal(["--scope", "team", "--cwd", sourceRoot.Path, "deploy", "--yes", "--env", "GREETING=hello"], invocation.Arguments); - Assert.Null(invocation.StandardInput); + var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment), entry => entry.Resource.Name == "api"); - var summary = Assert.Single(context.Summary.Items); - Assert.Equal("api Vercel deployment", summary.Key); - Assert.Equal("https://api.vercel.app", summary.Value); + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken)); - var savedSection = Assert.Single(stateManager.SavedSections); - Assert.Equal("communitytoolkit.vercel.vercel", savedSection.SectionName); - string stateJson = savedSection.Data.ToJsonString(); - Assert.Contains("vercel-state-project", stateJson); - Assert.Contains("dpl_123", stateJson); + Assert.Contains("references another Aspire resource endpoint or service", exception.Message); } [Fact] - public async Task DeployAsyncStagesGeneratedDockerfileBeforeRunningVercelCli() + public async Task BuildDeployArgumentsThrowsForServiceDiscoveryReferences() { - using var sourceRoot = TemporaryDirectory.Create("generated-vercel-project"); - using var outputRoot = TemporaryDirectory.Create(); - using var tempRoot = TemporaryDirectory.Create(); - File.WriteAllText(Path.Combine(sourceRoot.Path, "server.mjs"), "console.log('hello');"); - var runner = new FakeVercelCliRunner(new VercelCliResult(0, "https://generated.vercel.app", "")); - var stateManager = new FakeDeploymentStateManager(); + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); - var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); - builder.Services.AddSingleton(runner); - builder.Services.AddSingleton(stateManager); - builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); builder.AddVercelEnvironment("vercel"); + var backend = builder.AddContainer("backend", "backend") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithEndpoint(port: 8080, targetPort: 8080, scheme: "http", name: "http"); builder.AddContainer("api", "api") - .WithDockerfileFactory(sourceRoot.Path, _ => Task.FromResult(""" - FROM node:22-alpine - WORKDIR /app - COPY server.mjs . - CMD ["node", "server.mjs"] - """)); + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithReference(backend.GetEndpoint("http")); using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var context = CreatePipelineStepContext(builder, app); - - await VercelDeploymentStep.DeployAsync(context, environment); + var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment), entry => entry.Resource.Name == "api"); - var invocation = Assert.Single(runner.Invocations); - string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", "generated-vercel-project"); - Assert.Equal(expectedStagingRoot, invocation.WorkingDirectory); - Assert.Equal(["--cwd", expectedStagingRoot, "deploy", "--yes"], invocation.Arguments); - Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "Dockerfile"))); - Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "server.mjs"))); - Assert.Contains("FROM node:22-alpine", File.ReadAllText(Path.Combine(expectedStagingRoot, "Dockerfile"))); + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken)); - var savedSection = Assert.Single(stateManager.SavedSections); - Assert.Contains("generated-vercel-project", savedSection.Data.ToJsonString()); + Assert.Contains("references another Aspire resource endpoint or service", exception.Message); } [Fact] - public async Task DeployAsyncStagesCustomDockerfileNameBeforeRunningVercelCli() + public async Task BuildDeployArgumentsThrowsForCommandLineArguments() { - using var sourceRoot = TemporaryDirectory.Create("custom-vercel-project"); - using var outputRoot = TemporaryDirectory.Create(); - using var tempRoot = TemporaryDirectory.Create(); - File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.custom"), "FROM nginx:alpine"); - File.WriteAllText(Path.Combine(sourceRoot.Path, "index.html"), "hello"); - var runner = new FakeVercelCliRunner(new VercelCliResult(0, "https://custom.vercel.app", "")); - var stateManager = new FakeDeploymentStateManager(); + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); - var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); - builder.Services.AddSingleton(runner); - builder.Services.AddSingleton(stateManager); - builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile.custom"); + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithArgs("--verbose"); using var app = builder.Build(); - var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); - var context = CreatePipelineStepContext(builder, app); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); - await VercelDeploymentStep.DeployAsync(context, environment); + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken)); - var invocation = Assert.Single(runner.Invocations); + Assert.Contains("command-line arguments", exception.Message); + } + + [Fact] + public async Task BuildDeployArgumentsThrowsForDockerBuildArguments() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithBuildArg("FOO", "bar"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken)); + + Assert.Contains("build arguments", exception.Message); + } + + [Fact] + public async Task DeployAsyncDoesNotRequireContainerRegistryOrImageManager() + { + using var sourceRoot = TemporaryDirectory.Create("registry-free-project"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, "https://registry-free-project.vercel.app", ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); + var imageManager = new ThrowingResourceContainerImageManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.Services.AddSingleton(imageManager); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + Assert.Equal(0, imageManager.CallCount); + Assert.Equal(2, runner.Invocations.Count); + Assert.Single(stateManager.SavedSections); + } + + [Fact] + public async Task DeployAsyncRunsVercelCliAndSavesDeploymentState() + { + using var sourceRoot = TemporaryDirectory.Create("vercel-state-project"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner(new VercelCliResult(0, """ + { + "deployment": { + "id": "dpl_123", + "url": "https://api.vercel.app" + } + } + """, ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelScope("team") + .WithVercelProductionDeployments(); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithEnvironment("GREETING", "hello"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + var invocation = runner.Invocations[0]; + string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", "vercel-state-project"); + Assert.Equal("vercel", invocation.FileName); + Assert.Equal(expectedStagingRoot, invocation.WorkingDirectory); + Assert.Equal(["--scope", "team", "--cwd", expectedStagingRoot, "deploy", "--yes", "--prod", "--env", "GREETING=hello"], invocation.Arguments); + Assert.Null(invocation.StandardInput); + Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "Dockerfile"))); + Assert.Equal(["--scope", "team", "inspect", "https://api.vercel.app", "--wait", "--timeout", "120s", "--format=json"], runner.Invocations[1].Arguments); + + Assert.Collection( + context.Summary.Items, + summary => + { + Assert.Equal("api Vercel deployment", summary.Key); + Assert.Equal("https://api.vercel.app", summary.Value); + }, + summary => + { + Assert.Equal("api Vercel production URL", summary.Key); + Assert.Equal("https://vercel-state-project.vercel.app", summary.Value); + }); + + var savedSection = Assert.Single(stateManager.SavedSections); + Assert.Equal("communitytoolkit.vercel.vercel", savedSection.SectionName); + string stateJson = savedSection.Data.First().Value!.GetValue(); + Assert.Contains("schemaVersion", stateJson); + Assert.Contains("\"scope\": \"team\"", stateJson); + Assert.Contains("vercel-state-project", stateJson); + Assert.Contains("dpl_123", stateJson); + Assert.Contains("managedByAspire", stateJson); + var state = ReadSavedState(savedSection); + var deployment = Assert.Single(state.Deployments); + Assert.True(state.Production); + Assert.Equal("https://vercel-state-project.vercel.app", deployment.ProductionUrl); + } + + [Fact] + public async Task DeployAsyncMarksLinkedVercelProjectsAsUnmanaged() + { + using var sourceRoot = TemporaryDirectory.Create("linked-project-source"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + Directory.CreateDirectory(Path.Combine(sourceRoot.Path, ".vercel")); + File.WriteAllText(Path.Combine(sourceRoot.Path, ".vercel", "cache.json"), "{}"); + File.WriteAllText(Path.Combine(sourceRoot.Path, ".vercel", "project.json"), """ + { + "projectId": "prj_linked", + "projectName": "linked-project" + } + """); + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, "https://linked-project.vercel.app", ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + var state = ReadSavedState(Assert.Single(stateManager.SavedSections)); + var deployment = Assert.Single(state.Deployments); + Assert.Equal("linked-project", deployment.ProjectName); + Assert.Equal("prj_linked", deployment.ProjectId); + Assert.False(deployment.ManagedByAspire); + + string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", "linked-project-source"); + Assert.True(File.Exists(Path.Combine(expectedStagingRoot, ".vercel", "project.json"))); + Assert.False(File.Exists(Path.Combine(expectedStagingRoot, ".vercel", "cache.json"))); + } + + [Fact] + public async Task DeployAsyncSkipsIgnoredDirectoriesWhenStagingManagedProjects() + { + using var sourceRoot = TemporaryDirectory.Create("ignored-staging-project"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(sourceRoot.Path, ".vercelignore"), "node_modules"); + Directory.CreateDirectory(Path.Combine(sourceRoot.Path, ".git")); + File.WriteAllText(Path.Combine(sourceRoot.Path, ".git", "config"), "ignored"); + Directory.CreateDirectory(Path.Combine(sourceRoot.Path, "node_modules", "pkg")); + File.WriteAllText(Path.Combine(sourceRoot.Path, "node_modules", "pkg", "index.js"), "ignored"); + Directory.CreateDirectory(Path.Combine(sourceRoot.Path, ".vercel")); + File.WriteAllText(Path.Combine(sourceRoot.Path, ".vercel", "cache.json"), "{}"); + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, "https://ignored-staging-project.vercel.app", ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", "ignored-staging-project"); + Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "Dockerfile"))); + Assert.True(File.Exists(Path.Combine(expectedStagingRoot, ".vercelignore"))); + Assert.False(Directory.Exists(Path.Combine(expectedStagingRoot, ".git"))); + Assert.False(Directory.Exists(Path.Combine(expectedStagingRoot, "node_modules"))); + Assert.False(Directory.Exists(Path.Combine(expectedStagingRoot, ".vercel"))); + } + + [Fact] + public async Task DeployAsyncStagesGeneratedDockerfileBeforeRunningVercelCli() + { + using var sourceRoot = TemporaryDirectory.Create("generated-vercel-project"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "server.mjs"), "console.log('hello');"); + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, "https://generated.vercel.app", ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfileFactory(sourceRoot.Path, _ => Task.FromResult(""" + FROM node:22-alpine + WORKDIR /app + COPY server.mjs . + CMD ["node", "server.mjs"] + """)); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + var invocation = runner.Invocations[0]; + string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", "generated-vercel-project"); + Assert.Equal(expectedStagingRoot, invocation.WorkingDirectory); + Assert.Equal(["--cwd", expectedStagingRoot, "deploy", "--yes"], invocation.Arguments); + Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "Dockerfile"))); + Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "server.mjs"))); + Assert.Contains("FROM node:22-alpine", File.ReadAllText(Path.Combine(expectedStagingRoot, "Dockerfile"))); + + var savedSection = Assert.Single(stateManager.SavedSections); + Assert.Contains("generated-vercel-project", savedSection.Data.ToJsonString()); + } + + [Fact] + public async Task DeployAsyncStagesCustomDockerfileNameBeforeRunningVercelCli() + { + using var sourceRoot = TemporaryDirectory.Create("custom-vercel-project"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.custom"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "index.html"), "hello"); + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, "https://custom.vercel.app", ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile.custom"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + var invocation = runner.Invocations[0]; string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", "custom-vercel-project"); Assert.Equal(expectedStagingRoot, invocation.WorkingDirectory); Assert.Equal(["--cwd", expectedStagingRoot, "deploy", "--yes"], invocation.Arguments); @@ -806,6 +1260,70 @@ public async Task DeployAsyncThrowsWhenVercelCliFails() Assert.Contains("Failed to deploy resource 'api' to Vercel using 'vercel' (exit code 1). deploy failed", exception.Message); } + [Fact] + public async Task DeployAsyncThrowsWhenVercelInspectFails() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner( + new(0, "https://api.vercel.app", ""), + new(1, "", "inspect failed")); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.DeployAsync(context, environment)); + + Assert.Contains("Failed to verify Vercel deployment for resource 'api' using 'vercel' (exit code 1). inspect failed", exception.Message); + Assert.Empty(stateManager.SavedSections); + } + + [Fact] + public async Task DeployAsyncThrowsWhenVercelInspectReportsNonReadyState() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner( + new(0, "https://api.vercel.app", ""), + new(0, """{"readyState":"ERROR"}""", "")); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.DeployAsync(context, environment)); + + Assert.Contains("finished with state 'ERROR' instead of 'READY'", exception.Message); + Assert.Empty(stateManager.SavedSections); + } + [Fact] public async Task ValidateCliPrerequisitesRunsVersionAndWhoami() { @@ -830,6 +1348,57 @@ public async Task ValidateCliPrerequisitesRunsVersionAndWhoami() invocation => Assert.Equal(["whoami"], invocation.Arguments)); } + [Fact] + public async Task ValidateCliPrerequisitesValidatesConfiguredScope() + { + var runner = new FakeVercelCliRunner( + new(0, "54.18.6", ""), + new(0, "davidfowl-6717", ""), + new(0, "project-list", "")); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.AddVercelEnvironment("vercel") + .WithVercelScope("team"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.ValidateCliPrerequisitesAsync(context, environment); + + Assert.Collection( + runner.Invocations, + invocation => Assert.Equal(["--version"], invocation.Arguments), + invocation => Assert.Equal(["whoami"], invocation.Arguments), + invocation => Assert.Equal(["--scope", "team", "project", "ls", "--format=json"], invocation.Arguments)); + } + + [Fact] + public async Task ValidateCliPrerequisitesThrowsWhenScopeValidationFails() + { + var runner = new FakeVercelCliRunner( + new(0, "54.18.6", ""), + new(0, "davidfowl-6717", ""), + new(1, "", "scope not found")); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.AddVercelEnvironment("vercel") + .WithVercelScope("missing-team"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.ValidateCliPrerequisitesAsync(context, environment)); + + Assert.Contains("Failed to validate Vercel scope 'missing-team' using 'vercel' (exit code 1). scope not found", exception.Message); + } + [Fact] public async Task ValidateCliPrerequisitesThrowsWhenWhoamiFails() { @@ -902,11 +1471,15 @@ public async Task DestroyAsyncDeletesProjectsFromSavedDeploymentState() new(0, "", "")); var stateManager = new FakeDeploymentStateManager(); stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( + 1, "vercel", + "team", + null, + false, [ - new("api", "z-project", "dpl_1", "https://z-project.vercel.app"), - new("worker", "a-project", null, null), - new("api2", "z-project", null, null) + new("api", "z-project", "prj_z", "dpl_1", "https://z-project.vercel.app", "/src/api", true), + new("worker", "a-project", null, null, null, "/src/worker", true), + new("api2", "z-project", null, null, null, "/src/api2", true) ]))); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); @@ -938,15 +1511,16 @@ public async Task DestroyAsyncDeletesProjectsFromSavedDeploymentState() } [Fact] - public async Task DestroyAsyncFallsBackToConfiguredDeploymentsWhenStateIsMissing() + public async Task DestroyAsyncDoesNotFallBackToConfiguredDeploymentsWhenStateIsMissing() { using var sourceRoot = TemporaryDirectory.Create("fallback-project"); File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); - var runner = new FakeVercelCliRunner(new VercelCliResult(0, "", "")); + var runner = new FakeVercelCliRunner(); + var stateManager = new FakeDeploymentStateManager(); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); - builder.Services.AddSingleton(new FakeDeploymentStateManager()); + builder.Services.AddSingleton(stateManager); builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") .WithDockerfile(sourceRoot.Path, "Dockerfile"); @@ -958,12 +1532,15 @@ public async Task DestroyAsyncFallsBackToConfiguredDeploymentsWhenStateIsMissing await VercelDeploymentStep.DestroyAsync(context, environment); - var invocation = Assert.Single(runner.Invocations); - Assert.Equal(["project", "remove", "fallback-project"], invocation.Arguments); + Assert.Empty(runner.Invocations); + Assert.Empty(stateManager.DeletedSections); + var summary = Assert.Single(context.Summary.Items); + Assert.Equal("Vercel destroy", summary.Key); + Assert.Contains("No Vercel deployment state", summary.Value); } [Fact] - public async Task DestroyAsyncAddsSummaryWhenNoDeploymentsExist() + public async Task DestroyAsyncAddsSummaryWhenNoStateExists() { var runner = new FakeVercelCliRunner(); @@ -982,7 +1559,146 @@ public async Task DestroyAsyncAddsSummaryWhenNoDeploymentsExist() Assert.Empty(runner.Invocations); var summary = Assert.Single(context.Summary.Items); Assert.Equal("Vercel destroy", summary.Key); - Assert.Contains("No Vercel deployments", summary.Value); + Assert.Contains("No Vercel deployment state", summary.Value); + } + + [Fact] + public async Task DestroyAsyncRejectsDeploymentStateFromDifferentScope() + { + var runner = new FakeVercelCliRunner(); + var stateManager = new FakeDeploymentStateManager(); + stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( + 1, + "vercel", + "team-a", + null, + false, + [ + new("api", "api-project", "prj_1", "dpl_1", "https://api-project.vercel.app", "/src/api", true) + ]))); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.AddVercelEnvironment("vercel") + .WithVercelScope("team-b"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.DestroyAsync(context, environment)); + + Assert.Contains("created for scope 'team-a'", exception.Message); + Assert.Contains("configured for scope 'team-b'", exception.Message); + Assert.Empty(runner.Invocations); + Assert.Empty(stateManager.DeletedSections); + } + + [Fact] + public async Task DestroyAsyncSkipsUnmanagedProjectsAndClearsState() + { + var runner = new FakeVercelCliRunner(new VercelCliResult(0, "", "")); + var stateManager = new FakeDeploymentStateManager(); + stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( + 1, + "vercel", + null, + null, + false, + [ + new("api", "managed-project", "prj_managed", "dpl_1", "https://managed-project.vercel.app", "/src/api", true), + new("docs", "preexisting-project", "prj_existing", "dpl_2", "https://preexisting-project.vercel.app", "/src/docs", false) + ]))); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DestroyAsync(context, environment); + + var invocation = Assert.Single(runner.Invocations); + Assert.Equal(["project", "remove", "managed-project"], invocation.Arguments); + Assert.Single(stateManager.DeletedSections); + } + + [Fact] + public async Task DestroyAsyncTreatsMissingManagedProjectAsConverged() + { + var runner = new FakeVercelCliRunner(new VercelCliResult(1, "", "Project managed-project not found")); + var stateManager = new FakeDeploymentStateManager(); + stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( + 1, + "vercel", + null, + null, + false, + [ + new("api", "managed-project", "prj_managed", "dpl_1", "https://managed-project.vercel.app", "/src/api", true) + ]))); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DestroyAsync(context, environment); + + var invocation = Assert.Single(runner.Invocations); + Assert.Equal(["project", "remove", "managed-project"], invocation.Arguments); + var summary = Assert.Single(context.Summary.Items, item => item.Key == "Vercel project already absent"); + Assert.Equal("managed-project", summary.Value); + Assert.Single(stateManager.SavedSections); + Assert.Single(stateManager.DeletedSections); + } + + [Fact] + public async Task DestroyAsyncPreservesStateWhenProjectRemovalFails() + { + var runner = new FakeVercelCliRunner( + new(0, "", ""), + new(1, "", "remove failed")); + var stateManager = new FakeDeploymentStateManager(); + stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( + 1, + "vercel", + null, + null, + false, + [ + new("api", "a-project", "prj_a", "dpl_a", "https://a-project.vercel.app", "/src/api", true), + new("worker", "b-project", "prj_b", "dpl_b", "https://b-project.vercel.app", "/src/worker", true) + ]))); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.DestroyAsync(context, environment)); + + Assert.Contains("destroy Vercel project 'b-project'", exception.Message); + Assert.Equal(2, runner.Invocations.Count); + Assert.Empty(stateManager.DeletedSections); + var savedSection = Assert.Single(stateManager.SavedSections); + string savedState = savedSection.Data.First().Value!.GetValue(); + Assert.DoesNotContain("\"projectName\": \"a-project\"", savedState); + Assert.Contains("\"projectName\": \"b-project\"", savedState); } [Fact] @@ -1169,6 +1885,46 @@ private static VercelDeploymentEntry CreateDeploymentEntry(string sourceRoot) new DockerfileBuildAnnotation(sourceRoot, dockerfilePath, stage: null)); } + private static VercelCliResult ReadyInspectResult() + => new(0, """{"readyState":"READY"}""", ""); + + private static async Task AssertWriteDeploymentPlanThrowsAsync( + Action> configureApi, + string? directoryName = null) + => await Assert.ThrowsAsync(() => + WriteDeploymentPlanForConfiguredContainerAsync(configureApi, directoryName)); + + private static async Task AssertWriteDeploymentPlanSucceedsAsync( + Action> configureApi, + string? directoryName = null) + => await WriteDeploymentPlanForConfiguredContainerAsync(configureApi, directoryName); + + private static async Task WriteDeploymentPlanForConfiguredContainerAsync( + Action> configureApi, + string? directoryName = null) + { + using var sourceRoot = TemporaryDirectory.Create(directoryName); + using var outputRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + builder.AddVercelEnvironment("vercel"); + var api = builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + configureApi(api); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + await VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken); + } + + private static VercelDeploymentState ReadSavedState(DeploymentStateSection section) + => JsonSerializer.Deserialize( + section.Data.First().Value!.GetValue(), + new JsonSerializerOptions(JsonSerializerDefaults.Web))!; + private sealed class FakeProjectMetadata(string projectPath) : IProjectMetadata { public bool IsFileBasedApp => false; @@ -1238,6 +1994,29 @@ private sealed record VercelCliInvocation( string? WorkingDirectory, string? StandardInput); + private sealed class ThrowingResourceContainerImageManager : IResourceContainerImageManager + { + public int CallCount { get; private set; } + + public Task BuildImageAsync(IResource resource, CancellationToken cancellationToken) + { + CallCount++; + throw new InvalidOperationException("Vercel deployments must not build container images locally."); + } + + public Task BuildImagesAsync(IEnumerable resources, CancellationToken cancellationToken) + { + CallCount++; + throw new InvalidOperationException("Vercel deployments must not build container images locally."); + } + + public Task PushImageAsync(IResource resource, CancellationToken cancellationToken) + { + CallCount++; + throw new InvalidOperationException("Vercel deployments must not push container images."); + } + } + private sealed class FakeDeploymentStateManager : IDeploymentStateManager { private readonly Dictionary _sections = new(StringComparer.Ordinal); @@ -1249,7 +2028,11 @@ private sealed class FakeDeploymentStateManager : IDeploymentStateManager public List DeletedSections { get; } = []; public void SetSection(string sectionName, string value) - => _sections[sectionName] = new(sectionName, new JsonObject { ["value"] = value }, 0); + { + var section = new DeploymentStateSection(sectionName, new JsonObject(), 0); + section.SetValue(value); + _sections[sectionName] = section; + } public Task AcquireSectionAsync(string sectionName, CancellationToken cancellationToken) { From fc237117b114f67332789a33dfb9f79a5d92056c Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 30 Jun 2026 17:04:21 -0700 Subject: [PATCH 08/33] Address Vercel deployment review gaps Make destroy state-first, persist Vercel deployment state incrementally, validate managed project-name uniqueness, and require explicit verified deployment outputs before saving state. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../README.md | 10 +- .../VercelDeploymentStep.cs | 119 +++++-- ...celEnvironmentResourceBuilderExtensions.cs | 9 - .../VercelEnvironmentTests.cs | 302 ++++++++++++++++-- 4 files changed, 383 insertions(+), 57 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md index ef508466a..8d92ae3e4 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md @@ -48,7 +48,7 @@ builder.AddNodeApp("api", "../api", "server.mjs") .WithComputeEnvironment(vercel); ``` -For low-level container resources, Vercel requires existing Aspire Dockerfile build metadata. Prefer the workload-specific `Add*App` integration when one exists. `WithDockerfile`, `WithDockerfileFactory`, and `WithDockerfileBuilder` are supported as advanced escape hatches. The integration always stages the source root into Aspire's deploy-time temp directory, materializes the Vercel-facing `Dockerfile` there, and runs `vercel deploy` from the staged directory so the Vercel CLI does not write `.vercel` metadata into the source tree. Staging skips `.git`, `node_modules`, and unmanaged `.vercel` metadata; linked projects keep only `.vercel/project.json`. +For low-level container resources, Vercel requires existing Aspire Dockerfile build metadata. Prefer the workload-specific `Add*App` integration when one exists. `WithDockerfile`, `WithDockerfileFactory`, and `WithDockerfileBuilder` are supported as advanced escape hatches. The integration always stages the source root into Aspire's deploy-time temp directory, materializes the Vercel-facing `Dockerfile` there, and runs `vercel deploy` from the staged directory so the Vercel CLI does not write `.vercel` metadata into the source tree. Staging skips `.git`, `node_modules`, and unmanaged `.vercel` metadata; linked projects keep only `.vercel/project.json`. Add a `.vercelignore` file to exclude local files such as `.env`, `bin/`, `obj/`, test artifacts, large assets, and other files that should not be uploaded to Vercel. Non-secret Aspire environment variables configured on the resource are processed during publish/deploy and passed to Vercel as CLI environment variables: @@ -65,14 +65,14 @@ vercel --cwd deploy --yes --env GREETING=hello - `aspire start` is unchanged. The Vercel environment is publish/deploy-only. - `aspire publish` writes a deterministic `vercel-deployments.json` plan for the targeted resources, including the environment variable names passed to Vercel. It does not require a container registry. -- `aspire deploy` validates the Vercel CLI, validates authentication and configured scope, stages the source root, materializes the Dockerfile, invokes `vercel deploy`, and verifies the resulting deployment with `vercel inspect --wait --format=json` before saving deployment state. Vercel performs the remote source upload/build; Aspire does not build or push container images for this environment. The deployment summary and state record the deployment URL, and production deployments also record the deterministic `https://.vercel.app` production URL. -- `aspire destroy` reads Aspire deployment state and removes only Vercel projects that were created by this integration for the same environment/scope. Missing state is treated as a no-op, and state is preserved if project removal fails. +- `aspire deploy` validates the Vercel CLI, validates authentication and configured scope, stages the source root, materializes the Dockerfile, invokes `vercel deploy`, and verifies the resulting deployment with `vercel inspect --wait --format=json` before saving deployment state for each verified resource. Vercel performs the remote source upload/build; Aspire does not build or push container images for this environment. The deployment summary and state record the deployment URL, and production deployments also record the deterministic `https://.vercel.app` production URL. +- `aspire destroy` reads Aspire deployment state first and removes only Vercel projects that were created by this integration for the same environment/scope. Missing state or unmanaged-only state does not require Vercel CLI/auth. State is preserved if project removal fails. ## Prerequisites - An Aspire workload with Dockerfile publish metadata, such as a language app resource that emits Dockerfile metadata or a project configured with `PublishAsDockerFile`. - The deployed container should listen on `$PORT`; the default port is `80`. -- Vercel CLI installed and available on `PATH`. +- Vercel CLI installed and available on `PATH`. Use the latest Vercel CLI; this preview is tested with Vercel CLI 54.18.6 and depends on `deploy --env`, `inspect --wait --timeout --format=json`, and `project remove`. - Vercel authentication from an existing CLI login or the `VERCEL_TOKEN` environment variable. - Any project linking, secrets, domains, deployment protection, or build-time environment variables configured in Vercel. @@ -95,7 +95,7 @@ This preview integration intentionally supports a narrow Vercel Dockerfile deplo | Container entrypoint overrides and Aspire command-line args | Rejected. Configure runtime command behavior through the Dockerfile/workload publish output or Vercel project settings. | | Existing linked Vercel projects | Supported for deploy. Destroy preserves projects that were linked before deploy and only deletes Aspire-created projects recorded in state. | -Managed Vercel project names are inferred from the source directory name when no `.vercel/project.json` link exists. The integration slugifies the inferred name to Vercel's lowercase, hyphenated project-name form before staging and deployment. This preview intentionally does not expose resource-level project naming, alias, framework/output, or per-resource target APIs; link a Vercel project before deploy when you need an exact existing project, and add future per-resource settings through resource-specific annotations rather than overloading the environment. +Managed Vercel project names are inferred from the source directory name when no `.vercel/project.json` link exists. The integration slugifies the inferred name to Vercel's lowercase, hyphenated project-name form before staging and deployment, and rejects duplicate managed project names within the same Vercel environment. This preview intentionally does not expose resource-level project naming, alias, framework/output, or per-resource target APIs; link a Vercel project before deploy when you need an exact existing project, and add future per-resource settings through resource-specific annotations rather than overloading the environment. ## Additional documentation diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs index bd19a9147..c34d7f083 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs @@ -137,8 +137,7 @@ public static async Task DeployAsync(PipelineStepContext context, VercelEnvironm var entries = GetDeploymentEntries(context.Model, environment).ToList(); ValidateEntries(entries); - - List stateEntries = []; + await ValidateExistingDeploymentStateAsync(context, environment, options).ConfigureAwait(false); foreach (var entry in entries) { @@ -164,7 +163,7 @@ public static async Task DeployAsync(PipelineStepContext context, VercelEnvironm var projectLink = GetVercelProjectLink(preparedEntry); string? productionUrl = GetProductionUrl(options, projectLink.ProjectName); - stateEntries.Add(new( + var stateEntry = new VercelDeploymentStateEntry( entry.Resource.Name, projectLink.ProjectName, projectLink.ProjectId, @@ -174,7 +173,9 @@ public static async Task DeployAsync(PipelineStepContext context, VercelEnvironm managedByAspire) { ProductionUrl = productionUrl - }); + }; + + await SaveDeploymentStateEntryAsync(context, environment, options, stateEntry).ConfigureAwait(false); context.Summary.Add($"{entry.Resource.Name} Vercel deployment", deploymentResult.DeploymentUrl); if (productionUrl is not null) @@ -182,8 +183,6 @@ public static async Task DeployAsync(PipelineStepContext context, VercelEnvironm context.Summary.Add($"{entry.Resource.Name} Vercel production URL", productionUrl); } } - - await SaveDeploymentStateAsync(context, environment, options, stateEntries).ConfigureAwait(false); } internal static string[] BuildDeployArguments(VercelEnvironmentOptionsAnnotation options, VercelDeploymentEntry entry) @@ -259,8 +258,12 @@ private static async Task VerifyDeploymentAsync( } var inspection = GetDeploymentInspection(result.StandardOutput); - if (inspection.ReadyState is not null - && !string.Equals(inspection.ReadyState, "READY", StringComparison.OrdinalIgnoreCase)) + if (inspection.ReadyState is null) + { + throw new DistributedApplicationException($"Vercel inspect output for resource '{resourceName}' did not include a deployment ready state. Output: {GetTrimmedOutput(result.StandardOutput)}"); + } + + if (!string.Equals(inspection.ReadyState, "READY", StringComparison.OrdinalIgnoreCase)) { throw new DistributedApplicationException($"Vercel deployment for resource '{resourceName}' finished with state '{inspection.ReadyState}' instead of 'READY'."); } @@ -269,7 +272,6 @@ private static async Task VerifyDeploymentAsync( public static async Task DestroyAsync(PipelineStepContext context, VercelEnvironmentResource environment) { var options = environment.GetVercelOptions(); - var runner = context.Services.GetRequiredService(); var stateManager = context.Services.GetRequiredService(); var stateSection = await stateManager.AcquireSectionAsync(GetStateSectionName(environment), context.CancellationToken).ConfigureAwait(false); var state = ReadDeploymentState(stateSection); @@ -295,6 +297,9 @@ public static async Task DestroyAsync(PipelineStepContext context, VercelEnviron return; } + await ValidateCliPrerequisitesAsync(context, environment).ConfigureAwait(false); + var runner = context.Services.GetRequiredService(); + foreach (string projectName in projects) { string[] arguments = BuildDestroyProjectArguments(options, projectName); @@ -438,24 +443,67 @@ private static string BuildDisplayDeployCommand( return $"vercel {string.Join(" ", BuildDeployArguments(options, $"<{resourceName}-source-root>", displayEnvironmentVariables))}"; } - private static async Task SaveDeploymentStateAsync( + private static async Task ValidateExistingDeploymentStateAsync( + PipelineStepContext context, + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options) + { + var stateManager = context.Services.GetRequiredService(); + var stateSection = await stateManager.AcquireSectionAsync(GetStateSectionName(environment), context.CancellationToken).ConfigureAwait(false); + var existingState = ReadDeploymentState(stateSection); + if (existingState is not null) + { + ValidateDeploymentState(environment, options, existingState); + } + } + + private static async Task SaveDeploymentStateEntryAsync( PipelineStepContext context, VercelEnvironmentResource environment, VercelEnvironmentOptionsAnnotation options, - IReadOnlyList deployments) + VercelDeploymentStateEntry deployment) { var stateManager = context.Services.GetRequiredService(); var stateSection = await stateManager.AcquireSectionAsync(GetStateSectionName(environment), context.CancellationToken).ConfigureAwait(false); - var state = new VercelDeploymentState( + var existingState = ReadDeploymentState(stateSection); + var state = existingState is null + ? CreateDeploymentState(environment, options, [deployment]) + : MergeDeploymentState(environment, options, existingState, deployment); + + stateSection.SetValue(JsonSerializer.Serialize(state, JsonOptions)); + + await stateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); + } + + private static VercelDeploymentState CreateDeploymentState( + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentStateEntry[] deployments) + => new( DeploymentStateSchemaVersion, environment.Name, NormalizeScope(options.Scope), NormalizeTarget(options.Target), options.Production, - [.. deployments]); - stateSection.SetValue(JsonSerializer.Serialize(state, JsonOptions)); + deployments); - await stateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); + private static VercelDeploymentState MergeDeploymentState( + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentState existingState, + VercelDeploymentStateEntry deployment) + { + ValidateDeploymentState(environment, options, existingState); + + return CreateDeploymentState( + environment, + options, + [ + .. existingState.Deployments.Where(existing => + !string.Equals(existing.ResourceName, deployment.ResourceName, StringComparison.Ordinal) + || !string.Equals(existing.ProjectName, deployment.ProjectName, StringComparison.Ordinal)), + deployment + ]); } private static VercelDeploymentState? ReadDeploymentState(DeploymentStateSection stateSection) @@ -719,6 +767,32 @@ private static void ValidateEntries(IReadOnlyList entries ValidateUnsupportedResourceModel(entry); } + + ValidateUniqueVercelProjectNames(entries); + } + + private static void ValidateUniqueVercelProjectNames(IReadOnlyList entries) + { + var projectNames = entries + .Select(entry => new + { + Entry = entry, + ProjectLink = GetVercelProjectLink(entry), + Linked = HasVercelProjectLinkFile(entry.SourceRoot) + }) + .GroupBy(item => item.ProjectLink.ProjectName, StringComparer.Ordinal) + .Where(group => group.Count() > 1 && group.Any(static item => !item.Linked)) + .ToArray(); + + if (projectNames.Length == 0) + { + return; + } + + var collision = projectNames[0]; + string resources = string.Join(", ", collision.Select(static item => $"'{item.Entry.Resource.Name}'").Order(StringComparer.Ordinal)); + throw new DistributedApplicationException( + $"Multiple Vercel resources resolve to managed project name '{collision.Key}' ({resources}). Managed Vercel project names must be unique per environment. Link shared existing projects with .vercel/project.json or use distinct source directory names."); } private static void ValidateUnsupportedResourceModel(VercelDeploymentEntry entry) @@ -1072,8 +1146,11 @@ internal static VercelDeploymentResult GetDeploymentResult(string standardOutput string[] lines = standardOutput .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - string deploymentUrl = lines.LastOrDefault(static line => Uri.TryCreate(line, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http") - ?? standardOutput.Trim(); + string? deploymentUrl = lines.LastOrDefault(IsHttpUrl); + if (deploymentUrl is null) + { + throw new DistributedApplicationException($"Vercel deploy output did not contain an HTTP or HTTPS deployment URL. Output: {GetTrimmedOutput(standardOutput)}"); + } return new(DeploymentId: null, deploymentUrl); } @@ -1157,9 +1234,15 @@ private static bool TryGetHttpUrl(JsonElement urlElement, [NotNullWhen(true)] ou ? urlElement.GetString() : null; - return Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http"; + return IsHttpUrl(url); } + private static bool IsHttpUrl([NotNullWhen(true)] string? url) + => Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http"; + + private static string GetTrimmedOutput(string output) + => string.IsNullOrWhiteSpace(output) ? "" : output.Trim(); + private static DistributedApplicationException CreateCliException(string operation, string cliPath, VercelCliResult result) { string output = string.IsNullOrWhiteSpace(result.StandardError) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs index cd51041b6..c6b5148cf 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs @@ -69,19 +69,10 @@ public static IResourceBuilder AddVercelEnvironment( Action = context => VercelDeploymentStep.DeployAsync(context, resource) }, new PipelineStep - { - Name = $"{VercelDeploymentStep.DestroyPrereqStepNamePrefix}{resource.Name}", - Description = $"Validate Vercel CLI prerequisites for destroying '{resource.Name}'.", - Resource = resource, - RequiredBySteps = [WellKnownPipelineSteps.Destroy], - Action = context => VercelDeploymentStep.ValidateCliPrerequisitesAsync(context, resource) - }, - new PipelineStep { Name = $"{VercelDeploymentStep.DestroyStepNamePrefix}{resource.Name}", Description = $"Destroy Vercel resources for environment '{resource.Name}'.", Resource = resource, - DependsOnSteps = [$"{VercelDeploymentStep.DestroyPrereqStepNamePrefix}{resource.Name}"], RequiredBySteps = [WellKnownPipelineSteps.Destroy], Action = context => VercelDeploymentStep.DestroyAsync(context, resource) } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs index d4e304637..07d33e007 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs @@ -155,20 +155,44 @@ public async Task AddVercelEnvironmentRegistersExpectedPipelineSteps() Assert.Equal([WellKnownPipelineSteps.Deploy], step.RequiredBySteps); }, step => - { - Assert.Equal("vercel-destroy-prereq-vercel", step.Name); - Assert.Same(vercel.Resource, step.Resource); - Assert.Equal([WellKnownPipelineSteps.Destroy], step.RequiredBySteps); - }, - step => { Assert.Equal("vercel-destroy-vercel", step.Name); Assert.Same(vercel.Resource, step.Resource); - Assert.Equal(["vercel-destroy-prereq-vercel"], step.DependsOnSteps); + Assert.Empty(step.DependsOnSteps); Assert.Equal([WellKnownPipelineSteps.Destroy], step.RequiredBySteps); }); } + [Fact] + public async Task DestroyPipelineStepDoesNotValidateVercelCliWhenStateIsMissing() + { + var runner = new FakeVercelCliRunner(new VercelCliResult(1, "", "not logged in")); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + var annotation = Assert.Single(environment.Annotations.OfType()); + var steps = (await annotation.CreateStepsAsync(new() + { + PipelineContext = context.PipelineContext, + Resource = environment + })).ToArray(); + var destroyStep = Assert.Single(steps, step => step.Name == "vercel-destroy-vercel"); + + await destroyStep.Action(context); + + Assert.Empty(runner.Invocations); + var summary = Assert.Single(context.Summary.Items); + Assert.Equal("Vercel destroy", summary.Key); + Assert.Contains("No Vercel deployment state", summary.Value); + } + [Fact] public void WithVercelProductionDeploymentsShouldThrowWhenBuilderIsNull() { @@ -543,6 +567,37 @@ await AssertWriteDeploymentPlanSucceedsAsync(static api => api.WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: false)); } + [Fact] + public async Task WriteDeploymentPlanThrowsForManagedProjectNameCollisions() + { + using var parent = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + string firstRoot = Path.Combine(parent.Path, "my.api"); + string secondRoot = Path.Combine(parent.Path, "my_api"); + Directory.CreateDirectory(firstRoot); + Directory.CreateDirectory(secondRoot); + File.WriteAllText(Path.Combine(firstRoot, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(secondRoot, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(firstRoot, "Dockerfile"); + builder.AddContainer("worker", "worker") + .WithDockerfile(secondRoot, "Dockerfile"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken)); + + Assert.Contains("managed project name 'my-api'", exception.Message); + Assert.Contains("'api'", exception.Message); + Assert.Contains("'worker'", exception.Message); + } + [Fact] public async Task DeployAsyncStagesManagedProjectUsingSlugifiedProjectName() { @@ -1070,6 +1125,98 @@ public async Task DeployAsyncRunsVercelCliAndSavesDeploymentState() Assert.Equal("https://vercel-state-project.vercel.app", deployment.ProductionUrl); } + [Fact] + public async Task DeployAsyncSavesVerifiedResourcesBeforeLaterResourceFails() + { + using var parent = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + string apiRoot = Path.Combine(parent.Path, "partial-api"); + string workerRoot = Path.Combine(parent.Path, "partial-worker"); + Directory.CreateDirectory(apiRoot); + Directory.CreateDirectory(workerRoot); + File.WriteAllText(Path.Combine(apiRoot, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(workerRoot, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner( + new(0, "https://partial-api.vercel.app", ""), + ReadyInspectResult(), + new(1, "", "worker deploy failed")); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(apiRoot, "Dockerfile"); + builder.AddContainer("worker", "worker") + .WithDockerfile(workerRoot, "Dockerfile"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.DeployAsync(context, environment)); + + Assert.Contains("worker deploy failed", exception.Message); + var state = ReadSavedState(Assert.Single(stateManager.SavedSections)); + var deployment = Assert.Single(state.Deployments); + Assert.Equal("api", deployment.ResourceName); + Assert.Equal("partial-api", deployment.ProjectName); + } + + [Fact] + public async Task DeployAsyncPreservesPreviousManagedStateForResourcesRemovedFromModel() + { + using var sourceRoot = TemporaryDirectory.Create("current-api"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner( + new(0, "https://current-api.vercel.app", ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); + stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( + 1, + "vercel", + null, + null, + false, + [ + new("worker", "removed-worker", "prj_worker", "dpl_worker", "https://removed-worker.vercel.app", "/src/worker", true) + ]))); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + var state = ReadSavedState(Assert.Single(stateManager.SavedSections)); + Assert.Collection( + state.Deployments.OrderBy(static deployment => deployment.ResourceName), + deployment => + { + Assert.Equal("api", deployment.ResourceName); + Assert.Equal("current-api", deployment.ProjectName); + }, + deployment => + { + Assert.Equal("worker", deployment.ResourceName); + Assert.Equal("removed-worker", deployment.ProjectName); + }); + } + [Fact] public async Task DeployAsyncMarksLinkedVercelProjectsAsUnmanaged() { @@ -1260,6 +1407,36 @@ public async Task DeployAsyncThrowsWhenVercelCliFails() Assert.Contains("Failed to deploy resource 'api' to Vercel using 'vercel' (exit code 1). deploy failed", exception.Message); } + [Fact] + public async Task DeployAsyncThrowsWhenVercelDeployOutputHasNoDeploymentUrl() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner(new VercelCliResult(0, """{"deployment":{"id":"dpl_no_url"}}""", "")); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.DeployAsync(context, environment)); + + Assert.Contains("did not contain an HTTP or HTTPS deployment URL", exception.Message); + Assert.Single(runner.Invocations); + Assert.Empty(stateManager.SavedSections); + } + [Fact] public async Task DeployAsyncThrowsWhenVercelInspectFails() { @@ -1292,6 +1469,37 @@ public async Task DeployAsyncThrowsWhenVercelInspectFails() Assert.Empty(stateManager.SavedSections); } + [Fact] + public async Task DeployAsyncThrowsWhenVercelInspectOmitsReadyState() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner( + new(0, "https://api.vercel.app", ""), + new(0, """{"deployment":{"url":"https://api.vercel.app"}}""", "")); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.DeployAsync(context, environment)); + + Assert.Contains("did not include a deployment ready state", exception.Message); + Assert.Empty(stateManager.SavedSections); + } + [Fact] public async Task DeployAsyncThrowsWhenVercelInspectReportsNonReadyState() { @@ -1467,6 +1675,9 @@ public async Task ValidatePrerequisitesThrowsWhenNoResourcesArePublished() public async Task DestroyAsyncDeletesProjectsFromSavedDeploymentState() { var runner = new FakeVercelCliRunner( + new(0, "54.18.6", ""), + new(0, "davidfowl-6717", ""), + new(0, "project-list", ""), new(0, "", ""), new(0, "", "")); var stateManager = new FakeDeploymentStateManager(); @@ -1497,6 +1708,9 @@ public async Task DestroyAsyncDeletesProjectsFromSavedDeploymentState() Assert.Collection( runner.Invocations, + invocation => Assert.Equal(["--version"], invocation.Arguments), + invocation => Assert.Equal(["whoami"], invocation.Arguments), + invocation => Assert.Equal(["--scope", "team", "project", "ls", "--format=json"], invocation.Arguments), invocation => { Assert.Equal(["--scope", "team", "project", "remove", "a-project"], invocation.Arguments); @@ -1599,7 +1813,10 @@ public async Task DestroyAsyncRejectsDeploymentStateFromDifferentScope() [Fact] public async Task DestroyAsyncSkipsUnmanagedProjectsAndClearsState() { - var runner = new FakeVercelCliRunner(new VercelCliResult(0, "", "")); + var runner = new FakeVercelCliRunner( + new(0, "54.18.6", ""), + new(0, "davidfowl-6717", ""), + new(0, "", "")); var stateManager = new FakeDeploymentStateManager(); stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( 1, @@ -1623,15 +1840,51 @@ public async Task DestroyAsyncSkipsUnmanagedProjectsAndClearsState() await VercelDeploymentStep.DestroyAsync(context, environment); - var invocation = Assert.Single(runner.Invocations); - Assert.Equal(["project", "remove", "managed-project"], invocation.Arguments); + Assert.Collection( + runner.Invocations, + invocation => Assert.Equal(["--version"], invocation.Arguments), + invocation => Assert.Equal(["whoami"], invocation.Arguments), + invocation => Assert.Equal(["project", "remove", "managed-project"], invocation.Arguments)); + Assert.Single(stateManager.DeletedSections); + } + + [Fact] + public async Task DestroyAsyncSkipsCliWhenStateHasOnlyUnmanagedProjects() + { + var runner = new FakeVercelCliRunner(new VercelCliResult(1, "", "not logged in")); + var stateManager = new FakeDeploymentStateManager(); + stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( + 1, + "vercel", + null, + null, + false, + [ + new("docs", "preexisting-project", "prj_existing", "dpl_2", "https://preexisting-project.vercel.app", "/src/docs", false) + ]))); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DestroyAsync(context, environment); + + Assert.Empty(runner.Invocations); Assert.Single(stateManager.DeletedSections); } [Fact] public async Task DestroyAsyncTreatsMissingManagedProjectAsConverged() { - var runner = new FakeVercelCliRunner(new VercelCliResult(1, "", "Project managed-project not found")); + var runner = new FakeVercelCliRunner( + new(0, "54.18.6", ""), + new(0, "davidfowl-6717", ""), + new(1, "", "Project managed-project not found")); var stateManager = new FakeDeploymentStateManager(); stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( 1, @@ -1654,8 +1907,11 @@ public async Task DestroyAsyncTreatsMissingManagedProjectAsConverged() await VercelDeploymentStep.DestroyAsync(context, environment); - var invocation = Assert.Single(runner.Invocations); - Assert.Equal(["project", "remove", "managed-project"], invocation.Arguments); + Assert.Collection( + runner.Invocations, + invocation => Assert.Equal(["--version"], invocation.Arguments), + invocation => Assert.Equal(["whoami"], invocation.Arguments), + invocation => Assert.Equal(["project", "remove", "managed-project"], invocation.Arguments)); var summary = Assert.Single(context.Summary.Items, item => item.Key == "Vercel project already absent"); Assert.Equal("managed-project", summary.Value); Assert.Single(stateManager.SavedSections); @@ -1666,6 +1922,8 @@ public async Task DestroyAsyncTreatsMissingManagedProjectAsConverged() public async Task DestroyAsyncPreservesStateWhenProjectRemovalFails() { var runner = new FakeVercelCliRunner( + new(0, "54.18.6", ""), + new(0, "davidfowl-6717", ""), new(0, "", ""), new(1, "", "remove failed")); var stateManager = new FakeDeploymentStateManager(); @@ -1693,7 +1951,7 @@ public async Task DestroyAsyncPreservesStateWhenProjectRemovalFails() VercelDeploymentStep.DestroyAsync(context, environment)); Assert.Contains("destroy Vercel project 'b-project'", exception.Message); - Assert.Equal(2, runner.Invocations.Count); + Assert.Equal(4, runner.Invocations.Count); Assert.Empty(stateManager.DeletedSections); var savedSection = Assert.Single(stateManager.SavedSections); string savedState = savedSection.Data.First().Value!.GetValue(); @@ -1781,24 +2039,18 @@ public void GetDeploymentResultFallsBackWhenJsonIsInvalid() } [Fact] - public void GetDeploymentResultFallsBackWhenJsonHasNoUrl() + public void GetDeploymentResultThrowsWhenOutputHasNoUrl() { - var result = VercelDeploymentStep.GetDeploymentResult(""" + var exception = Assert.Throws(() => + VercelDeploymentStep.GetDeploymentResult(""" { "deployment": { "id": "dpl_no_url" } } - """); + """)); - Assert.Null(result.DeploymentId); - Assert.Equal(""" - { - "deployment": { - "id": "dpl_no_url" - } - } - """.Trim(), result.DeploymentUrl); + Assert.Contains("did not contain an HTTP or HTTPS deployment URL", exception.Message); } [Fact] From a6f011fca6a085137b6c257e613f2b7e917c080d Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 30 Jun 2026 17:27:24 -0700 Subject: [PATCH 09/33] Fix Vercel TypeScript test on Windows Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../TypeScriptAppHostTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/TypeScriptAppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/TypeScriptAppHostTests.cs index 8dbf554d9..e6cbf090a 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/TypeScriptAppHostTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/TypeScriptAppHostTests.cs @@ -27,7 +27,8 @@ public async Task TypeScriptAppHostPublishesWithExplicitComputeEnvironment() try { await ProcessTestUtilities.RunProcessAsync("aspire", ["restore"], appHostRoot, TestContext.Current.CancellationToken); - await ProcessTestUtilities.RunProcessAsync("npm", ["run", "build"], appHostRoot, TestContext.Current.CancellationToken); + string npm = OperatingSystem.IsWindows() ? "npm.cmd" : "npm"; + await ProcessTestUtilities.RunProcessAsync(npm, ["run", "build"], appHostRoot, TestContext.Current.CancellationToken); await ProcessTestUtilities.RunProcessAsync("aspire", ["publish", "--non-interactive"], appHostRoot, TestContext.Current.CancellationToken); string planPath = Path.Combine(outputRoot, "vercel", "vercel-deployments.json"); From a5a135a6cb00835114dbfe418d36e34bf1bdca2d Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 30 Jun 2026 17:33:56 -0700 Subject: [PATCH 10/33] Remove reflection from Vercel validation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../VercelDeploymentStep.cs | 35 ++----------------- 1 file changed, 3 insertions(+), 32 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs index c34d7f083..34b38b97d 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs @@ -672,7 +672,7 @@ private static bool ContainsSecretReference(object? value) IResourceWithConnectionString => true, IResourceBuilder => true, IValueWithReferences valueWithReferences => valueWithReferences.References.Any(ContainsSecretReference), - _ => IsConnectionStringResourceBuilder(value) + _ => false }; } @@ -688,40 +688,11 @@ private static bool ContainsRemoteResourceReference(IResource resource, object? EndpointReferenceExpression => true, IResource referencedResource => !ReferenceEquals(referencedResource, resource), IValueWithReferences valueWithReferences => valueWithReferences.References.Any(reference => ContainsRemoteResourceReference(resource, reference)), - _ => IsResourceBuilder(value, resource) + IResourceBuilder resourceBuilder => !ReferenceEquals(resourceBuilder.Resource, resource), + _ => false }; } - private static bool IsConnectionStringResourceBuilder(object value) - { - return value.GetType() - .GetInterfaces() - .Any(static interfaceType => - interfaceType.IsGenericType - && interfaceType.GetGenericTypeDefinition() == typeof(IResourceBuilder<>) - && typeof(IResourceWithConnectionString).IsAssignableFrom(interfaceType.GetGenericArguments()[0])); - } - - private static bool IsResourceBuilder(object value, IResource resource) - { - return value.GetType() - .GetInterfaces() - .Any(interfaceType => - interfaceType.IsGenericType - && interfaceType.GetGenericTypeDefinition() == typeof(IResourceBuilder<>) - && typeof(IResource).IsAssignableFrom(interfaceType.GetGenericArguments()[0]) - && !typeof(ParameterResource).IsAssignableFrom(interfaceType.GetGenericArguments()[0]) - && TryGetResource(value, out var referencedResource) - && !ReferenceEquals(referencedResource, resource)); - } - - private static bool TryGetResource(object value, [NotNullWhen(true)] out IResource? resource) - { - resource = value.GetType().GetProperty(nameof(IResourceBuilder.Resource))?.GetValue(value) as IResource; - return resource is not null; - } - - internal static IEnumerable GetDeploymentEntries(DistributedApplicationModel model, VercelEnvironmentResource environment) { foreach (var resource in model.Resources.OfType()) From 5bfb4ff77c2b228e00cb539638eba21b0769b875 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 30 Jun 2026 17:50:10 -0700 Subject: [PATCH 11/33] Avoid npm shim in Vercel TypeScript test Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../TypeScriptAppHostTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/TypeScriptAppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/TypeScriptAppHostTests.cs index e6cbf090a..2e8284cf8 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/TypeScriptAppHostTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/TypeScriptAppHostTests.cs @@ -27,8 +27,9 @@ public async Task TypeScriptAppHostPublishesWithExplicitComputeEnvironment() try { await ProcessTestUtilities.RunProcessAsync("aspire", ["restore"], appHostRoot, TestContext.Current.CancellationToken); - string npm = OperatingSystem.IsWindows() ? "npm.cmd" : "npm"; - await ProcessTestUtilities.RunProcessAsync(npm, ["run", "build"], appHostRoot, TestContext.Current.CancellationToken); + string node = OperatingSystem.IsWindows() ? "node.exe" : "node"; + await ProcessTestUtilities.RunProcessAsync(node, [Path.Combine("node_modules", "eslint", "bin", "eslint.js"), "apphost.mts"], appHostRoot, TestContext.Current.CancellationToken); + await ProcessTestUtilities.RunProcessAsync(node, [Path.Combine("node_modules", "typescript", "bin", "tsc")], appHostRoot, TestContext.Current.CancellationToken); await ProcessTestUtilities.RunProcessAsync("aspire", ["publish", "--non-interactive"], appHostRoot, TestContext.Current.CancellationToken); string planPath = Path.Combine(outputRoot, "vercel", "vercel-deployments.json"); From 6def3a12ee1066e006a4b6553212417a94ced937 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 30 Jun 2026 22:10:48 -0700 Subject: [PATCH 12/33] Support Vercel production endpoint references Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../README.md | 18 ++- .../VercelDeploymentStep.cs | 133 ++++++++++++++++-- .../VercelEnvironmentResource.cs | 50 ++++++- .../VercelEnvironmentTests.cs | 97 ++++++++++--- 4 files changed, 266 insertions(+), 32 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md index 8d92ae3e4..431fcb8f0 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md @@ -61,6 +61,22 @@ builder.AddNodeApp("api", "../api", "server.mjs") vercel --cwd deploy --yes --env GREETING=hello ``` +Production endpoint references to other Vercel-targeted workloads are converted to deterministic Vercel project URLs: + +```csharp +var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelProductionDeployments(); + +var backend = builder.AddNodeApp("backend", "../backend", "server.mjs") + .WithComputeEnvironment(vercel); + +builder.AddNodeApp("api", "../api", "server.mjs") + .WithEnvironment("BACKEND_URL", backend.GetEndpoint("http")) + .WithComputeEnvironment(vercel); +``` + +The `BACKEND_URL` value is deployed as `https://.vercel.app`. Preview and custom-target deployments still reject endpoint references because those URLs are assigned by Vercel after deployment. + ## Deployment behavior - `aspire start` is unchanged. The Vercel environment is publish/deploy-only. @@ -88,7 +104,7 @@ This preview integration intentionally supports a narrow Vercel Dockerfile deplo | Container registries, image build, image push | Not used. Vercel uploads and builds the staged source tree. | | Non-secret environment variables | Passed with `vercel deploy --env KEY=value`; names must use letters, digits, and underscores and values are redacted from publish output. | | Secret parameters, connection strings, Docker build args/secrets | Rejected, including composite values that contain secrets. Configure them in Vercel project environment variables or Vercel secrets. | -| Service discovery, endpoint references, `WithReference` to another resource | Rejected because preview deployments only expose post-deploy output URLs and do not provide stable pre-deploy endpoint expressions. | +| Service discovery, endpoint references, `WithReference` to another resource | Supported for Vercel production deployments by using deterministic `https://.vercel.app` URLs. Rejected for preview/custom targets because those URLs are assigned after deployment. | | Endpoints | HTTP and HTTPS endpoints are accepted when they map to one target port. Non-HTTP(S) endpoints or multiple target ports are rejected. | | Volumes, bind mounts, Aspire container files | Rejected. Include required files in the source tree or use a Vercel-supported external service for state. | | Health checks, probes, replicas, wait/dependency ordering | Rejected until they are mapped to Vercel-native behavior. | diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs index 34b38b97d..73f5a9062 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs @@ -11,6 +11,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -337,6 +338,7 @@ internal static async Task BuildDeployArgumentsAsync( var environmentVariables = await GetVercelEnvironmentVariablesAsync( executionContext, logger, + options, entry, cancellationToken).ConfigureAwait(false); @@ -356,7 +358,7 @@ private static async Task CreateDeploymentPlanEntri { var environmentVariables = executionContext is null || logger is null ? [] - : await GetVercelEnvironmentVariablesAsync(executionContext, logger, entry, cancellationToken).ConfigureAwait(false); + : await GetVercelEnvironmentVariablesAsync(executionContext, logger, options, entry, cancellationToken).ConfigureAwait(false); planEntries.Add(new( entry.Resource.Name, @@ -371,6 +373,7 @@ private static async Task CreateDeploymentPlanEntri private static async Task>> GetVercelEnvironmentVariablesAsync( DistributedApplicationExecutionContext executionContext, ILogger logger, + VercelEnvironmentOptionsAnnotation options, VercelDeploymentEntry entry, CancellationToken cancellationToken) { @@ -388,7 +391,7 @@ private static async Task>> GetVercel ValidateUnsupportedRuntimeConfiguration(entry.Resource, executionConfiguration); - var environmentVariables = GetVercelEnvironmentVariables(entry.Resource, executionConfiguration); + var environmentVariables = GetVercelEnvironmentVariables(entry.Resource, options, executionConfiguration); return environmentVariables; } @@ -590,6 +593,7 @@ private static bool IsMissingProjectResult(VercelCliResult result, string projec private static IReadOnlyList> GetVercelEnvironmentVariables( IResource resource, + VercelEnvironmentOptionsAnnotation options, IExecutionConfigurationResult executionConfiguration) { List> environmentVariables = []; @@ -614,10 +618,15 @@ private static IReadOnlyList> GetVercelEnvironmentV $"Environment variable '{name}' for resource '{resource.Name}' references a secret or connection string. Vercel CLI --env would pass the value on the command line, so configure this value in Vercel project environment variables or a Vercel secret instead."); } - if (ContainsRemoteResourceReference(resource, unprocessedValue)) + if (ContainsUnsupportedResourceReference(resource, unprocessedValue)) { throw new DistributedApplicationException( - $"Environment variable '{name}' for resource '{resource.Name}' references another Aspire resource endpoint or service. Vercel deployment URLs are not available until after deployment, so cross-resource endpoint and service discovery references are not supported by the Vercel preview integration."); + $"Environment variable '{name}' for resource '{resource.Name}' references another Aspire resource or service in a way that cannot be represented as a Vercel deployment URL. Use endpoint references to Vercel production workloads, or configure the value in Vercel project environment variables."); + } + + if (TryGetVercelEnvironmentVariableValue(resource, options, unprocessedValue, out string? vercelValue)) + { + value = vercelValue; } environmentVariables.Add(new(name, value)); @@ -626,6 +635,89 @@ private static IReadOnlyList> GetVercelEnvironmentV return environmentVariables; } + private static bool TryGetVercelEnvironmentVariableValue( + IResource resource, + VercelEnvironmentOptionsAnnotation options, + object? value, + [NotNullWhen(true)] out string? vercelValue) + { + switch (value) + { + case EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference): + vercelValue = GetVercelEndpointPropertyValue(options, endpointReference.Property(EndpointProperty.Url)); + return true; + case EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint): + vercelValue = GetVercelEndpointPropertyValue(options, endpointReferenceExpression); + return true; + case ReferenceExpression referenceExpression when ContainsCrossResourceEndpointReference(resource, referenceExpression): + vercelValue = GetVercelReferenceExpressionValue(resource, options, referenceExpression); + return true; + default: + vercelValue = null; + return false; + } + } + + private static string GetVercelReferenceExpressionValue( + IResource resource, + VercelEnvironmentOptionsAnnotation options, + ReferenceExpression referenceExpression) + { + if (referenceExpression.IsConditional) + { + throw new DistributedApplicationException("Vercel endpoint references do not support conditional reference expressions. Configure a concrete Vercel project environment variable instead."); + } + + var arguments = new object?[referenceExpression.ValueProviders.Count]; + for (int i = 0; i < referenceExpression.ValueProviders.Count; i++) + { + IValueProvider valueProvider = referenceExpression.ValueProviders[i]; + arguments[i] = valueProvider switch + { + EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference) => GetVercelEndpointPropertyValue(options, endpointReference.Property(EndpointProperty.Url)), + EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint) => GetVercelEndpointPropertyValue(options, endpointReferenceExpression), + _ => throw new DistributedApplicationException("Vercel endpoint reference expressions cannot be combined with parameters, secrets, or other value providers. Configure a concrete Vercel project environment variable instead.") + }; + + if (referenceExpression.StringFormats[i] is "uri" && arguments[i] is string stringValue) + { + arguments[i] = Uri.EscapeDataString(stringValue); + } + } + + return string.Format(CultureInfo.InvariantCulture, referenceExpression.Format, arguments); + } + + private static string GetVercelEndpointPropertyValue( + VercelEnvironmentOptionsAnnotation options, + EndpointReferenceExpression endpointReferenceExpression) + { + if (!options.Production) + { + throw new DistributedApplicationException( + "Vercel endpoint references require production deployments because preview and custom target URLs are assigned by Vercel after deployment. Call WithVercelProductionDeployments on the Vercel environment, or remove the reference."); + } + + var endpointReference = endpointReferenceExpression.Endpoint; + var endpoint = endpointReference.EndpointAnnotation; + string host = $"{GetVercelProjectName(endpointReference.Resource)}.vercel.app"; + const int port = 443; + + return endpointReferenceExpression.Property switch + { + EndpointProperty.Url => $"https://{host}", + EndpointProperty.Host or EndpointProperty.IPV4Host => host, + EndpointProperty.Port => port.ToString(CultureInfo.InvariantCulture), + EndpointProperty.TargetPort => endpoint.TargetPort is int targetPort + ? targetPort.ToString(CultureInfo.InvariantCulture) + : port.ToString(CultureInfo.InvariantCulture), + EndpointProperty.Scheme => "https", + EndpointProperty.HostAndPort => $"{host}:{port.ToString(CultureInfo.InvariantCulture)}", + EndpointProperty.TlsEnabled => bool.TrueString, + _ => throw new DistributedApplicationException($"The endpoint property '{endpointReferenceExpression.Property}' is not supported for Vercel endpoint references.") + }; + } + private static void ValidateEnvironmentVariableName(IResource resource, string name) { if (string.IsNullOrWhiteSpace(name) @@ -676,7 +768,22 @@ private static bool ContainsSecretReference(object? value) }; } - private static bool ContainsRemoteResourceReference(IResource resource, object? value) + private static bool ContainsCrossResourceEndpointReference(IResource resource, object? value) + { + return value switch + { + null => false, + EndpointReference endpointReference => IsCrossResourceEndpointReference(resource, endpointReference), + EndpointReferenceExpression endpointReferenceExpression => IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint), + IValueWithReferences valueWithReferences => valueWithReferences.References.Any(reference => ContainsCrossResourceEndpointReference(resource, reference)), + _ => false + }; + } + + private static bool IsCrossResourceEndpointReference(IResource resource, EndpointReference endpointReference) + => !ReferenceEquals(endpointReference.Resource, resource); + + private static bool ContainsUnsupportedResourceReference(IResource resource, object? value) { return value switch { @@ -684,10 +791,10 @@ private static bool ContainsRemoteResourceReference(IResource resource, object? string => false, ParameterResource => false, IResourceBuilder => false, - EndpointReference => true, - EndpointReferenceExpression => true, + EndpointReference => false, + EndpointReferenceExpression => false, IResource referencedResource => !ReferenceEquals(referencedResource, resource), - IValueWithReferences valueWithReferences => valueWithReferences.References.Any(reference => ContainsRemoteResourceReference(resource, reference)), + IValueWithReferences valueWithReferences => valueWithReferences.References.Any(reference => ContainsUnsupportedResourceReference(resource, reference)), IResourceBuilder resourceBuilder => !ReferenceEquals(resourceBuilder.Resource, resource), _ => false }; @@ -1001,6 +1108,16 @@ private static StringComparison GetPathStringComparison() internal static string GetVercelProjectName(VercelDeploymentEntry entry) => GetVercelProjectLink(entry).ProjectName; + internal static string GetVercelProjectName(IResource resource) + { + if (!resource.TryGetLastAnnotation(out var dockerfile)) + { + throw new DistributedApplicationException($"Resource '{resource.Name}' targets Vercel but does not have Aspire Dockerfile build metadata. Use a workload integration that publishes Dockerfile metadata, call PublishAsDockerFile, or configure the resource with WithDockerfile, WithDockerfileFactory, or WithDockerfileBuilder."); + } + + return GetVercelProjectName(new VercelDeploymentEntry(resource, dockerfile.ContextPath, dockerfile.DockerfilePath, dockerfile)); + } + private static VercelProjectLink GetVercelProjectLink(VercelDeploymentEntry entry) { if (TryReadVercelProjectLink(entry.SourceRoot, out var projectLink)) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs index f8b5863bd..ae6cd32d4 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs @@ -1,5 +1,8 @@ using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting; +using CommunityToolkit.Aspire.Hosting.Vercel; using System.Diagnostics.CodeAnalysis; +using System.Globalization; namespace Aspire.Hosting.ApplicationModel; @@ -9,4 +12,49 @@ namespace Aspire.Hosting.ApplicationModel; /// The Aspire resource name for the Vercel environment. [Experimental("CTASPIREVERCEL001")] [AspireExport(ExposeProperties = true)] -public sealed class VercelEnvironmentResource(string name) : Resource(name), IComputeEnvironmentResource; +public sealed class VercelEnvironmentResource(string name) : Resource(name), IComputeEnvironmentResource +{ + /// + [Experimental("ASPIRECOMPUTE002", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public ReferenceExpression GetHostAddressExpression(EndpointReference endpointReference) + { + ArgumentNullException.ThrowIfNull(endpointReference); + + var options = this.GetVercelOptions(); + if (!options.Production) + { + throw new InvalidOperationException( + $"Vercel endpoint references require production deployments because preview and custom target URLs are assigned by Vercel after deployment. Call {nameof(VercelEnvironmentResourceBuilderExtensions.WithVercelProductionDeployments)} on the Vercel environment, or remove the reference."); + } + + string projectName = VercelDeploymentStep.GetVercelProjectName(endpointReference.Resource); + return ReferenceExpression.Create($"{projectName}.vercel.app"); + } + + /// + [Experimental("ASPIRECOMPUTE002", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public ReferenceExpression GetEndpointPropertyExpression(EndpointReferenceExpression endpointReferenceExpression) + { + ArgumentNullException.ThrowIfNull(endpointReferenceExpression); + + var endpointReference = endpointReferenceExpression.Endpoint; + var property = endpointReferenceExpression.Property; + var endpoint = endpointReference.EndpointAnnotation; + var host = GetHostAddressExpression(endpointReference); + const int port = 443; + + return property switch + { + EndpointProperty.Url => ReferenceExpression.Create($"https://{host}"), + EndpointProperty.Host or EndpointProperty.IPV4Host => host, + EndpointProperty.Port => ReferenceExpression.Create($"{port.ToString(CultureInfo.InvariantCulture)}"), + EndpointProperty.TargetPort => endpoint.TargetPort is int targetPort + ? ReferenceExpression.Create($"{targetPort.ToString(CultureInfo.InvariantCulture)}") + : ReferenceExpression.Create($"{port.ToString(CultureInfo.InvariantCulture)}"), + EndpointProperty.Scheme => ReferenceExpression.Create($"https"), + EndpointProperty.HostAndPort => ReferenceExpression.Create($"{host}:{port.ToString(CultureInfo.InvariantCulture)}"), + EndpointProperty.TlsEnabled => ReferenceExpression.Create($"{bool.TrueString}"), + _ => throw new InvalidOperationException($"The property '{property}' is not supported for the endpoint '{endpoint.Name}'.") + }; + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs index 07d33e007..c114086d6 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs @@ -902,18 +902,26 @@ public async Task BuildDeployArgumentsThrowsForInvalidEnvironmentVariableNames() } [Fact] - public async Task BuildDeployArgumentsThrowsForEndpointEnvironmentVariables() + public async Task BuildDeployArgumentsUsesProductionUrlForEndpointEnvironmentVariables() { using var sourceRoot = TemporaryDirectory.Create(); - File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + string apiRoot = Path.Combine(sourceRoot.Path, "api-app"); + string backendRoot = Path.Combine(sourceRoot.Path, "backend-app"); + Directory.CreateDirectory(apiRoot); + Directory.CreateDirectory(backendRoot); + File.WriteAllText(Path.Combine(apiRoot, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(backendRoot, "Dockerfile"), "FROM nginx:alpine"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); - builder.AddVercelEnvironment("vercel"); + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelProductionDeployments(); var api = builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile"); + .WithDockerfile(apiRoot, "Dockerfile") + .WithComputeEnvironment(vercel); var backend = builder.AddContainer("backend", "backend") - .WithDockerfile(sourceRoot.Path, "Dockerfile") - .WithEndpoint(port: 8080, targetPort: 8080, scheme: "http", name: "http"); + .WithDockerfile(backendRoot, "Dockerfile") + .WithEndpoint(port: 8080, targetPort: 8080, scheme: "http", name: "http") + .WithComputeEnvironment(vercel); api.WithEnvironment("BACKEND_URL", backend.GetEndpoint("http")); using var app = builder.Build(); @@ -921,31 +929,76 @@ public async Task BuildDeployArgumentsThrowsForEndpointEnvironmentVariables() var environment = Assert.Single(model.Resources.OfType()); var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment), entry => entry.Resource.Name == "api"); - var exception = await Assert.ThrowsAsync(() => - VercelDeploymentStep.BuildDeployArgumentsAsync( - builder.ExecutionContext, - NullLogger.Instance, - environment.GetVercelOptions(), - entry, - TestContext.Current.CancellationToken)); + string[] arguments = await VercelDeploymentStep.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken); - Assert.Contains("references another Aspire resource endpoint or service", exception.Message); + Assert.Contains("BACKEND_URL=https://backend-app.vercel.app", arguments); } [Fact] - public async Task BuildDeployArgumentsThrowsForServiceDiscoveryReferences() + public async Task BuildDeployArgumentsUsesProductionUrlForServiceDiscoveryReferences() { using var sourceRoot = TemporaryDirectory.Create(); - File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + string apiRoot = Path.Combine(sourceRoot.Path, "api-app"); + string backendRoot = Path.Combine(sourceRoot.Path, "backend-app"); + Directory.CreateDirectory(apiRoot); + Directory.CreateDirectory(backendRoot); + File.WriteAllText(Path.Combine(apiRoot, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(backendRoot, "Dockerfile"), "FROM nginx:alpine"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); - builder.AddVercelEnvironment("vercel"); + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelProductionDeployments(); var backend = builder.AddContainer("backend", "backend") - .WithDockerfile(sourceRoot.Path, "Dockerfile") - .WithEndpoint(port: 8080, targetPort: 8080, scheme: "http", name: "http"); + .WithDockerfile(backendRoot, "Dockerfile") + .WithEndpoint(port: 8080, targetPort: 8080, scheme: "http", name: "http") + .WithComputeEnvironment(vercel); builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile") - .WithReference(backend.GetEndpoint("http")); + .WithDockerfile(apiRoot, "Dockerfile") + .WithReference(backend.GetEndpoint("http")) + .WithComputeEnvironment(vercel); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment), entry => entry.Resource.Name == "api"); + + string[] arguments = await VercelDeploymentStep.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken); + + Assert.Contains("BACKEND_HTTP=https://backend-app.vercel.app", arguments); + } + + [Fact] + public async Task BuildDeployArgumentsThrowsForEndpointReferencesWithoutProductionUrls() + { + using var sourceRoot = TemporaryDirectory.Create(); + string apiRoot = Path.Combine(sourceRoot.Path, "api-app"); + string backendRoot = Path.Combine(sourceRoot.Path, "backend-app"); + Directory.CreateDirectory(apiRoot); + Directory.CreateDirectory(backendRoot); + File.WriteAllText(Path.Combine(apiRoot, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(backendRoot, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelTarget("preview"); + var api = builder.AddContainer("api", "api") + .WithDockerfile(apiRoot, "Dockerfile") + .WithComputeEnvironment(vercel); + var backend = builder.AddContainer("backend", "backend") + .WithDockerfile(backendRoot, "Dockerfile") + .WithEndpoint(port: 8080, targetPort: 8080, scheme: "http", name: "http") + .WithComputeEnvironment(vercel); + api.WithEnvironment("BACKEND_URL", backend.GetEndpoint("http")); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -960,7 +1013,7 @@ public async Task BuildDeployArgumentsThrowsForServiceDiscoveryReferences() entry, TestContext.Current.CancellationToken)); - Assert.Contains("references another Aspire resource endpoint or service", exception.Message); + Assert.Contains("Vercel endpoint references require production deployments", exception.ToString()); } [Fact] From 4ab0be245bb72a9ddfadbb00388aa8201dbdd260 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 30 Jun 2026 22:15:43 -0700 Subject: [PATCH 13/33] Reject duplicate linked Vercel projects Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../README.md | 2 +- .../VercelDeploymentStep.cs | 4 +- .../VercelEnvironmentTests.cs | 47 ++++++++++++++++++- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md index 431fcb8f0..86d281148 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md @@ -111,7 +111,7 @@ This preview integration intentionally supports a narrow Vercel Dockerfile deplo | Container entrypoint overrides and Aspire command-line args | Rejected. Configure runtime command behavior through the Dockerfile/workload publish output or Vercel project settings. | | Existing linked Vercel projects | Supported for deploy. Destroy preserves projects that were linked before deploy and only deletes Aspire-created projects recorded in state. | -Managed Vercel project names are inferred from the source directory name when no `.vercel/project.json` link exists. The integration slugifies the inferred name to Vercel's lowercase, hyphenated project-name form before staging and deployment, and rejects duplicate managed project names within the same Vercel environment. This preview intentionally does not expose resource-level project naming, alias, framework/output, or per-resource target APIs; link a Vercel project before deploy when you need an exact existing project, and add future per-resource settings through resource-specific annotations rather than overloading the environment. +Managed Vercel project names are inferred from the source directory name when no `.vercel/project.json` link exists. The integration slugifies the inferred name to Vercel's lowercase, hyphenated project-name form before staging and deployment, and rejects duplicate project names within the same Vercel environment, including linked projects. Each Aspire resource must map to one distinct Vercel project because production endpoint references use the project-level `https://.vercel.app` URL. This preview intentionally does not expose resource-level project naming, alias, framework/output, or per-resource target APIs; link each resource to a distinct Vercel project before deploy when you need exact existing project identities, and add future per-resource settings through resource-specific annotations rather than overloading the environment. ## Additional documentation diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs index 73f5a9062..b3b0f607b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs @@ -859,7 +859,7 @@ private static void ValidateUniqueVercelProjectNames(IReadOnlyList item.ProjectLink.ProjectName, StringComparer.Ordinal) - .Where(group => group.Count() > 1 && group.Any(static item => !item.Linked)) + .Where(group => group.Count() > 1) .ToArray(); if (projectNames.Length == 0) @@ -870,7 +870,7 @@ private static void ValidateUniqueVercelProjectNames(IReadOnlyList $"'{item.Entry.Resource.Name}'").Order(StringComparer.Ordinal)); throw new DistributedApplicationException( - $"Multiple Vercel resources resolve to managed project name '{collision.Key}' ({resources}). Managed Vercel project names must be unique per environment. Link shared existing projects with .vercel/project.json or use distinct source directory names."); + $"Multiple Vercel resources resolve to project name '{collision.Key}' ({resources}). Vercel project names must be unique per environment because each resource deploys to and references one project production URL. Use distinct source directory names or link each resource to a distinct Vercel project with .vercel/project.json."); } private static void ValidateUnsupportedResourceModel(VercelDeploymentEntry entry) diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs index c114086d6..9c58e129b 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs @@ -593,7 +593,40 @@ public async Task WriteDeploymentPlanThrowsForManagedProjectNameCollisions() var exception = await Assert.ThrowsAsync(() => VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken)); - Assert.Contains("managed project name 'my-api'", exception.Message); + Assert.Contains("project name 'my-api'", exception.Message); + Assert.Contains("'api'", exception.Message); + Assert.Contains("'worker'", exception.Message); + } + + [Fact] + public async Task WriteDeploymentPlanThrowsForLinkedProjectNameCollisions() + { + using var parent = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + string firstRoot = Path.Combine(parent.Path, "api"); + string secondRoot = Path.Combine(parent.Path, "worker"); + Directory.CreateDirectory(firstRoot); + Directory.CreateDirectory(secondRoot); + File.WriteAllText(Path.Combine(firstRoot, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(secondRoot, "Dockerfile"), "FROM nginx:alpine"); + WriteVercelProjectLink(firstRoot, "shared-project", "prj_shared_a"); + WriteVercelProjectLink(secondRoot, "shared-project", "prj_shared_b"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(firstRoot, "Dockerfile"); + builder.AddContainer("worker", "worker") + .WithDockerfile(secondRoot, "Dockerfile"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken)); + + Assert.Contains("project name 'shared-project'", exception.Message); Assert.Contains("'api'", exception.Message); Assert.Contains("'worker'", exception.Message); } @@ -2190,6 +2223,18 @@ private static VercelDeploymentEntry CreateDeploymentEntry(string sourceRoot) new DockerfileBuildAnnotation(sourceRoot, dockerfilePath, stage: null)); } + private static void WriteVercelProjectLink(string sourceRoot, string projectName, string projectId) + { + string vercelDirectory = Path.Combine(sourceRoot, ".vercel"); + Directory.CreateDirectory(vercelDirectory); + File.WriteAllText(Path.Combine(vercelDirectory, "project.json"), $$""" + { + "projectId": "{{projectId}}", + "projectName": "{{projectName}}" + } + """); + } + private static VercelCliResult ReadyInspectResult() => new(0, """{"readyState":"READY"}""", ""); From 122f195af26d885f43e04c30fe725a5ec0a42eb6 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 30 Jun 2026 22:20:56 -0700 Subject: [PATCH 14/33] Tighten Vercel endpoint reference mapping Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../README.md | 5 +- .../VercelDeploymentStep.cs | 86 ++++++++--- .../VercelEnvironmentResource.cs | 20 ++- .../VercelEnvironmentTests.cs | 143 +++++++++++++++++- 4 files changed, 228 insertions(+), 26 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md index 86d281148..e521815a2 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md @@ -68,6 +68,7 @@ var vercel = builder.AddVercelEnvironment("vercel") .WithVercelProductionDeployments(); var backend = builder.AddNodeApp("backend", "../backend", "server.mjs") + .WithEndpoint(name: "http", scheme: "http", env: "PORT", isExternal: true) .WithComputeEnvironment(vercel); builder.AddNodeApp("api", "../api", "server.mjs") @@ -75,7 +76,7 @@ builder.AddNodeApp("api", "../api", "server.mjs") .WithComputeEnvironment(vercel); ``` -The `BACKEND_URL` value is deployed as `https://.vercel.app`. Preview and custom-target deployments still reject endpoint references because those URLs are assigned by Vercel after deployment. +The `BACKEND_URL` value is deployed as `https://.vercel.app`. Endpoint references must target external HTTP or HTTPS endpoints on workloads in the same Vercel environment. Preview and custom-target deployments still reject endpoint references because those URLs are assigned by Vercel after deployment. ## Deployment behavior @@ -104,7 +105,7 @@ This preview integration intentionally supports a narrow Vercel Dockerfile deplo | Container registries, image build, image push | Not used. Vercel uploads and builds the staged source tree. | | Non-secret environment variables | Passed with `vercel deploy --env KEY=value`; names must use letters, digits, and underscores and values are redacted from publish output. | | Secret parameters, connection strings, Docker build args/secrets | Rejected, including composite values that contain secrets. Configure them in Vercel project environment variables or Vercel secrets. | -| Service discovery, endpoint references, `WithReference` to another resource | Supported for Vercel production deployments by using deterministic `https://.vercel.app` URLs. Rejected for preview/custom targets because those URLs are assigned after deployment. | +| Service discovery, endpoint references, `WithReference` to another resource | Supported for external HTTP(S) endpoints on workloads in the same Vercel production environment by using deterministic `https://.vercel.app` URLs. Rejected for preview/custom targets because those URLs are assigned after deployment. | | Endpoints | HTTP and HTTPS endpoints are accepted when they map to one target port. Non-HTTP(S) endpoints or multiple target ports are rejected. | | Volumes, bind mounts, Aspire container files | Rejected. Include required files in the source tree or use a Vercel-supported external service for state. | | Health checks, probes, replicas, wait/dependency ordering | Rejected until they are mapped to Vercel-native behavior. | diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs index b3b0f607b..549acabfb 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs @@ -149,6 +149,7 @@ public static async Task DeployAsync(PipelineStepContext context, VercelEnvironm context.Logger, options, preparedEntry, + entries, context.CancellationToken).ConfigureAwait(false); var result = await runner.RunAsync(VercelCliFileName, arguments, preparedEntry.SourceRoot, context.CancellationToken).ConfigureAwait(false); @@ -334,12 +335,29 @@ internal static async Task BuildDeployArgumentsAsync( VercelEnvironmentOptionsAnnotation options, VercelDeploymentEntry entry, CancellationToken cancellationToken) + => await BuildDeployArgumentsAsync( + executionContext, + logger, + options, + entry, + [entry], + cancellationToken).ConfigureAwait(false); + + internal static async Task BuildDeployArgumentsAsync( + DistributedApplicationExecutionContext executionContext, + ILogger logger, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry, + IReadOnlyList entries, + CancellationToken cancellationToken) { + var entriesByResourceName = GetDeploymentEntriesByResourceName(entries); var environmentVariables = await GetVercelEnvironmentVariablesAsync( executionContext, logger, options, entry, + entriesByResourceName, cancellationToken).ConfigureAwait(false); return BuildDeployArguments(options, entry.SourceRoot, environmentVariables); @@ -353,12 +371,13 @@ private static async Task CreateDeploymentPlanEntri CancellationToken cancellationToken) { List planEntries = []; + var entriesByResourceName = GetDeploymentEntriesByResourceName(entries); foreach (var entry in entries) { var environmentVariables = executionContext is null || logger is null ? [] - : await GetVercelEnvironmentVariablesAsync(executionContext, logger, options, entry, cancellationToken).ConfigureAwait(false); + : await GetVercelEnvironmentVariablesAsync(executionContext, logger, options, entry, entriesByResourceName, cancellationToken).ConfigureAwait(false); planEntries.Add(new( entry.Resource.Name, @@ -375,6 +394,7 @@ private static async Task>> GetVercel ILogger logger, VercelEnvironmentOptionsAnnotation options, VercelDeploymentEntry entry, + IReadOnlyDictionary entriesByResourceName, CancellationToken cancellationToken) { var executionConfiguration = await ExecutionConfigurationBuilder @@ -391,7 +411,7 @@ private static async Task>> GetVercel ValidateUnsupportedRuntimeConfiguration(entry.Resource, executionConfiguration); - var environmentVariables = GetVercelEnvironmentVariables(entry.Resource, options, executionConfiguration); + var environmentVariables = GetVercelEnvironmentVariables(entry.Resource, options, executionConfiguration, entriesByResourceName); return environmentVariables; } @@ -594,7 +614,8 @@ private static bool IsMissingProjectResult(VercelCliResult result, string projec private static IReadOnlyList> GetVercelEnvironmentVariables( IResource resource, VercelEnvironmentOptionsAnnotation options, - IExecutionConfigurationResult executionConfiguration) + IExecutionConfigurationResult executionConfiguration, + IReadOnlyDictionary entriesByResourceName) { List> environmentVariables = []; HashSet names = new(StringComparer.Ordinal); @@ -624,7 +645,7 @@ private static IReadOnlyList> GetVercelEnvironmentV $"Environment variable '{name}' for resource '{resource.Name}' references another Aspire resource or service in a way that cannot be represented as a Vercel deployment URL. Use endpoint references to Vercel production workloads, or configure the value in Vercel project environment variables."); } - if (TryGetVercelEnvironmentVariableValue(resource, options, unprocessedValue, out string? vercelValue)) + if (TryGetVercelEnvironmentVariableValue(resource, options, entriesByResourceName, unprocessedValue, out string? vercelValue)) { value = vercelValue; } @@ -638,19 +659,20 @@ private static IReadOnlyList> GetVercelEnvironmentV private static bool TryGetVercelEnvironmentVariableValue( IResource resource, VercelEnvironmentOptionsAnnotation options, + IReadOnlyDictionary entriesByResourceName, object? value, [NotNullWhen(true)] out string? vercelValue) { switch (value) { case EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference): - vercelValue = GetVercelEndpointPropertyValue(options, endpointReference.Property(EndpointProperty.Url)); + vercelValue = GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReference.Property(EndpointProperty.Url)); return true; case EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint): - vercelValue = GetVercelEndpointPropertyValue(options, endpointReferenceExpression); + vercelValue = GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReferenceExpression); return true; case ReferenceExpression referenceExpression when ContainsCrossResourceEndpointReference(resource, referenceExpression): - vercelValue = GetVercelReferenceExpressionValue(resource, options, referenceExpression); + vercelValue = GetVercelReferenceExpressionValue(resource, options, entriesByResourceName, referenceExpression); return true; default: vercelValue = null; @@ -661,6 +683,7 @@ private static bool TryGetVercelEnvironmentVariableValue( private static string GetVercelReferenceExpressionValue( IResource resource, VercelEnvironmentOptionsAnnotation options, + IReadOnlyDictionary entriesByResourceName, ReferenceExpression referenceExpression) { if (referenceExpression.IsConditional) @@ -674,8 +697,8 @@ private static string GetVercelReferenceExpressionValue( IValueProvider valueProvider = referenceExpression.ValueProviders[i]; arguments[i] = valueProvider switch { - EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference) => GetVercelEndpointPropertyValue(options, endpointReference.Property(EndpointProperty.Url)), - EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint) => GetVercelEndpointPropertyValue(options, endpointReferenceExpression), + EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference) => GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReference.Property(EndpointProperty.Url)), + EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint) => GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReferenceExpression), _ => throw new DistributedApplicationException("Vercel endpoint reference expressions cannot be combined with parameters, secrets, or other value providers. Configure a concrete Vercel project environment variable instead.") }; @@ -689,7 +712,9 @@ EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndp } private static string GetVercelEndpointPropertyValue( + IResource resource, VercelEnvironmentOptionsAnnotation options, + IReadOnlyDictionary entriesByResourceName, EndpointReferenceExpression endpointReferenceExpression) { if (!options.Production) @@ -700,7 +725,25 @@ private static string GetVercelEndpointPropertyValue( var endpointReference = endpointReferenceExpression.Endpoint; var endpoint = endpointReference.EndpointAnnotation; - string host = $"{GetVercelProjectName(endpointReference.Resource)}.vercel.app"; + if (!endpoint.IsExternal) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' references endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}', but Vercel endpoint references can only target external HTTP or HTTPS endpoints. Configure an external endpoint or remove the reference."); + } + + if (!IsHttpEndpoint(endpoint)) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' references endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}' with scheme '{endpoint.UriScheme}', but Vercel endpoint references support only HTTP or HTTPS endpoints."); + } + + if (!entriesByResourceName.TryGetValue(endpointReference.Resource.Name, out var referencedEntry)) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' references endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}', but the referenced resource does not target this Vercel environment. Vercel endpoint references can only target workloads deployed to the same Vercel environment."); + } + + string host = $"{GetVercelProjectName(referencedEntry)}.vercel.app"; const int port = 443; return endpointReferenceExpression.Property switch @@ -710,7 +753,8 @@ private static string GetVercelEndpointPropertyValue( EndpointProperty.Port => port.ToString(CultureInfo.InvariantCulture), EndpointProperty.TargetPort => endpoint.TargetPort is int targetPort ? targetPort.ToString(CultureInfo.InvariantCulture) - : port.ToString(CultureInfo.InvariantCulture), + : throw new DistributedApplicationException( + $"Resource '{resource.Name}' references endpoint property '{EndpointProperty.TargetPort}' for endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}', but the endpoint does not define an explicit target port. Configure a target port or avoid passing TargetPort to Vercel."), EndpointProperty.Scheme => "https", EndpointProperty.HostAndPort => $"{host}:{port.ToString(CultureInfo.InvariantCulture)}", EndpointProperty.TlsEnabled => bool.TrueString, @@ -781,7 +825,7 @@ private static bool ContainsCrossResourceEndpointReference(IResource resource, o } private static bool IsCrossResourceEndpointReference(IResource resource, EndpointReference endpointReference) - => !ReferenceEquals(endpointReference.Resource, resource); + => !IsSameResource(resource, endpointReference.Resource); private static bool ContainsUnsupportedResourceReference(IResource resource, object? value) { @@ -793,13 +837,23 @@ private static bool ContainsUnsupportedResourceReference(IResource resource, obj IResourceBuilder => false, EndpointReference => false, EndpointReferenceExpression => false, - IResource referencedResource => !ReferenceEquals(referencedResource, resource), + IResource referencedResource => !IsSameResource(resource, referencedResource), IValueWithReferences valueWithReferences => valueWithReferences.References.Any(reference => ContainsUnsupportedResourceReference(resource, reference)), - IResourceBuilder resourceBuilder => !ReferenceEquals(resourceBuilder.Resource, resource), + IResourceBuilder resourceBuilder => !IsSameResource(resource, resourceBuilder.Resource), _ => false }; } + private static bool IsSameResource(IResource resource, IResource otherResource) + => string.Equals(resource.Name, otherResource.Name, StringComparison.Ordinal); + + private static bool IsHttpEndpoint(EndpointAnnotation endpoint) + => string.Equals(endpoint.UriScheme, "http", StringComparison.OrdinalIgnoreCase) + || string.Equals(endpoint.UriScheme, "https", StringComparison.OrdinalIgnoreCase); + + private static IReadOnlyDictionary GetDeploymentEntriesByResourceName(IReadOnlyList entries) + => entries.ToDictionary(static entry => entry.Resource.Name, StringComparer.Ordinal); + internal static IEnumerable GetDeploymentEntries(DistributedApplicationModel model, VercelEnvironmentResource environment) { foreach (var resource in model.Resources.OfType()) @@ -924,9 +978,7 @@ private static void ValidateEndpointModel(VercelDeploymentEntry entry) return; } - var unsupportedEndpoint = endpoints.FirstOrDefault(static endpoint => - !endpoint.UriScheme.Equals("http", StringComparison.OrdinalIgnoreCase) - && !endpoint.UriScheme.Equals("https", StringComparison.OrdinalIgnoreCase)); + var unsupportedEndpoint = endpoints.FirstOrDefault(static endpoint => !IsHttpEndpoint(endpoint)); if (unsupportedEndpoint is not null) { throw new DistributedApplicationException( diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs index ae6cd32d4..f5586d9f7 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs @@ -27,6 +27,19 @@ public ReferenceExpression GetHostAddressExpression(EndpointReference endpointRe $"Vercel endpoint references require production deployments because preview and custom target URLs are assigned by Vercel after deployment. Call {nameof(VercelEnvironmentResourceBuilderExtensions.WithVercelProductionDeployments)} on the Vercel environment, or remove the reference."); } + var endpoint = endpointReference.EndpointAnnotation; + if (!endpoint.IsExternal) + { + throw new InvalidOperationException( + $"Vercel endpoint references can only target external HTTP or HTTPS endpoints. Endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}' is not external."); + } + + if (!IsHttpEndpoint(endpoint)) + { + throw new InvalidOperationException( + $"Vercel endpoint references support only HTTP or HTTPS endpoints. Endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}' uses scheme '{endpoint.UriScheme}'."); + } + string projectName = VercelDeploymentStep.GetVercelProjectName(endpointReference.Resource); return ReferenceExpression.Create($"{projectName}.vercel.app"); } @@ -50,11 +63,16 @@ public ReferenceExpression GetEndpointPropertyExpression(EndpointReferenceExpres EndpointProperty.Port => ReferenceExpression.Create($"{port.ToString(CultureInfo.InvariantCulture)}"), EndpointProperty.TargetPort => endpoint.TargetPort is int targetPort ? ReferenceExpression.Create($"{targetPort.ToString(CultureInfo.InvariantCulture)}") - : ReferenceExpression.Create($"{port.ToString(CultureInfo.InvariantCulture)}"), + : throw new InvalidOperationException( + $"The endpoint property '{EndpointProperty.TargetPort}' cannot be resolved for endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}' because the endpoint does not define an explicit target port."), EndpointProperty.Scheme => ReferenceExpression.Create($"https"), EndpointProperty.HostAndPort => ReferenceExpression.Create($"{host}:{port.ToString(CultureInfo.InvariantCulture)}"), EndpointProperty.TlsEnabled => ReferenceExpression.Create($"{bool.TrueString}"), _ => throw new InvalidOperationException($"The property '{property}' is not supported for the endpoint '{endpoint.Name}'.") }; } + + private static bool IsHttpEndpoint(EndpointAnnotation endpoint) + => string.Equals(endpoint.UriScheme, "http", StringComparison.OrdinalIgnoreCase) + || string.Equals(endpoint.UriScheme, "https", StringComparison.OrdinalIgnoreCase); } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs index 9c58e129b..7222ec48b 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs @@ -953,20 +953,22 @@ public async Task BuildDeployArgumentsUsesProductionUrlForEndpointEnvironmentVar .WithComputeEnvironment(vercel); var backend = builder.AddContainer("backend", "backend") .WithDockerfile(backendRoot, "Dockerfile") - .WithEndpoint(port: 8080, targetPort: 8080, scheme: "http", name: "http") + .WithEndpoint(port: 8080, targetPort: 8080, scheme: "http", name: "http", isExternal: true) .WithComputeEnvironment(vercel); api.WithEnvironment("BACKEND_URL", backend.GetEndpoint("http")); using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment), entry => entry.Resource.Name == "api"); + var entries = VercelDeploymentStep.GetDeploymentEntries(model, environment).ToArray(); + var entry = Assert.Single(entries, entry => entry.Resource.Name == "api"); string[] arguments = await VercelDeploymentStep.BuildDeployArgumentsAsync( builder.ExecutionContext, NullLogger.Instance, environment.GetVercelOptions(), entry, + entries, TestContext.Current.CancellationToken); Assert.Contains("BACKEND_URL=https://backend-app.vercel.app", arguments); @@ -988,7 +990,7 @@ public async Task BuildDeployArgumentsUsesProductionUrlForServiceDiscoveryRefere .WithVercelProductionDeployments(); var backend = builder.AddContainer("backend", "backend") .WithDockerfile(backendRoot, "Dockerfile") - .WithEndpoint(port: 8080, targetPort: 8080, scheme: "http", name: "http") + .WithEndpoint(port: 8080, targetPort: 8080, scheme: "http", name: "http", isExternal: true) .WithComputeEnvironment(vercel); builder.AddContainer("api", "api") .WithDockerfile(apiRoot, "Dockerfile") @@ -998,13 +1000,15 @@ public async Task BuildDeployArgumentsUsesProductionUrlForServiceDiscoveryRefere using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment), entry => entry.Resource.Name == "api"); + var entries = VercelDeploymentStep.GetDeploymentEntries(model, environment).ToArray(); + var entry = Assert.Single(entries, entry => entry.Resource.Name == "api"); string[] arguments = await VercelDeploymentStep.BuildDeployArgumentsAsync( builder.ExecutionContext, NullLogger.Instance, environment.GetVercelOptions(), entry, + entries, TestContext.Current.CancellationToken); Assert.Contains("BACKEND_HTTP=https://backend-app.vercel.app", arguments); @@ -1029,14 +1033,15 @@ public async Task BuildDeployArgumentsThrowsForEndpointReferencesWithoutProducti .WithComputeEnvironment(vercel); var backend = builder.AddContainer("backend", "backend") .WithDockerfile(backendRoot, "Dockerfile") - .WithEndpoint(port: 8080, targetPort: 8080, scheme: "http", name: "http") + .WithEndpoint(port: 8080, targetPort: 8080, scheme: "http", name: "http", isExternal: true) .WithComputeEnvironment(vercel); api.WithEnvironment("BACKEND_URL", backend.GetEndpoint("http")); using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment), entry => entry.Resource.Name == "api"); + var entries = VercelDeploymentStep.GetDeploymentEntries(model, environment).ToArray(); + var entry = Assert.Single(entries, entry => entry.Resource.Name == "api"); var exception = await Assert.ThrowsAsync(() => VercelDeploymentStep.BuildDeployArgumentsAsync( @@ -1044,11 +1049,137 @@ public async Task BuildDeployArgumentsThrowsForEndpointReferencesWithoutProducti NullLogger.Instance, environment.GetVercelOptions(), entry, + entries, TestContext.Current.CancellationToken)); Assert.Contains("Vercel endpoint references require production deployments", exception.ToString()); } + [Fact] + public async Task BuildDeployArgumentsThrowsForEndpointReferencesToDifferentVercelEnvironment() + { + using var sourceRoot = TemporaryDirectory.Create(); + string apiRoot = Path.Combine(sourceRoot.Path, "api-app"); + string backendRoot = Path.Combine(sourceRoot.Path, "backend-app"); + Directory.CreateDirectory(apiRoot); + Directory.CreateDirectory(backendRoot); + File.WriteAllText(Path.Combine(apiRoot, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(backendRoot, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelProductionDeployments(); + var otherVercel = builder.AddVercelEnvironment("other-vercel") + .WithVercelProductionDeployments(); + var api = builder.AddContainer("api", "api") + .WithDockerfile(apiRoot, "Dockerfile") + .WithComputeEnvironment(vercel); + var backend = builder.AddContainer("backend", "backend") + .WithDockerfile(backendRoot, "Dockerfile") + .WithEndpoint(port: 8080, targetPort: 8080, scheme: "http", name: "http", isExternal: true) + .WithComputeEnvironment(otherVercel); + api.WithEnvironment("BACKEND_URL", backend.GetEndpoint("http")); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType(), environment => environment.Name == "vercel"); + var entries = VercelDeploymentStep.GetDeploymentEntries(model, environment).ToArray(); + var entry = Assert.Single(entries, entry => entry.Resource.Name == "api"); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + entries, + TestContext.Current.CancellationToken)); + + Assert.Contains("does not target this Vercel environment", exception.Message); + } + + [Fact] + public async Task BuildDeployArgumentsThrowsForInternalEndpointReferences() + { + using var sourceRoot = TemporaryDirectory.Create(); + string apiRoot = Path.Combine(sourceRoot.Path, "api-app"); + string backendRoot = Path.Combine(sourceRoot.Path, "backend-app"); + Directory.CreateDirectory(apiRoot); + Directory.CreateDirectory(backendRoot); + File.WriteAllText(Path.Combine(apiRoot, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(backendRoot, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelProductionDeployments(); + var api = builder.AddContainer("api", "api") + .WithDockerfile(apiRoot, "Dockerfile") + .WithComputeEnvironment(vercel); + var backend = builder.AddContainer("backend", "backend") + .WithDockerfile(backendRoot, "Dockerfile") + .WithEndpoint(port: 8080, targetPort: 8080, scheme: "http", name: "http", isExternal: false) + .WithComputeEnvironment(vercel); + api.WithEnvironment("BACKEND_URL", backend.GetEndpoint("http")); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entries = VercelDeploymentStep.GetDeploymentEntries(model, environment).ToArray(); + var entry = Assert.Single(entries, entry => entry.Resource.Name == "api"); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + entries, + TestContext.Current.CancellationToken)); + + Assert.Contains("can only target external HTTP or HTTPS endpoints", exception.Message); + } + + [Fact] + public async Task BuildDeployArgumentsThrowsForTargetPortEndpointReferenceWithoutExplicitTargetPort() + { + using var sourceRoot = TemporaryDirectory.Create(); + string apiRoot = Path.Combine(sourceRoot.Path, "api-app"); + string backendRoot = Path.Combine(sourceRoot.Path, "backend-app"); + Directory.CreateDirectory(apiRoot); + Directory.CreateDirectory(backendRoot); + File.WriteAllText(Path.Combine(apiRoot, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(backendRoot, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelProductionDeployments(); + var api = builder.AddContainer("api", "api") + .WithDockerfile(apiRoot, "Dockerfile") + .WithComputeEnvironment(vercel); + var backend = builder.AddContainer("backend", "backend") + .WithDockerfile(backendRoot, "Dockerfile") + .WithEndpoint(port: 8080, scheme: "http", name: "http", isExternal: true) + .WithComputeEnvironment(vercel); + api.WithEnvironment("BACKEND_TARGET_PORT", backend.GetEndpoint("http").Property(EndpointProperty.TargetPort)); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entries = VercelDeploymentStep.GetDeploymentEntries(model, environment).ToArray(); + var entry = Assert.Single(entries, entry => entry.Resource.Name == "api"); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + entries, + TestContext.Current.CancellationToken)); + + Assert.Contains("does not define an explicit target port", exception.Message); + } + [Fact] public async Task BuildDeployArgumentsThrowsForCommandLineArguments() { From f8a02e9be85471dd29b14cdbef0e2df3cdf20518 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 30 Jun 2026 22:41:30 -0700 Subject: [PATCH 15/33] Tighten Vercel deploy preflight Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../README.md | 4 +- .../VercelDeploymentStep.cs | 156 ++++++++++++++++++ .../VercelEnvironmentTests.cs | 131 ++++++++++++++- 3 files changed, 286 insertions(+), 5 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md index e521815a2..568eee977 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md @@ -48,7 +48,7 @@ builder.AddNodeApp("api", "../api", "server.mjs") .WithComputeEnvironment(vercel); ``` -For low-level container resources, Vercel requires existing Aspire Dockerfile build metadata. Prefer the workload-specific `Add*App` integration when one exists. `WithDockerfile`, `WithDockerfileFactory`, and `WithDockerfileBuilder` are supported as advanced escape hatches. The integration always stages the source root into Aspire's deploy-time temp directory, materializes the Vercel-facing `Dockerfile` there, and runs `vercel deploy` from the staged directory so the Vercel CLI does not write `.vercel` metadata into the source tree. Staging skips `.git`, `node_modules`, and unmanaged `.vercel` metadata; linked projects keep only `.vercel/project.json`. Add a `.vercelignore` file to exclude local files such as `.env`, `bin/`, `obj/`, test artifacts, large assets, and other files that should not be uploaded to Vercel. +For low-level container resources, Vercel requires existing Aspire Dockerfile build metadata. Prefer the workload-specific `Add*App` integration when one exists. `WithDockerfile`, `WithDockerfileFactory`, and `WithDockerfileBuilder` are supported as advanced escape hatches. The integration always stages the source root into Aspire's deploy-time temp directory, materializes the Vercel-facing `Dockerfile` there, and runs `vercel deploy` from the staged directory so the Vercel CLI does not write `.vercel` metadata into the source tree. Staging skips `.git`, `node_modules`, and unmanaged `.vercel` metadata; linked projects keep only `.vercel/project.json`. Add a `.vercelignore` file to exclude local files such as `.env`, `bin/`, `obj/`, test artifacts, large assets, and other files that should not be uploaded to Vercel. Deploy emits a warning when common sensitive or heavy root entries such as `.env*`, `bin/`, `obj/`, `TestResults/`, or `coverage/` are present and are not covered by `.vercelignore`. Non-secret Aspire environment variables configured on the resource are processed during publish/deploy and passed to Vercel as CLI environment variables: @@ -89,7 +89,7 @@ The `BACKEND_URL` value is deployed as `https://.vercel.app`. E - An Aspire workload with Dockerfile publish metadata, such as a language app resource that emits Dockerfile metadata or a project configured with `PublishAsDockerFile`. - The deployed container should listen on `$PORT`; the default port is `80`. -- Vercel CLI installed and available on `PATH`. Use the latest Vercel CLI; this preview is tested with Vercel CLI 54.18.6 and depends on `deploy --env`, `inspect --wait --timeout --format=json`, and `project remove`. +- Vercel CLI 54.18.6 or later installed and available on `PATH`. This preview depends on `deploy --env`, `inspect --wait --timeout --format=json`, and `project remove`; older CLI versions are rejected during deploy preflight. - Vercel authentication from an existing CLI login or the `VERCEL_TOKEN` environment variable. - Any project linking, secrets, domains, deployment protection, or build-time environment variables configured in Vercel. diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs index 549acabfb..f792d42f2 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs @@ -15,6 +15,7 @@ using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.RegularExpressions; namespace CommunityToolkit.Aspire.Hosting.Vercel; @@ -31,12 +32,21 @@ internal static class VercelDeploymentStep private const int DeploymentStateSchemaVersion = 1; private const int VercelProjectNameMaxLength = 100; private const string VercelCliFileName = "vercel"; + private static readonly Version MinimumVercelCliVersion = new(54, 18, 6); private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; + private static readonly string[] CommonSourceUploadDirectoryWarnings = + [ + "bin", + "obj", + "TestResults", + "coverage" + ]; + public static async Task WriteDeploymentPlanAsync(PipelineStepContext context, VercelEnvironmentResource environment) { var outputService = context.Services.GetRequiredService(); @@ -115,6 +125,19 @@ public static async Task ValidateCliPrerequisitesAsync(PipelineStepContext conte throw CreateCliException("validate Vercel CLI installation", VercelCliFileName, versionResult); } + var versionOutput = $"{versionResult.StandardOutput}{Environment.NewLine}{versionResult.StandardError}"; + if (!TryGetVercelCliVersion(versionOutput, out var version)) + { + throw new DistributedApplicationException( + $"Failed to determine Vercel CLI version from '{GetTrimmedOutput(versionOutput)}'. Install Vercel CLI {MinimumVercelCliVersion} or later from https://vercel.com/docs/cli."); + } + + if (version < MinimumVercelCliVersion) + { + throw new DistributedApplicationException( + $"Vercel CLI version '{version}' is not supported. Install Vercel CLI {MinimumVercelCliVersion} or later from https://vercel.com/docs/cli."); + } + var whoamiResult = await runner.RunAsync(VercelCliFileName, ["whoami"], workingDirectory: null, context.CancellationToken).ConfigureAwait(false); if (!whoamiResult.Succeeded) { @@ -1019,6 +1042,8 @@ private static async Task PrepareDeploymentEntryAsync(Pip Directory.Delete(stagingRoot, recursive: true); } + LogSourceUploadWarnings(context.Logger, entry); + CopyDirectory( entry.SourceRoot, stagingRoot, @@ -1080,6 +1105,121 @@ private static string GetStagingSourceRoot(string tempDirectory, VercelDeploymen return Path.Combine(tempDirectory, sourceRootName); } + private static void LogSourceUploadWarnings(ILogger logger, VercelDeploymentEntry entry) + { + var warningPaths = GetSourceUploadWarningPaths(entry.SourceRoot); + if (warningPaths.Count == 0) + { + return; + } + + logger.LogWarning( + "Resource '{ResourceName}' source root contains files or directories that may be uploaded to Vercel: {Paths}. Add or update .vercelignore to exclude sensitive or unnecessary content.", + entry.Resource.Name, + string.Join(", ", warningPaths)); + } + + internal static IReadOnlyList GetSourceUploadWarningPaths(string sourceRoot) + { + var ignorePatterns = ReadVercelIgnorePatterns(sourceRoot); + List warningPaths = []; + + foreach (string directoryName in CommonSourceUploadDirectoryWarnings) + { + string directoryPath = Path.Combine(sourceRoot, directoryName); + if (Directory.Exists(directoryPath) && !IsIgnoredByVercelIgnore(directoryName, isDirectory: true, ignorePatterns)) + { + warningPaths.Add($"{directoryName}/"); + } + } + + foreach (string file in Directory.EnumerateFiles(sourceRoot, ".env*")) + { + string fileName = Path.GetFileName(file); + if (IsExampleEnvironmentFile(fileName)) + { + continue; + } + + if (!IsIgnoredByVercelIgnore(fileName, isDirectory: false, ignorePatterns)) + { + warningPaths.Add(fileName); + } + } + + return [.. warningPaths.Order(StringComparer.Ordinal)]; + } + + private static bool IsExampleEnvironmentFile(string fileName) + => fileName.Equals(".env.example", GetPathStringComparison()) + || fileName.Equals(".env.sample", GetPathStringComparison()) + || fileName.Equals(".env.template", GetPathStringComparison()); + + private static IReadOnlyList ReadVercelIgnorePatterns(string sourceRoot) + { + string vercelIgnorePath = Path.Combine(sourceRoot, ".vercelignore"); + if (!File.Exists(vercelIgnorePath)) + { + return []; + } + + return [.. File.ReadAllLines(vercelIgnorePath) + .Select(static line => line.Trim()) + .Where(static line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith('#'))]; + } + + private static bool IsIgnoredByVercelIgnore(string relativePath, bool isDirectory, IReadOnlyList ignorePatterns) + { + bool ignored = false; + foreach (string pattern in ignorePatterns) + { + bool negated = pattern.StartsWith('!'); + string normalizedPattern = negated ? pattern[1..] : pattern; + if (string.IsNullOrWhiteSpace(normalizedPattern)) + { + continue; + } + + if (VercelIgnorePatternMatches(relativePath, isDirectory, normalizedPattern)) + { + ignored = !negated; + } + } + + return ignored; + } + + private static bool VercelIgnorePatternMatches(string relativePath, bool isDirectory, string pattern) + { + bool directoryOnly = pattern.EndsWith('/'); + if (directoryOnly && !isDirectory) + { + return false; + } + + string normalizedPattern = pattern.Trim('/'); + if (string.IsNullOrWhiteSpace(normalizedPattern)) + { + return false; + } + + string normalizedPath = relativePath.Replace(Path.DirectorySeparatorChar, '/'); + if (normalizedPattern.Contains('/')) + { + return WildcardMatches(normalizedPath, normalizedPattern); + } + + return normalizedPath + .Split('/', StringSplitOptions.RemoveEmptyEntries) + .Any(segment => WildcardMatches(segment, normalizedPattern)); + } + + private static bool WildcardMatches(string value, string pattern) + { + string regexPattern = $"^{Regex.Escape(pattern).Replace("\\*", ".*", StringComparison.Ordinal).Replace("\\?", ".", StringComparison.Ordinal)}$"; + return Regex.IsMatch(value, regexPattern, RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); + } + private static void CopyDirectory( string sourceDirectory, string destinationDirectory, @@ -1380,6 +1520,22 @@ private static bool TryGetHttpUrl(JsonElement urlElement, [NotNullWhen(true)] ou private static bool IsHttpUrl([NotNullWhen(true)] string? url) => Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http"; + internal static bool TryGetVercelCliVersion(string output, [NotNullWhen(true)] out Version? version) + { + var match = Regex.Match(output, @"(?\d+)\.(?\d+)\.(?\d+)(?!\d)", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); + if (!match.Success) + { + version = null; + return false; + } + + version = new( + int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture), + int.Parse(match.Groups["minor"].Value, CultureInfo.InvariantCulture), + int.Parse(match.Groups["patch"].Value, CultureInfo.InvariantCulture)); + return true; + } + private static string GetTrimmedOutput(string output) => string.IsNullOrWhiteSpace(output) ? "" : output.Trim(); diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs index 7222ec48b..35d8db18c 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs @@ -1520,6 +1520,67 @@ public async Task DeployAsyncSkipsIgnoredDirectoriesWhenStagingManagedProjects() Assert.False(Directory.Exists(Path.Combine(expectedStagingRoot, ".vercel"))); } + [Fact] + public async Task DeployAsyncWarnsWhenSourceUploadMayIncludeSensitiveOrHeavyFiles() + { + using var sourceRoot = TemporaryDirectory.Create("source-warning-project"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(sourceRoot.Path, ".env"), "SECRET=value"); + Directory.CreateDirectory(Path.Combine(sourceRoot.Path, "bin")); + Directory.CreateDirectory(Path.Combine(sourceRoot.Path, "obj")); + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, "https://source-warning-project.vercel.app", ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); + var logger = new RecordingLogger(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app, logger); + + await VercelDeploymentStep.DeployAsync(context, environment); + + var warning = Assert.Single(logger.Entries, entry => entry.Level == LogLevel.Warning); + Assert.Contains("source root contains files or directories that may be uploaded to Vercel", warning.Message); + Assert.Contains(".env", warning.Message); + Assert.Contains("bin/", warning.Message); + Assert.Contains("obj/", warning.Message); + Assert.DoesNotContain("SECRET=value", warning.Message); + } + + [Fact] + public void GetSourceUploadWarningPathsHonorsVercelIgnore() + { + using var sourceRoot = TemporaryDirectory.Create("ignored-source-warning-project"); + File.WriteAllText(Path.Combine(sourceRoot.Path, ".vercelignore"), """ + .env* + bin/ + obj/ + TestResults/ + coverage/ + """); + File.WriteAllText(Path.Combine(sourceRoot.Path, ".env.local"), "SECRET=value"); + File.WriteAllText(Path.Combine(sourceRoot.Path, ".env.example"), "DOCUMENTED=value"); + Directory.CreateDirectory(Path.Combine(sourceRoot.Path, "bin")); + Directory.CreateDirectory(Path.Combine(sourceRoot.Path, "obj")); + Directory.CreateDirectory(Path.Combine(sourceRoot.Path, "TestResults")); + Directory.CreateDirectory(Path.Combine(sourceRoot.Path, "coverage")); + + var warnings = VercelDeploymentStep.GetSourceUploadWarningPaths(sourceRoot.Path); + + Assert.Empty(warnings); + } + [Fact] public async Task DeployAsyncStagesGeneratedDockerfileBeforeRunningVercelCli() { @@ -1753,7 +1814,7 @@ public async Task DeployAsyncThrowsWhenVercelInspectReportsNonReadyState() public async Task ValidateCliPrerequisitesRunsVersionAndWhoami() { var runner = new FakeVercelCliRunner( - new(0, "54.18.6", ""), + new(0, "Vercel CLI 54.18.6", ""), new(0, "davidfowl-6717", "")); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); @@ -1866,6 +1927,48 @@ public async Task ValidateCliPrerequisitesThrowsWhenVersionFailsWithStandardOutp Assert.Contains("Failed to validate Vercel CLI installation using 'vercel' (exit code 1). missing vercel", exception.Message); } + [Fact] + public async Task ValidateCliPrerequisitesThrowsWhenVersionIsTooOld() + { + var runner = new FakeVercelCliRunner(new VercelCliResult(0, "54.18.5", "")); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.ValidateCliPrerequisitesAsync(context, environment)); + + Assert.Contains("Vercel CLI version '54.18.5' is not supported", exception.Message); + Assert.Single(runner.Invocations); + } + + [Fact] + public async Task ValidateCliPrerequisitesThrowsWhenVersionCannotBeParsed() + { + var runner = new FakeVercelCliRunner(new VercelCliResult(0, "vercel dev build", "")); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.ValidateCliPrerequisitesAsync(context, environment)); + + Assert.Contains("Failed to determine Vercel CLI version", exception.Message); + Assert.Single(runner.Invocations); + } + [Fact] public async Task ValidatePrerequisitesThrowsWhenNoResourcesArePublished() { @@ -2327,14 +2430,15 @@ public void GetVercelOptionsReturnsDefaultOptionsWithoutAnnotation() private static PipelineStepContext CreatePipelineStepContext( IDistributedApplicationBuilder builder, - DistributedApplication app) + DistributedApplication app, + ILogger? logger = null) { var model = app.Services.GetRequiredService(); var pipelineContext = new PipelineContext( model, builder.ExecutionContext, app.Services, - NullLogger.Instance, + logger ?? NullLogger.Instance, TestContext.Current.CancellationToken); return new() @@ -2469,6 +2573,27 @@ public Task RunAsync( } } + private sealed class RecordingLogger : ILogger + { + public List Entries { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + Entries.Add(new(logLevel, formatter(state, exception))); + } + } + + private sealed record LogEntry(LogLevel Level, string Message); + private sealed record VercelCliInvocation( string FileName, string[] Arguments, From fddbaa3a11a4de12344e39ae4c67a251deb4bd50 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 30 Jun 2026 22:44:36 -0700 Subject: [PATCH 16/33] Add Vercel project name customization Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../apphost.mts | 1 + .../README.md | 11 +- .../VercelDeploymentStep.cs | 25 +++- .../VercelProjectOptionsAnnotation.cs | 5 + .../VercelResourceBuilderExtensions.cs | 42 ++++++ .../VercelEnvironmentTests.cs | 125 ++++++++++++++++++ 6 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectOptionsAnnotation.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceBuilderExtensions.cs diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts index 0060f506b..187e4f361 100644 --- a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts @@ -9,6 +9,7 @@ await builder.addDockerComposeEnvironment("docker"); const api = await builder .addNodeApp("api", "./ct-aspire-vercel-typescript", "server.mjs"); +await api.withVercelProjectName("typescript-api"); await api.withComputeEnvironment(vercel); await api.withEnvironment("GREETING", "hello-from-typescript-apphost"); diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md index 568eee977..edc24559a 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md @@ -48,6 +48,15 @@ builder.AddNodeApp("api", "../api", "server.mjs") .WithComputeEnvironment(vercel); ``` +Use `WithVercelProjectName` when an Aspire-managed resource should deploy to a specific Vercel project name instead of inferring one from the source directory: + +```csharp +builder.AddNodeApp("api", "../api", "server.mjs") + .WithVercelProjectName("my-api"); +``` + +Linked projects with `.vercel/project.json` keep their existing provider project identity and take precedence over `WithVercelProjectName`. + For low-level container resources, Vercel requires existing Aspire Dockerfile build metadata. Prefer the workload-specific `Add*App` integration when one exists. `WithDockerfile`, `WithDockerfileFactory`, and `WithDockerfileBuilder` are supported as advanced escape hatches. The integration always stages the source root into Aspire's deploy-time temp directory, materializes the Vercel-facing `Dockerfile` there, and runs `vercel deploy` from the staged directory so the Vercel CLI does not write `.vercel` metadata into the source tree. Staging skips `.git`, `node_modules`, and unmanaged `.vercel` metadata; linked projects keep only `.vercel/project.json`. Add a `.vercelignore` file to exclude local files such as `.env`, `bin/`, `obj/`, test artifacts, large assets, and other files that should not be uploaded to Vercel. Deploy emits a warning when common sensitive or heavy root entries such as `.env*`, `bin/`, `obj/`, `TestResults/`, or `coverage/` are present and are not covered by `.vercelignore`. Non-secret Aspire environment variables configured on the resource are processed during publish/deploy and passed to Vercel as CLI environment variables: @@ -112,7 +121,7 @@ This preview integration intentionally supports a narrow Vercel Dockerfile deplo | Container entrypoint overrides and Aspire command-line args | Rejected. Configure runtime command behavior through the Dockerfile/workload publish output or Vercel project settings. | | Existing linked Vercel projects | Supported for deploy. Destroy preserves projects that were linked before deploy and only deletes Aspire-created projects recorded in state. | -Managed Vercel project names are inferred from the source directory name when no `.vercel/project.json` link exists. The integration slugifies the inferred name to Vercel's lowercase, hyphenated project-name form before staging and deployment, and rejects duplicate project names within the same Vercel environment, including linked projects. Each Aspire resource must map to one distinct Vercel project because production endpoint references use the project-level `https://.vercel.app` URL. This preview intentionally does not expose resource-level project naming, alias, framework/output, or per-resource target APIs; link each resource to a distinct Vercel project before deploy when you need exact existing project identities, and add future per-resource settings through resource-specific annotations rather than overloading the environment. +Managed Vercel project names are inferred from the source directory name when no `.vercel/project.json` link exists and no explicit `WithVercelProjectName` is configured. The integration slugifies inferred names to Vercel's lowercase, hyphenated project-name form before staging and deployment, and rejects duplicate project names within the same Vercel environment, including linked projects and explicitly configured names. Each Aspire resource must map to one distinct Vercel project because production endpoint references use the project-level `https://.vercel.app` URL. This preview intentionally does not yet expose resource-level alias, domain, framework/output, build-setting, deployment-protection, or per-resource target APIs; link each resource to a distinct Vercel project before deploy when you need existing provider project identities. ## Additional documentation diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs index f792d42f2..34b354a7b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs @@ -947,7 +947,7 @@ private static void ValidateUniqueVercelProjectNames(IReadOnlyList $"'{item.Entry.Resource.Name}'").Order(StringComparer.Ordinal)); throw new DistributedApplicationException( - $"Multiple Vercel resources resolve to project name '{collision.Key}' ({resources}). Vercel project names must be unique per environment because each resource deploys to and references one project production URL. Use distinct source directory names or link each resource to a distinct Vercel project with .vercel/project.json."); + $"Multiple Vercel resources resolve to project name '{collision.Key}' ({resources}). Vercel project names must be unique per environment because each resource deploys to and references one project production URL. Use WithVercelProjectName, distinct source directory names, or link each resource to a distinct Vercel project with .vercel/project.json."); } private static void ValidateUnsupportedResourceModel(VercelDeploymentEntry entry) @@ -1322,6 +1322,11 @@ private static VercelProjectLink GetVercelProjectLink(VercelDeploymentEntry entr private static string GetManagedVercelProjectName(VercelDeploymentEntry entry) { + if (entry.Resource.TryGetLastAnnotation(out var options)) + { + return options.ProjectName; + } + string sourceRoot = Path.TrimEndingDirectorySeparator(entry.SourceRoot); string sourceRootName = Path.GetFileName(sourceRoot); @@ -1334,6 +1339,21 @@ private static string GetManagedVercelProjectName(VercelDeploymentEntry entry) throw new DistributedApplicationException($"Could not infer a valid Vercel project name for resource '{entry.Resource.Name}' from source root '{entry.SourceRoot}'. Rename the source directory or link the source root to an existing Vercel project."); } + internal static bool IsValidVercelProjectName(string projectName) + { + if (string.IsNullOrWhiteSpace(projectName) + || projectName.Length > VercelProjectNameMaxLength + || !IsLowercaseAsciiLetterOrDigit(projectName[0]) + || !IsLowercaseAsciiLetterOrDigit(projectName[^1])) + { + return false; + } + + return projectName.All(static character => + IsLowercaseAsciiLetterOrDigit(character) + || character == '-'); + } + private static bool TryCreateVercelProjectName(string? value, [NotNullWhen(true)] out string? projectName) { if (string.IsNullOrWhiteSpace(value)) @@ -1380,6 +1400,9 @@ private static bool TryCreateVercelProjectName(string? value, [NotNullWhen(true) private static bool IsAsciiLetterOrDigit(char character) => character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9'; + private static bool IsLowercaseAsciiLetterOrDigit(char character) + => character is >= 'a' and <= 'z' or >= '0' and <= '9'; + private static bool HasVercelProjectLinkFile(string sourceRoot) => File.Exists(GetVercelProjectJsonPath(sourceRoot)); diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectOptionsAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectOptionsAnnotation.cs new file mode 100644 index 000000000..0a561c5da --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectOptionsAnnotation.cs @@ -0,0 +1,5 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal sealed record VercelProjectOptionsAnnotation(string ProjectName) : IResourceAnnotation; diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceBuilderExtensions.cs new file mode 100644 index 000000000..529609502 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceBuilderExtensions.cs @@ -0,0 +1,42 @@ +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Vercel; +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for configuring Aspire workloads deployed to Vercel Dockerfile hosting. +/// +[Experimental("CTASPIREVERCEL001")] +public static class VercelResourceBuilderExtensions +{ + /// + /// Configures the Vercel project name to use when deploying an Aspire-managed Vercel project for the resource. + /// + /// The compute resource type. + /// The resource builder. + /// The Vercel project name. Use lowercase letters, digits, and hyphens. + /// The resource builder. + /// + /// This setting is used only when the source root is not already linked to a Vercel project with .vercel/project.json. + /// Linked projects keep their existing provider project identity. + /// + [AspireExport] + public static IResourceBuilder WithVercelProjectName( + this IResourceBuilder builder, + string projectName) + where TResource : IComputeResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(projectName); + + if (!VercelDeploymentStep.IsValidVercelProjectName(projectName)) + { + throw new ArgumentException( + $"Vercel project name '{projectName}' is invalid. Use lowercase letters, digits, and hyphens; start and end with a letter or digit; and keep the name at most 100 characters.", + nameof(projectName)); + } + + return builder.WithAnnotation(new VercelProjectOptionsAnnotation(projectName), ResourceAnnotationMutationBehavior.Replace); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs index 35d8db18c..94dc0166d 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs @@ -105,6 +105,19 @@ public void VercelEnvironmentOptionsCanBeConfigured() Assert.False(options.Production); } + [Fact] + public void WithVercelProjectNameThrowsForInvalidProjectName() + { + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + var api = builder.AddContainer("api", "api"); + + var exception = Assert.Throws(() => + api.WithVercelProjectName("Invalid_Project")); + + Assert.Equal("projectName", exception.ParamName); + Assert.Contains("Use lowercase letters, digits, and hyphens", exception.Message); + } + [Fact] public async Task AddVercelEnvironmentRegistersExpectedPipelineSteps() { @@ -631,6 +644,40 @@ public async Task WriteDeploymentPlanThrowsForLinkedProjectNameCollisions() Assert.Contains("'worker'", exception.Message); } + [Fact] + public async Task WriteDeploymentPlanThrowsForConfiguredProjectNameCollisions() + { + using var parent = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + string firstRoot = Path.Combine(parent.Path, "api"); + string secondRoot = Path.Combine(parent.Path, "worker"); + Directory.CreateDirectory(firstRoot); + Directory.CreateDirectory(secondRoot); + File.WriteAllText(Path.Combine(firstRoot, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(secondRoot, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(firstRoot, "Dockerfile") + .WithVercelProjectName("shared-project"); + builder.AddContainer("worker", "worker") + .WithDockerfile(secondRoot, "Dockerfile") + .WithVercelProjectName("shared-project"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken)); + + Assert.Contains("project name 'shared-project'", exception.Message); + Assert.Contains("WithVercelProjectName", exception.Message); + Assert.Contains("'api'", exception.Message); + Assert.Contains("'worker'", exception.Message); + } + [Fact] public async Task DeployAsyncStagesManagedProjectUsingSlugifiedProjectName() { @@ -665,6 +712,42 @@ public async Task DeployAsyncStagesManagedProjectUsingSlugifiedProjectName() Assert.Equal("invalid-project", deployment.ProjectName); } + [Fact] + public async Task DeployAsyncStagesManagedProjectUsingConfiguredProjectName() + { + using var sourceRoot = TemporaryDirectory.Create("source-folder"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, "https://configured-project.vercel.app", ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithVercelProjectName("configured-project"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", "configured-project"); + Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "Dockerfile"))); + + var state = ReadSavedState(Assert.Single(stateManager.SavedSections)); + var deployment = Assert.Single(state.Deployments); + Assert.Equal("configured-project", deployment.ProjectName); + Assert.True(deployment.ManagedByAspire); + } + [Fact] public void BuildDeployArgumentsIncludesConfiguredOptions() { @@ -1014,6 +1097,47 @@ public async Task BuildDeployArgumentsUsesProductionUrlForServiceDiscoveryRefere Assert.Contains("BACKEND_HTTP=https://backend-app.vercel.app", arguments); } + [Fact] + public async Task BuildDeployArgumentsUsesConfiguredProjectNameForEndpointReferences() + { + using var sourceRoot = TemporaryDirectory.Create(); + string apiRoot = Path.Combine(sourceRoot.Path, "api-app"); + string backendRoot = Path.Combine(sourceRoot.Path, "backend-source"); + Directory.CreateDirectory(apiRoot); + Directory.CreateDirectory(backendRoot); + File.WriteAllText(Path.Combine(apiRoot, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(backendRoot, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelProductionDeployments(); + var api = builder.AddContainer("api", "api") + .WithDockerfile(apiRoot, "Dockerfile") + .WithComputeEnvironment(vercel); + var backend = builder.AddContainer("backend", "backend") + .WithDockerfile(backendRoot, "Dockerfile") + .WithEndpoint(port: 8080, targetPort: 8080, scheme: "http", name: "http", isExternal: true) + .WithVercelProjectName("configured-backend") + .WithComputeEnvironment(vercel); + api.WithEnvironment("BACKEND_URL", backend.GetEndpoint("http")); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entries = VercelDeploymentStep.GetDeploymentEntries(model, environment).ToArray(); + var entry = Assert.Single(entries, entry => entry.Resource.Name == "api"); + + string[] arguments = await VercelDeploymentStep.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + entries, + TestContext.Current.CancellationToken); + + Assert.Contains("BACKEND_URL=https://configured-backend.vercel.app", arguments); + } + [Fact] public async Task BuildDeployArgumentsThrowsForEndpointReferencesWithoutProductionUrls() { @@ -2399,6 +2523,7 @@ public void GetVercelProjectNameReadsVercelProjectLink() Directory.CreateDirectory(vercelDirectory); File.WriteAllText(Path.Combine(vercelDirectory, "project.json"), """{"projectName":"linked-project"}"""); var entry = CreateDeploymentEntry(sourceRoot.Path); + entry.Resource.Annotations.Add(new VercelProjectOptionsAnnotation("configured-project")); string projectName = VercelDeploymentStep.GetVercelProjectName(entry); From 57ea1dbdcabe756daf5d5f3449841f9a4617ebf8 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 30 Jun 2026 23:55:29 -0700 Subject: [PATCH 17/33] Harden Vercel deployment integration Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../README.md | 36 +- .../VercelDeploymentStep.cs | 667 ++++++++++++++++-- .../VercelEnvironmentResource.cs | 21 +- .../VercelEnvironmentTests.cs | 350 +++++++-- 4 files changed, 944 insertions(+), 130 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md index edc24559a..9b5e5354e 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md @@ -34,7 +34,7 @@ builder.AddProject("api") By default, `aspire deploy` runs: ```bash -vercel --cwd deploy --yes +vercel --cwd deploy --yes --project ``` Use `WithVercelProductionDeployments` to add `--prod`, `WithVercelTarget` to add `--target`, and `WithVercelScope` to deploy to a team or account scope. @@ -57,9 +57,9 @@ builder.AddNodeApp("api", "../api", "server.mjs") Linked projects with `.vercel/project.json` keep their existing provider project identity and take precedence over `WithVercelProjectName`. -For low-level container resources, Vercel requires existing Aspire Dockerfile build metadata. Prefer the workload-specific `Add*App` integration when one exists. `WithDockerfile`, `WithDockerfileFactory`, and `WithDockerfileBuilder` are supported as advanced escape hatches. The integration always stages the source root into Aspire's deploy-time temp directory, materializes the Vercel-facing `Dockerfile` there, and runs `vercel deploy` from the staged directory so the Vercel CLI does not write `.vercel` metadata into the source tree. Staging skips `.git`, `node_modules`, and unmanaged `.vercel` metadata; linked projects keep only `.vercel/project.json`. Add a `.vercelignore` file to exclude local files such as `.env`, `bin/`, `obj/`, test artifacts, large assets, and other files that should not be uploaded to Vercel. Deploy emits a warning when common sensitive or heavy root entries such as `.env*`, `bin/`, `obj/`, `TestResults/`, or `coverage/` are present and are not covered by `.vercelignore`. +For low-level container resources, Vercel requires existing Aspire Dockerfile build metadata. Prefer the workload-specific `Add*App` integration when one exists. `WithDockerfile`, `WithDockerfileFactory`, and `WithDockerfileBuilder` are supported as advanced escape hatches. The integration always stages the source root into Aspire's deploy-time temp directory, materializes the Vercel-facing `Dockerfile.vercel` there, writes a services-mode `vercel.json` that routes all traffic to a generated `app` container service, and runs `vercel deploy` from the staged directory so the Vercel CLI does not write `.vercel` metadata into the source tree. Staging skips `.git`, `node_modules`, and unmanaged `.vercel` metadata; linked projects keep only `.vercel/project.json`. Source roots containing symbolic links or reparse points are rejected because staging would otherwise change symlink semantics or copy data from outside the source root. Add a `.vercelignore` file to exclude local files such as `.env`, test artifacts, large assets, and other files that should not be uploaded to Vercel. Deploy emits a warning when root `.env*` files are present and are not covered by `.vercelignore`. -Non-secret Aspire environment variables configured on the resource are processed during publish/deploy and passed to Vercel as CLI environment variables: +Non-secret Aspire environment variables configured on the resource are processed during publish/deploy and passed to Vercel as deployment-scoped CLI environment variables: ```csharp builder.AddNodeApp("api", "../api", "server.mjs") @@ -67,9 +67,24 @@ builder.AddNodeApp("api", "../api", "server.mjs") ``` ```bash -vercel --cwd deploy --yes --env GREETING=hello +vercel --cwd deploy --yes --project --env GREETING=hello ``` +Secret parameters, connection strings, and composite values that contain secrets are configured as sensitive Vercel project environment variables before deploy. The integration links the staged project when needed, then sends the value through standard input instead of putting it on the command line: + +```csharp +var apiKey = builder.AddParameter("api-key", secret: true); + +builder.AddNodeApp("api", "../api", "server.mjs") + .WithEnvironment("API_KEY", apiKey); +``` + +```bash +vercel --cwd env add API_KEY production --yes --force --sensitive +``` + +Secret project environment variables are written to `production` when `WithVercelProductionDeployments` is used, to the configured `WithVercelTarget` value when one is set, and to `preview` otherwise. Deploy treats secret environment variable names configured in the AppHost as Aspire-owned for that Vercel target and overwrites those values on each deploy. + Production endpoint references to other Vercel-targeted workloads are converted to deterministic Vercel project URLs: ```csharp @@ -91,7 +106,7 @@ The `BACKEND_URL` value is deployed as `https://.vercel.app`. E - `aspire start` is unchanged. The Vercel environment is publish/deploy-only. - `aspire publish` writes a deterministic `vercel-deployments.json` plan for the targeted resources, including the environment variable names passed to Vercel. It does not require a container registry. -- `aspire deploy` validates the Vercel CLI, validates authentication and configured scope, stages the source root, materializes the Dockerfile, invokes `vercel deploy`, and verifies the resulting deployment with `vercel inspect --wait --format=json` before saving deployment state for each verified resource. Vercel performs the remote source upload/build; Aspire does not build or push container images for this environment. The deployment summary and state record the deployment URL, and production deployments also record the deterministic `https://.vercel.app` production URL. +- `aspire deploy` validates the Vercel CLI, validates authentication and configured scope, stages the source root, materializes `Dockerfile.vercel` and the required services-mode `vercel.json`, invokes `vercel deploy`, and verifies the resulting deployment with `vercel inspect --wait --format=json` before saving deployment state for each verified resource. Vercel performs the remote source upload/build; Aspire does not build or push container images for this environment. The deployment summary and state record the deployment URL, and production deployments also record the deterministic `https://.vercel.app` production URL. - `aspire destroy` reads Aspire deployment state first and removes only Vercel projects that were created by this integration for the same environment/scope. Missing state or unmanaged-only state does not require Vercel CLI/auth. State is preserved if project removal fails. ## Prerequisites @@ -100,9 +115,7 @@ The `BACKEND_URL` value is deployed as `https://.vercel.app`. E - The deployed container should listen on `$PORT`; the default port is `80`. - Vercel CLI 54.18.6 or later installed and available on `PATH`. This preview depends on `deploy --env`, `inspect --wait --timeout --format=json`, and `project remove`; older CLI versions are rejected during deploy preflight. - Vercel authentication from an existing CLI login or the `VERCEL_TOKEN` environment variable. -- Any project linking, secrets, domains, deployment protection, or build-time environment variables configured in Vercel. - -Vercel supports project environment variables through `vercel env add/update`, including sensitive production and preview variables supplied on stdin. This preview integration does not mutate project environment settings yet because those values are project-scoped and require explicit ownership/update semantics. Secret Aspire values are therefore rejected instead of being passed through `vercel deploy --env`. +- Any domains, deployment protection, or build-time environment variables configured in Vercel. ## Known limitations @@ -113,9 +126,10 @@ This preview integration intentionally supports a narrow Vercel Dockerfile deplo | Dockerfile-backed compute resources | Supported through existing Aspire Dockerfile metadata and Vercel remote builds. | | Container registries, image build, image push | Not used. Vercel uploads and builds the staged source tree. | | Non-secret environment variables | Passed with `vercel deploy --env KEY=value`; names must use letters, digits, and underscores and values are redacted from publish output. | -| Secret parameters, connection strings, Docker build args/secrets | Rejected, including composite values that contain secrets. Configure them in Vercel project environment variables or Vercel secrets. | +| Secret parameters and connection strings | Configured as sensitive Vercel project environment variables with `vercel env add --force --sensitive`; values are supplied on stdin and redacted from publish output. Missing secret values are rejected. | +| Docker build args/secrets | Rejected. Vercel runs the Dockerfile build remotely, so configure build-time values in Vercel project settings. | | Service discovery, endpoint references, `WithReference` to another resource | Supported for external HTTP(S) endpoints on workloads in the same Vercel production environment by using deterministic `https://.vercel.app` URLs. Rejected for preview/custom targets because those URLs are assigned after deployment. | -| Endpoints | HTTP and HTTPS endpoints are accepted when they map to one target port. Non-HTTP(S) endpoints or multiple target ports are rejected. | +| Endpoints | External HTTP and HTTPS endpoints with HTTP transports are accepted when they map to one target port. Internal endpoints, non-HTTP(S) endpoints/transports, or multiple target ports are rejected. | | Volumes, bind mounts, Aspire container files | Rejected. Include required files in the source tree or use a Vercel-supported external service for state. | | Health checks, probes, replicas, wait/dependency ordering | Rejected until they are mapped to Vercel-native behavior. | | Container entrypoint overrides and Aspire command-line args | Rejected. Configure runtime command behavior through the Dockerfile/workload publish output or Vercel project settings. | @@ -123,6 +137,8 @@ This preview integration intentionally supports a narrow Vercel Dockerfile deplo Managed Vercel project names are inferred from the source directory name when no `.vercel/project.json` link exists and no explicit `WithVercelProjectName` is configured. The integration slugifies inferred names to Vercel's lowercase, hyphenated project-name form before staging and deployment, and rejects duplicate project names within the same Vercel environment, including linked projects and explicitly configured names. Each Aspire resource must map to one distinct Vercel project because production endpoint references use the project-level `https://.vercel.app` URL. This preview intentionally does not yet expose resource-level alias, domain, framework/output, build-setting, deployment-protection, or per-resource target APIs; link each resource to a distinct Vercel project before deploy when you need existing provider project identities. +If the source root already contains a `vercel.json`, the integration preserves top-level URL/routing configuration that is valid in Vercel services mode and appends the generated catch-all service rewrite. Existing `services` configuration, legacy `routes`, deprecated top-level `env`/`build`/`builds`, and top-level build/runtime settings such as `framework`, `buildCommand`, or `outputDirectory` are rejected because this preview owns the generated container service shape, catch-all service routing, and AppHost-driven environment variables. + ## Additional documentation - [Run any Dockerfile on Vercel](https://vercel.com/blog/dockerfile-on-vercel) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs index 34b354a7b..a5dbb1b54 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.Logging; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Net.Sockets; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -32,21 +33,32 @@ internal static class VercelDeploymentStep private const int DeploymentStateSchemaVersion = 1; private const int VercelProjectNameMaxLength = 100; private const string VercelCliFileName = "vercel"; + private const string VercelDockerfileFileName = "Dockerfile.vercel"; + private const string VercelJsonFileName = "vercel.json"; + private const string VercelContainerServiceName = "app"; private static readonly Version MinimumVercelCliVersion = new(54, 18, 6); + // These keys either bypass services mode or configure build/runtime/env behavior that + // Aspire must own so Dockerfile.vercel, endpoint refs, and secret handling stay coherent. + private static readonly string[] VercelJsonServicesModeUnsupportedKeys = + [ + "build", + "builds", + "buildCommand", + "devCommand", + "env", + "framework", + "functions", + "ignoreCommand", + "installCommand", + "outputDirectory", + "routes" + ]; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; - private static readonly string[] CommonSourceUploadDirectoryWarnings = - [ - "bin", - "obj", - "TestResults", - "coverage" - ]; - public static async Task WriteDeploymentPlanAsync(PipelineStepContext context, VercelEnvironmentResource environment) { var outputService = context.Services.GetRequiredService(); @@ -87,6 +99,9 @@ internal static async Task WriteDeploymentPlanAsync( var entries = GetDeploymentEntries(model, environment).ToList(); ValidateEntries(entries); + // Publish is a reviewable handoff, not a dry-run deploy. Keep it deterministic: + // show commands, Dockerfile paths, and env var names without resolving secrets or + // depending on mutable Vercel state. Directory.CreateDirectory(outputDirectory); var options = environment.GetVercelOptions(); @@ -132,6 +147,8 @@ public static async Task ValidateCliPrerequisitesAsync(PipelineStepContext conte $"Failed to determine Vercel CLI version from '{GetTrimmedOutput(versionOutput)}'. Install Vercel CLI {MinimumVercelCliVersion} or later from https://vercel.com/docs/cli."); } + // The preview relies on newer CLI behavior: deployment-scoped --env, JSON inspect + // output with --wait/--timeout, project removal, and services-mode Dockerfile deploys. if (version < MinimumVercelCliVersion) { throw new DistributedApplicationException( @@ -162,18 +179,46 @@ public static async Task DeployAsync(PipelineStepContext context, VercelEnvironm ValidateEntries(entries); await ValidateExistingDeploymentStateAsync(context, environment, options).ConfigureAwait(false); + var entriesByResourceName = GetDeploymentEntriesByResourceName(entries); foreach (var entry in entries) { var preparedEntry = await PrepareDeploymentEntryAsync(context, entry).ConfigureAwait(false); - bool managedByAspire = !HasVercelProjectLinkFile(preparedEntry.SourceRoot); - string[] arguments = await BuildDeployArgumentsAsync( + // A checked-in .vercel/project.json means the user linked an existing provider project, + // so deploy can target it but destroy must not claim ownership of it. + bool managedByAspire = !HasVercelProjectLinkFile(entry.SourceRoot); + if (managedByAspire) + { + // Create/validate the project before env configuration. `vercel env add` is + // project-scoped and does not accept --project, while deploy can pass --project. + await EnsureManagedProjectAsync(context, runner, options, preparedEntry).ConfigureAwait(false); + } + + // Use Aspire's unprocessed values so endpoint references and secrets keep their + // graph meaning until Vercel-specific deployment translation happens. + var environmentConfiguration = await GetVercelEnvironmentConfigurationAsync( context.ExecutionContext, context.Logger, options, preparedEntry, - entries, + entriesByResourceName, + resolveProjectEnvironmentVariableValues: true, context.CancellationToken).ConfigureAwait(false); + // Vercel resolves project env vars during the provider-side build/runtime. + // Configure secret-bearing values before deploy; non-secret per-deployment + // values remain on `vercel deploy --env`. + await ConfigureProjectEnvironmentVariablesAsync( + context, + runner, + options, + preparedEntry, + environmentConfiguration.ProjectEnvironmentVariables).ConfigureAwait(false); + + string[] arguments = BuildDeployArguments( + options, + preparedEntry.SourceRoot, + GetVercelProjectName(preparedEntry), + environmentConfiguration.DeploymentEnvironmentVariables); var result = await runner.RunAsync(VercelCliFileName, arguments, preparedEntry.SourceRoot, context.CancellationToken).ConfigureAwait(false); @@ -200,6 +245,9 @@ public static async Task DeployAsync(PipelineStepContext context, VercelEnvironm ProductionUrl = productionUrl }; + // Persist each verified resource independently. A later resource can fail after + // Vercel has already created earlier projects, and destroy must still know which + // managed projects are safe to remove or retry. await SaveDeploymentStateEntryAsync(context, environment, options, stateEntry).ConfigureAwait(false); context.Summary.Add($"{entry.Resource.Name} Vercel deployment", deploymentResult.DeploymentUrl); @@ -211,7 +259,7 @@ public static async Task DeployAsync(PipelineStepContext context, VercelEnvironm } internal static string[] BuildDeployArguments(VercelEnvironmentOptionsAnnotation options, VercelDeploymentEntry entry) - => BuildDeployArguments(options, entry.SourceRoot, environmentVariables: []); + => BuildDeployArguments(options, entry.SourceRoot, GetVercelProjectName(entry), environmentVariables: []); internal static string[] BuildDestroyProjectArguments(VercelEnvironmentOptionsAnnotation options, string projectName) { @@ -230,6 +278,73 @@ internal static string[] BuildDestroyProjectArguments(VercelEnvironmentOptionsAn return [.. arguments]; } + internal static string[] BuildAddProjectArguments(VercelEnvironmentOptionsAnnotation options, string projectName) + { + List arguments = []; + + if (!string.IsNullOrWhiteSpace(options.Scope)) + { + arguments.Add("--scope"); + arguments.Add(options.Scope); + } + + arguments.Add("project"); + arguments.Add("add"); + arguments.Add(projectName); + + return [.. arguments]; + } + + internal static string[] BuildLinkProjectArguments( + VercelEnvironmentOptionsAnnotation options, + string sourceRoot, + string projectName) + { + List arguments = []; + + if (!string.IsNullOrWhiteSpace(options.Scope)) + { + arguments.Add("--scope"); + arguments.Add(options.Scope); + } + + arguments.Add("--cwd"); + arguments.Add(sourceRoot); + arguments.Add("link"); + arguments.Add("--yes"); + arguments.Add("--project"); + arguments.Add(projectName); + + return [.. arguments]; + } + + internal static string[] BuildAddProjectEnvironmentVariableArguments( + VercelEnvironmentOptionsAnnotation options, + string sourceRoot, + string name, + string targetEnvironment) + { + List arguments = []; + + if (!string.IsNullOrWhiteSpace(options.Scope)) + { + arguments.Add("--scope"); + arguments.Add(options.Scope); + } + + arguments.Add("--cwd"); + arguments.Add(sourceRoot); + arguments.Add("env"); + arguments.Add("add"); + arguments.Add(name); + arguments.Add(targetEnvironment); + arguments.Add("--yes"); + arguments.Add("--force"); + arguments.Add("--sensitive"); + + return [.. arguments]; + } + internal static string[] BuildInspectDeploymentArguments(VercelEnvironmentOptionsAnnotation options, string deploymentUrl) { List arguments = []; @@ -274,6 +389,9 @@ private static async Task VerifyDeploymentAsync( string resourceName, VercelDeploymentResult deploymentResult) { + // A successful `vercel deploy` only means the CLI accepted the submission. + // Query the provider before recording state so Aspire does not persist failed + // or still-building deployments as successfully applied resources. string[] arguments = BuildInspectDeploymentArguments(options, deploymentResult.DeploymentUrl); var result = await runner.RunAsync(VercelCliFileName, arguments, workingDirectory: null, context.CancellationToken).ConfigureAwait(false); @@ -302,12 +420,17 @@ public static async Task DestroyAsync(PipelineStepContext context, VercelEnviron var state = ReadDeploymentState(stateSection); if (state is null) { + // Keep no-op destroy cheap and offline. If Aspire has no deployment state, + // there is no recorded provider object that this integration owns. context.Summary.Add("Vercel destroy", $"No Vercel deployment state was found for environment '{environment.Name}'. Nothing to destroy."); return; } ValidateDeploymentState(environment, options, state); + // Destroy is state-first rather than model-first. The current AppHost may no + // longer contain the resources that created these Vercel projects, but persisted + // state records which provider objects Aspire is allowed to delete. var projects = state.Deployments .Where(static deployment => deployment.ManagedByAspire) .Select(static deployment => deployment.ProjectName) @@ -317,11 +440,15 @@ public static async Task DestroyAsync(PipelineStepContext context, VercelEnviron if (projects.Length == 0) { + // Linked .vercel projects are valid deploy targets but remain externally owned, + // so clearing state is safer than deleting provider projects the user brought. context.Summary.Add("Vercel destroy", $"No Aspire-managed Vercel deployments were found for environment '{environment.Name}'."); await stateManager.DeleteSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); return; } + // Validate auth only after we know there is provider state to mutate. This keeps + // `aspire destroy` usable in clean workspaces or after state has already been removed. await ValidateCliPrerequisitesAsync(context, environment).ConfigureAwait(false); var runner = context.Services.GetRequiredService(); @@ -345,6 +472,8 @@ public static async Task DestroyAsync(PipelineStepContext context, VercelEnviron } state = RemoveManagedProjectFromDeploymentState(state, projectName); + // Save after each project removal so a later CLI failure leaves retryable state + // for projects that still exist instead of forgetting partially cleaned resources. stateSection.SetValue(JsonSerializer.Serialize(state, JsonOptions)); await stateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); } @@ -375,15 +504,16 @@ internal static async Task BuildDeployArgumentsAsync( CancellationToken cancellationToken) { var entriesByResourceName = GetDeploymentEntriesByResourceName(entries); - var environmentVariables = await GetVercelEnvironmentVariablesAsync( + var environmentConfiguration = await GetVercelEnvironmentConfigurationAsync( executionContext, logger, options, entry, entriesByResourceName, + resolveProjectEnvironmentVariableValues: false, cancellationToken).ConfigureAwait(false); - return BuildDeployArguments(options, entry.SourceRoot, environmentVariables); + return BuildDeployArguments(options, entry.SourceRoot, GetVercelProjectName(entry), environmentConfiguration.DeploymentEnvironmentVariables); } private static async Task CreateDeploymentPlanEntriesAsync( @@ -398,26 +528,30 @@ private static async Task CreateDeploymentPlanEntri foreach (var entry in entries) { - var environmentVariables = executionContext is null || logger is null - ? [] - : await GetVercelEnvironmentVariablesAsync(executionContext, logger, options, entry, entriesByResourceName, cancellationToken).ConfigureAwait(false); + // When execution context is available, include the same target-native env names + // deploy will use. Values stay redacted because publish output is committed or + // handed off more often than deploy logs. + var environmentConfiguration = executionContext is null || logger is null + ? VercelEnvironmentConfiguration.Empty + : await GetVercelEnvironmentConfigurationAsync(executionContext, logger, options, entry, entriesByResourceName, resolveProjectEnvironmentVariableValues: false, cancellationToken).ConfigureAwait(false); planEntries.Add(new( entry.Resource.Name, GetDisplayDockerfilePath(entry), - BuildDisplayDeployCommand(options, entry.Resource.Name, environmentVariables), - [.. environmentVariables.Select(static variable => variable.Key).Order(StringComparer.Ordinal)])); + BuildDisplayDeployCommand(options, entry.Resource.Name, environmentConfiguration.DeploymentEnvironmentVariables), + [.. environmentConfiguration.AllEnvironmentVariableNames.Order(StringComparer.Ordinal)])); } return [.. planEntries]; } - private static async Task>> GetVercelEnvironmentVariablesAsync( + private static async Task GetVercelEnvironmentConfigurationAsync( DistributedApplicationExecutionContext executionContext, ILogger logger, VercelEnvironmentOptionsAnnotation options, VercelDeploymentEntry entry, IReadOnlyDictionary entriesByResourceName, + bool resolveProjectEnvironmentVariableValues, CancellationToken cancellationToken) { var executionConfiguration = await ExecutionConfigurationBuilder @@ -434,7 +568,13 @@ private static async Task>> GetVercel ValidateUnsupportedRuntimeConfiguration(entry.Resource, executionConfiguration); - var environmentVariables = GetVercelEnvironmentVariables(entry.Resource, options, executionConfiguration, entriesByResourceName); + var environmentVariables = await GetVercelEnvironmentConfigurationAsync( + entry.Resource, + options, + executionConfiguration, + entriesByResourceName, + resolveProjectEnvironmentVariableValues, + cancellationToken).ConfigureAwait(false); return environmentVariables; } @@ -442,6 +582,7 @@ private static async Task>> GetVercel private static string[] BuildDeployArguments( VercelEnvironmentOptionsAnnotation options, string sourceRoot, + string projectName, IReadOnlyList> environmentVariables) { List arguments = []; @@ -456,6 +597,8 @@ private static string[] BuildDeployArguments( arguments.Add(sourceRoot); arguments.Add("deploy"); arguments.Add("--yes"); + arguments.Add("--project"); + arguments.Add(projectName); if (options.Production) { @@ -482,11 +625,13 @@ private static string BuildDisplayDeployCommand( string resourceName, IReadOnlyList> environmentVariables) { + // The plan should explain the command shape without leaking concrete source roots, + // project names, or environment values that may be machine- or account-specific. var displayEnvironmentVariables = environmentVariables .Select(static environmentVariable => new KeyValuePair(environmentVariable.Key, "")) .ToArray(); - return $"vercel {string.Join(" ", BuildDeployArguments(options, $"<{resourceName}-source-root>", displayEnvironmentVariables))}"; + return $"vercel {string.Join(" ", BuildDeployArguments(options, $"<{resourceName}-source-root>", projectName: $"<{resourceName}-project>", displayEnvironmentVariables))}"; } private static async Task ValidateExistingDeploymentStateAsync( @@ -554,6 +699,8 @@ .. existingState.Deployments.Where(existing => private static VercelDeploymentState? ReadDeploymentState(DeploymentStateSection stateSection) { + // DeploymentStateSection storage shape has changed across Aspire builds. Accept the + // known wrappers so destroy can still clean up projects created by an older CLI. if (stateSection.Data.TryGetPropertyValue("value", out JsonNode? value) && value is not null) { @@ -634,15 +781,87 @@ private static bool IsMissingProjectResult(VercelCliResult result, string projec private static string GetStateSectionName(VercelEnvironmentResource environment) => $"{StateSectionNamePrefix}{environment.Name}"; - private static IReadOnlyList> GetVercelEnvironmentVariables( + private static async Task ConfigureProjectEnvironmentVariablesAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry, + IReadOnlyList> environmentVariables) + { + if (environmentVariables.Count == 0) + { + return; + } + + string projectName = GetVercelProjectName(entry); + if (!HasVercelProjectLinkFile(entry.SourceRoot)) + { + // `vercel env add` does not accept --project, so link only the staged copy + // before writing provider-managed secret values. + string[] linkArguments = BuildLinkProjectArguments(options, entry.SourceRoot, projectName); + var linkResult = await runner.RunAsync(VercelCliFileName, linkArguments, entry.SourceRoot, context.CancellationToken).ConfigureAwait(false); + if (!linkResult.Succeeded) + { + throw CreateCliException($"link Vercel project '{projectName}' for resource '{entry.Resource.Name}'", VercelCliFileName, linkResult); + } + } + + string targetEnvironment = GetVercelProjectEnvironmentName(options); + foreach (var environmentVariable in environmentVariables.OrderBy(static variable => variable.Key, StringComparer.Ordinal)) + { + string[] arguments = BuildAddProjectEnvironmentVariableArguments(options, entry.SourceRoot, environmentVariable.Key, targetEnvironment); + var result = await runner.RunAsync(VercelCliFileName, arguments, entry.SourceRoot, context.CancellationToken, standardInput: environmentVariable.Value).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException($"configure Vercel project environment variable '{environmentVariable.Key}' for resource '{entry.Resource.Name}'", VercelCliFileName, result); + } + } + } + + private static async Task EnsureManagedProjectAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry) + { + string projectName = GetVercelProjectName(entry); + string[] arguments = BuildAddProjectArguments(options, projectName); + var result = await runner.RunAsync(VercelCliFileName, arguments, workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException($"create or validate Vercel project '{projectName}' for resource '{entry.Resource.Name}'", VercelCliFileName, result); + } + } + + private static string GetVercelProjectEnvironmentName(VercelEnvironmentOptionsAnnotation options) + { + if (options.Production) + { + return "production"; + } + + return string.IsNullOrWhiteSpace(options.Target) ? "preview" : options.Target; + } + + private static async Task GetVercelEnvironmentConfigurationAsync( IResource resource, VercelEnvironmentOptionsAnnotation options, IExecutionConfigurationResult executionConfiguration, - IReadOnlyDictionary entriesByResourceName) + IReadOnlyDictionary entriesByResourceName, + bool resolveProjectEnvironmentVariableValues, + CancellationToken cancellationToken) { - List> environmentVariables = []; + List> deploymentEnvironmentVariables = []; + List> projectEnvironmentVariables = []; HashSet names = new(StringComparer.Ordinal); + // This is the env-var edge-case boundary. Vercel has deployment env args and + // project env secrets, but no Aspire-style connection binding/service-discovery + // object in this preview. Only deterministic production endpoint URLs are projected; + // other resource references fail here instead of becoming misleading strings. + // Keep a single pass over Aspire's unprocessed environment dictionary. The processed + // value can contain publish-mode manifest expressions, but deployment needs the + // original graph value so it can choose Vercel's concrete URL/secret mechanism. foreach (var environmentVariable in executionConfiguration.EnvironmentVariablesWithUnprocessed) { string name = environmentVariable.Key; @@ -656,27 +875,139 @@ private static IReadOnlyList> GetVercelEnvironmentV $"Resource '{resource.Name}' configures environment variable '{name}' more than once. Vercel project environment variable names must be unique."); } - if (ContainsSecretReference(unprocessedValue)) - { - throw new DistributedApplicationException( - $"Environment variable '{name}' for resource '{resource.Name}' references a secret or connection string. Vercel CLI --env would pass the value on the command line, so configure this value in Vercel project environment variables or a Vercel secret instead."); - } - if (ContainsUnsupportedResourceReference(resource, unprocessedValue)) { throw new DistributedApplicationException( $"Environment variable '{name}' for resource '{resource.Name}' references another Aspire resource or service in a way that cannot be represented as a Vercel deployment URL. Use endpoint references to Vercel production workloads, or configure the value in Vercel project environment variables."); } - if (TryGetVercelEnvironmentVariableValue(resource, options, entriesByResourceName, unprocessedValue, out string? vercelValue)) + // Non-secrets can ride on `vercel deploy --env`; secret-bearing values must use + // Vercel project environment variables so values never appear in CLI arguments. + bool containsSecret = ContainsSecretReference(unprocessedValue); + if (containsSecret) + { + value = resolveProjectEnvironmentVariableValues + ? await GetVercelProjectEnvironmentVariableValueAsync( + resource, + options, + entriesByResourceName, + unprocessedValue, + value, + cancellationToken).ConfigureAwait(false) + : ""; + } + else if (TryGetVercelEnvironmentVariableValue(resource, options, entriesByResourceName, unprocessedValue, out string? vercelValue)) { value = vercelValue; } - environmentVariables.Add(new(name, value)); + if (containsSecret) + { + projectEnvironmentVariables.Add(new(name, value)); + } + else + { + deploymentEnvironmentVariables.Add(new(name, value)); + } } - return environmentVariables; + return new(deploymentEnvironmentVariables, projectEnvironmentVariables); + } + + private static async ValueTask GetVercelProjectEnvironmentVariableValueAsync( + IResource resource, + VercelEnvironmentOptionsAnnotation options, + IReadOnlyDictionary entriesByResourceName, + object? value, + string processedValue, + CancellationToken cancellationToken) + { + // This path resolves values for `vercel env add`, not `vercel deploy --env`. + // Values can be secret-bearing because they are sent on stdin to Vercel's secret + // store; they must not be copied into publish plans, command lines, or state. + switch (value) + { + case null: + return processedValue; + case string stringValue: + return stringValue; + case ParameterResource parameter: + return await GetParameterValueAsync(parameter, cancellationToken).ConfigureAwait(false); + case IResourceBuilder parameterBuilder: + return await GetParameterValueAsync(parameterBuilder.Resource, cancellationToken).ConfigureAwait(false); + case IResourceWithConnectionString connectionStringResource: + return await GetValueProviderValueAsync(connectionStringResource.ConnectionStringExpression, $"connection string for resource '{connectionStringResource.Name}'", cancellationToken).ConfigureAwait(false); + case IResourceBuilder connectionStringBuilder: + return await GetValueProviderValueAsync(connectionStringBuilder.Resource.ConnectionStringExpression, $"connection string for resource '{connectionStringBuilder.Resource.Name}'", cancellationToken).ConfigureAwait(false); + case ReferenceExpression referenceExpression: + return await GetVercelProjectReferenceExpressionValueAsync(resource, options, entriesByResourceName, referenceExpression, cancellationToken).ConfigureAwait(false); + case IValueProvider valueProvider: + return await GetValueProviderValueAsync(valueProvider, "environment variable value", cancellationToken).ConfigureAwait(false); + default: + return processedValue; + } + } + + private static async ValueTask GetVercelProjectReferenceExpressionValueAsync( + IResource resource, + VercelEnvironmentOptionsAnnotation options, + IReadOnlyDictionary entriesByResourceName, + ReferenceExpression referenceExpression, + CancellationToken cancellationToken) + { + if (referenceExpression.IsConditional) + { + throw new DistributedApplicationException("Vercel project environment variables do not support conditional reference expressions. Configure a concrete Vercel project environment variable instead."); + } + + var arguments = new object?[referenceExpression.ValueProviders.Count]; + for (int i = 0; i < referenceExpression.ValueProviders.Count; i++) + { + IValueProvider valueProvider = referenceExpression.ValueProviders[i]; + arguments[i] = valueProvider switch + { + // Secret-bearing project env vars may combine endpoint URLs with secret + // parameters because the final value is sent through Vercel's secret path. + EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference) => GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReference.Property(EndpointProperty.Url)), + EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint) => GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReferenceExpression), + ParameterResource parameter => await GetParameterValueAsync(parameter, cancellationToken).ConfigureAwait(false), + IResourceWithConnectionString connectionStringResource => await GetValueProviderValueAsync(connectionStringResource.ConnectionStringExpression, $"connection string for resource '{connectionStringResource.Name}'", cancellationToken).ConfigureAwait(false), + _ => await GetValueProviderValueAsync(valueProvider, "reference expression value", cancellationToken).ConfigureAwait(false) + }; + + if (referenceExpression.StringFormats[i] is "uri" && arguments[i] is string stringValue) + { + arguments[i] = Uri.EscapeDataString(stringValue); + } + } + + return string.Format(CultureInfo.InvariantCulture, referenceExpression.Format, arguments); + } + + private static async ValueTask GetParameterValueAsync(ParameterResource parameter, CancellationToken cancellationToken) + { + try + { + string? value = await parameter.GetValueAsync(cancellationToken).ConfigureAwait(false); + return value ?? throw new DistributedApplicationException($"Secret parameter '{parameter.Name}' did not produce a value for Vercel project environment configuration."); + } + catch (MissingParameterValueException ex) + { + throw new DistributedApplicationException($"Secret parameter '{parameter.Name}' does not have a value. Provide a value before deploying to Vercel.", ex); + } + } + + private static async ValueTask GetValueProviderValueAsync(IValueProvider valueProvider, string description, CancellationToken cancellationToken) + { + try + { + string? value = await valueProvider.GetValueAsync(cancellationToken).ConfigureAwait(false); + return value ?? throw new DistributedApplicationException($"The {description} did not produce a value for Vercel project environment configuration."); + } + catch (MissingParameterValueException ex) + { + throw new DistributedApplicationException($"The {description} does not have a value. Provide a value before deploying to Vercel.", ex); + } } private static bool TryGetVercelEnvironmentVariableValue( @@ -686,6 +1017,8 @@ private static bool TryGetVercelEnvironmentVariableValue( object? value, [NotNullWhen(true)] out string? vercelValue) { + // Service-discovery env vars generated by WithReference also arrive as structured + // endpoint values, so translate by value shape instead of by environment variable name. switch (value) { case EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference): @@ -722,6 +1055,8 @@ private static string GetVercelReferenceExpressionValue( { EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference) => GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReference.Property(EndpointProperty.Url)), EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint) => GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReferenceExpression), + // Mixed expressions can hide provider-specific ordering or secret semantics. + // Keep this path to deterministic endpoint-only production URLs. _ => throw new DistributedApplicationException("Vercel endpoint reference expressions cannot be combined with parameters, secrets, or other value providers. Configure a concrete Vercel project environment variable instead.") }; @@ -740,6 +1075,10 @@ private static string GetVercelEndpointPropertyValue( IReadOnlyDictionary entriesByResourceName, EndpointReferenceExpression endpointReferenceExpression) { + // This is the endpoint-reference edge-case boundary: preview/custom URLs are + // post-deploy outputs, internal endpoints have no public Vercel edge address, and + // cross-environment references lack a stable same-deploy alias. Fail before values + // are written to Vercel env vars. if (!options.Production) { throw new DistributedApplicationException( @@ -771,12 +1110,18 @@ private static string GetVercelEndpointPropertyValue( return endpointReferenceExpression.Property switch { + // Production aliases are deterministic before deploy; preview/custom URLs are not. + // Keep endpoint references on the stable caller-visible Vercel HTTPS surface. EndpointProperty.Url => $"https://{host}", EndpointProperty.Host or EndpointProperty.IPV4Host => host, EndpointProperty.Port => port.ToString(CultureInfo.InvariantCulture), EndpointProperty.TargetPort => endpoint.TargetPort is int targetPort ? targetPort.ToString(CultureInfo.InvariantCulture) : throw new DistributedApplicationException( + // Azure publishers can carry ContainerPortReference placeholders in + // Bicep/Helm. Vercel deploy receives concrete CLI env values, so an + // unresolved TargetPort would become a bogus string rather than a + // target-native reference. $"Resource '{resource.Name}' references endpoint property '{EndpointProperty.TargetPort}' for endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}', but the endpoint does not define an explicit target port. Configure a target port or avoid passing TargetPort to Vercel."), EndpointProperty.Scheme => "https", EndpointProperty.HostAndPort => $"{host}:{port.ToString(CultureInfo.InvariantCulture)}", @@ -787,6 +1132,8 @@ private static string GetVercelEndpointPropertyValue( private static void ValidateEnvironmentVariableName(IResource resource, string name) { + // Do not remap names to satisfy Vercel's env var shape. A lossy rename would break + // the consuming workload's contract, so invalid names fail with the original key. if (string.IsNullOrWhiteSpace(name) || (!char.IsAsciiLetter(name[0]) && name[0] != '_') || name.Any(static character => !char.IsAsciiLetterOrDigit(character) && character != '_')) @@ -800,6 +1147,10 @@ private static void ValidateUnsupportedRuntimeConfiguration( IResource resource, IExecutionConfigurationResult executionConfiguration) { + // These Aspire concepts have no faithful Vercel Dockerfile-deploy equivalent in this + // preview. Rejecting them is safer than silently dropping entrypoint, args, or build + // values that would change the workload's deployed behavior. Vercel builds remotely + // from uploaded source, so Aspire cannot apply late command/build overrides after upload. if (resource is ContainerResource { Entrypoint: not null }) { throw new DistributedApplicationException( @@ -822,6 +1173,8 @@ private static void ValidateUnsupportedRuntimeConfiguration( private static bool ContainsSecretReference(object? value) { + // Connection strings are treated as secret-bearing even when the underlying provider + // does not mark each segment secret; Vercel should receive them through project env. return value switch { null => false, @@ -852,6 +1205,9 @@ private static bool IsCrossResourceEndpointReference(IResource resource, Endpoin private static bool ContainsUnsupportedResourceReference(IResource resource, object? value) { + // Vercel only knows how to turn endpoint references into deterministic production + // aliases. Other resource references can represent connection strings, parameters, + // or custom values that need a target-native mechanism this preview does not have. return value switch { null => false, @@ -868,11 +1224,19 @@ private static bool ContainsUnsupportedResourceReference(IResource resource, obj } private static bool IsSameResource(IResource resource, IResource otherResource) + // Compare by Aspire resource name rather than object identity. Polyglot/ATS flows + // can recreate resource references across an RPC boundary, but resource name is the + // app-model identity used by deployment target maps. => string.Equals(resource.Name, otherResource.Name, StringComparison.Ordinal); private static bool IsHttpEndpoint(EndpointAnnotation endpoint) - => string.Equals(endpoint.UriScheme, "http", StringComparison.OrdinalIgnoreCase) - || string.Equals(endpoint.UriScheme, "https", StringComparison.OrdinalIgnoreCase); + // URI scheme alone is not enough: Aspire can model a TCP transport with an HTTP + // URI scheme. Vercel's container ingress here is HTTP-family traffic over TCP. + => endpoint.Protocol == ProtocolType.Tcp + && (string.Equals(endpoint.UriScheme, "http", StringComparison.OrdinalIgnoreCase) + || string.Equals(endpoint.UriScheme, "https", StringComparison.OrdinalIgnoreCase)) + && (string.Equals(endpoint.Transport, "http", StringComparison.OrdinalIgnoreCase) + || string.Equals(endpoint.Transport, "http2", StringComparison.OrdinalIgnoreCase)); private static IReadOnlyDictionary GetDeploymentEntriesByResourceName(IReadOnlyList entries) => entries.ToDictionary(static entry => entry.Resource.Name, StringComparer.Ordinal); @@ -898,11 +1262,16 @@ internal static IEnumerable GetDeploymentEntries(Distribu private static bool IsTargetedToEnvironment(IResource resource, VercelEnvironmentResource environment) { var computeEnvironment = resource.GetComputeEnvironment(); + // Match Aspire's single-environment convention: when Vercel is the only compute + // environment, Dockerfile-backed workloads implicitly target it. return computeEnvironment is null || ReferenceEquals(computeEnvironment, environment); } private static void ValidateEntries(IReadOnlyList entries) { + // Keep unsupported Vercel-preview cases in validation so publish/prereq/deploy fail + // before mutating provider projects. Each failure names the Aspire concept that + // cannot be projected rather than letting a later Vercel CLI call fail opaquely. if (entries.Count == 0) { throw new DistributedApplicationException("No Dockerfile-backed compute resources target Vercel. Add a workload with Aspire Dockerfile publish metadata, or use WithComputeEnvironment to target Vercel when multiple compute environments are present."); @@ -928,6 +1297,9 @@ private static void ValidateEntries(IReadOnlyList entries private static void ValidateUniqueVercelProjectNames(IReadOnlyList entries) { + // Production endpoint references use https://{projectName}.vercel.app. If two + // resources resolve to the same Vercel project, endpoint references and destroy + // ownership would both become ambiguous. var projectNames = entries .Select(entry => new { @@ -954,6 +1326,10 @@ private static void ValidateUnsupportedResourceModel(VercelDeploymentEntry entry { IResource resource = entry.Resource; + // This method is intentionally conservative. Each rejected annotation has run-mode + // or another deployment-target semantics that Vercel's Dockerfile deploy cannot + // project without changing what the user modeled in the AppHost. If Vercel gains a + // native equivalent later, add the mapping and tests here instead of silently ignoring it. if (resource.Annotations.OfType().Any()) { throw new DistributedApplicationException( @@ -1001,11 +1377,23 @@ private static void ValidateEndpointModel(VercelDeploymentEntry entry) return; } + // Reject the tempting Compose/ACA shapes up front: private listeners, multiple + // target ports, and non-HTTP protocols do not have an equivalent in this preview's + // single public Vercel container ingress. + // Vercel's Dockerfile preview exposes one public platform ingress; it has no + // Aspire-modeled private service network for internal endpoints. + var internalEndpoint = endpoints.FirstOrDefault(static endpoint => !endpoint.IsExternal); + if (internalEndpoint is not null) + { + throw new DistributedApplicationException( + $"Resource '{entry.Resource.Name}' configures endpoint '{internalEndpoint.Name}' as internal, but Vercel Dockerfile deployments expose public platform HTTPS ingress only. Mark the endpoint external or remove it before deploying to Vercel."); + } + var unsupportedEndpoint = endpoints.FirstOrDefault(static endpoint => !IsHttpEndpoint(endpoint)); if (unsupportedEndpoint is not null) { throw new DistributedApplicationException( - $"Resource '{entry.Resource.Name}' configures endpoint '{unsupportedEndpoint.Name}' with scheme '{unsupportedEndpoint.UriScheme}', but Vercel Dockerfile deployments support only HTTP or HTTPS endpoints."); + $"Resource '{entry.Resource.Name}' configures endpoint '{unsupportedEndpoint.Name}' with scheme '{unsupportedEndpoint.UriScheme}' and transport '{unsupportedEndpoint.Transport}', but Vercel Dockerfile deployments support only HTTP or HTTPS endpoints with HTTP transports."); } var targetPorts = endpoints @@ -1015,6 +1403,8 @@ private static void ValidateEndpointModel(VercelDeploymentEntry entry) .Distinct() .ToArray(); + // Vercel provides one runtime listener through $PORT. Additional target ports would + // look like ACA extra ports, but Vercel has no equivalent modeled here. if (targetPorts.Length > 1) { throw new DistributedApplicationException( @@ -1044,13 +1434,23 @@ private static async Task PrepareDeploymentEntryAsync(Pip LogSourceUploadWarnings(context.Logger, entry); + // Vercel deploy uploads the working tree rooted at --cwd. Aspire Dockerfile metadata + // can point at generated files or Dockerfiles outside that root, so stage a temporary + // Vercel-shaped source tree instead of mutating the user's checkout. + bool preserveVercelProjectLink = HasVercelProjectLinkFile(entry.SourceRoot); + // Staging uses regular file copies. Reject links up front so we do not + // accidentally dereference outside-root content or change Docker context semantics. + ValidateNoSymbolicLinks(entry, preserveVercelProjectLink, context.CancellationToken); + CopyDirectory( entry.SourceRoot, stagingRoot, - preserveVercelProjectLink: HasVercelProjectLinkFile(entry.SourceRoot), + preserveVercelProjectLink, context.CancellationToken); - string stagedDockerfilePath = Path.Combine(stagingRoot, "Dockerfile"); + await WriteVercelProjectConfigurationAsync(entry.Resource, stagingRoot, context.CancellationToken).ConfigureAwait(false); + + string stagedDockerfilePath = Path.Combine(stagingRoot, VercelDockerfileFileName); DockerfileFactoryContext dockerfileContext = new() { CancellationToken = context.CancellationToken, @@ -1067,8 +1467,91 @@ private static async Task PrepareDeploymentEntryAsync(Pip }; } + private static async Task WriteVercelProjectConfigurationAsync( + IResource resource, + string stagingRoot, + CancellationToken cancellationToken) + { + string vercelJsonPath = Path.Combine(stagingRoot, VercelJsonFileName); + JsonObject root; + if (File.Exists(vercelJsonPath)) + { + try + { + root = JsonNode.Parse(await File.ReadAllTextAsync(vercelJsonPath, cancellationToken).ConfigureAwait(false)) as JsonObject + ?? throw new DistributedApplicationException($"Resource '{resource.Name}' source root contains '{VercelJsonFileName}', but it is not a JSON object."); + } + catch (JsonException ex) + { + throw new DistributedApplicationException($"Resource '{resource.Name}' source root contains invalid '{VercelJsonFileName}'.", ex); + } + } + else + { + root = []; + } + + if (root.ContainsKey("services")) + { + // Do not merge an existing services block. Service names, roots, and rewrites + // are part of the deployment contract used by endpoint references, so a partial + // merge could deploy successfully but route traffic to a user-defined service. + throw new DistributedApplicationException( + $"Resource '{resource.Name}' source root contains '{VercelJsonFileName}' with an existing 'services' configuration. The Vercel preview integration owns the generated container service configuration; remove the existing services block or deploy this project outside the Aspire Vercel integration."); + } + + var unsupportedKey = VercelJsonServicesModeUnsupportedKeys.FirstOrDefault(root.ContainsKey); + if (unsupportedKey is not null) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' source root contains '{VercelJsonFileName}' with top-level '{unsupportedKey}', but the Aspire Vercel integration owns the generated services-mode container and catch-all routing configuration. Move that setting into the Dockerfile, AppHost environment variables, or Vercel project settings before deploying with the Aspire Vercel integration."); + } + + // Dockerfile.vercel is only honored by Vercel's services/container runtime path, + // so generate the minimal service shape instead of relying on framework detection. + root["services"] = new JsonObject + { + [VercelContainerServiceName] = new JsonObject + { + ["runtime"] = "container", + ["root"] = ".", + ["entrypoint"] = VercelDockerfileFileName + } + }; + + JsonObject catchAllRewrite = new() + { + ["source"] = "/(.*)", + ["destination"] = new JsonObject + { + ["service"] = VercelContainerServiceName + } + }; + + // Services mode routes requests to named services explicitly. The generated catch-all + // rewrite makes Aspire's single Dockerfile-backed workload behave like one public app + // endpoint instead of relying on Vercel framework routing. + if (root["rewrites"] is null) + { + root["rewrites"] = new JsonArray(catchAllRewrite); + } + else if (root["rewrites"] is JsonArray rewrites) + { + rewrites.Add(catchAllRewrite); + } + else + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' source root contains '{VercelJsonFileName}' with a 'rewrites' value that is not an array."); + } + + await File.WriteAllTextAsync(vercelJsonPath, root.ToJsonString(JsonOptions), cancellationToken).ConfigureAwait(false); + } + private static bool RequiresStaging(VercelDeploymentEntry entry) { + // Publish output should point at the Vercel-facing artifact shape. Generated or + // non-root Dockerfiles are materialized as Dockerfile.vercel in the staged tree. if (entry.Dockerfile.DockerfileFactory is not null) { return true; @@ -1077,14 +1560,14 @@ private static bool RequiresStaging(VercelDeploymentEntry entry) string dockerfileDirectory = Path.GetDirectoryName(Path.GetFullPath(entry.DockerfilePath)) ?? string.Empty; return !PathEquals(dockerfileDirectory, entry.SourceRoot) - || !string.Equals(Path.GetFileName(entry.DockerfilePath), "Dockerfile", GetPathStringComparison()); + || !string.Equals(Path.GetFileName(entry.DockerfilePath), VercelDockerfileFileName, GetPathStringComparison()); } private static string GetDisplayDockerfilePath(VercelDeploymentEntry entry) { if (RequiresStaging(entry)) { - return "Dockerfile"; + return VercelDockerfileFileName; } return Path.GetRelativePath(entry.SourceRoot, entry.DockerfilePath); @@ -1092,6 +1575,9 @@ private static string GetDisplayDockerfilePath(VercelDeploymentEntry entry) private static string GetStagingSourceRoot(string tempDirectory, VercelDeploymentEntry entry) { + // Use provider identity for the staged folder when possible. Vercel commands use CWD + // and link metadata, and stable staging names make logs/output easier to correlate + // without writing anything back to the source checkout. string sourceRoot = Path.TrimEndingDirectorySeparator(entry.SourceRoot); string sourceRootName = HasVercelProjectLinkFile(entry.SourceRoot) ? Path.GetFileName(sourceRoot) @@ -1105,6 +1591,56 @@ private static string GetStagingSourceRoot(string tempDirectory, VercelDeploymen return Path.Combine(tempDirectory, sourceRootName); } + private static void ValidateNoSymbolicLinks( + VercelDeploymentEntry entry, + bool preserveVercelProjectLink, + CancellationToken cancellationToken) + { + ValidateNoSymbolicLinks( + entry.Resource, + entry.SourceRoot, + entry.SourceRoot, + preserveVercelProjectLink, + cancellationToken); + } + + private static void ValidateNoSymbolicLinks( + IResource resource, + string sourceRoot, + string directory, + bool preserveVercelProjectLink, + CancellationToken cancellationToken) + { + foreach (string path in Directory.EnumerateFileSystemEntries(directory)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var attributes = File.GetAttributes(path); + string relativePath = Path.GetRelativePath(sourceRoot, path); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' source root contains symbolic link or reparse point '{relativePath}'. Vercel staging copies files into an upload directory and does not preserve symlink semantics. Replace the link with real files inside the source root or adjust the Dockerfile source context."); + } + + if ((attributes & FileAttributes.Directory) != 0) + { + string directoryName = Path.GetFileName(path); + if (ShouldSkipStagingDirectory(directoryName, preserveVercelProjectLink)) + { + continue; + } + + ValidateNoSymbolicLinks( + resource, + sourceRoot, + path, + preserveVercelProjectLink: false, + cancellationToken); + } + } + } + private static void LogSourceUploadWarnings(ILogger logger, VercelDeploymentEntry entry) { var warningPaths = GetSourceUploadWarningPaths(entry.SourceRoot); @@ -1114,7 +1650,7 @@ private static void LogSourceUploadWarnings(ILogger logger, VercelDeploymentEntr } logger.LogWarning( - "Resource '{ResourceName}' source root contains files or directories that may be uploaded to Vercel: {Paths}. Add or update .vercelignore to exclude sensitive or unnecessary content.", + "Resource '{ResourceName}' source root contains environment files that may be uploaded to Vercel: {Paths}. Add or update .vercelignore to exclude sensitive content.", entry.Resource.Name, string.Join(", ", warningPaths)); } @@ -1124,15 +1660,6 @@ internal static IReadOnlyList GetSourceUploadWarningPaths(string sourceR var ignorePatterns = ReadVercelIgnorePatterns(sourceRoot); List warningPaths = []; - foreach (string directoryName in CommonSourceUploadDirectoryWarnings) - { - string directoryPath = Path.Combine(sourceRoot, directoryName); - if (Directory.Exists(directoryPath) && !IsIgnoredByVercelIgnore(directoryName, isDirectory: true, ignorePatterns)) - { - warningPaths.Add($"{directoryName}/"); - } - } - foreach (string file in Directory.EnumerateFiles(sourceRoot, ".env*")) { string fileName = Path.GetFileName(file); @@ -1157,6 +1684,10 @@ private static bool IsExampleEnvironmentFile(string fileName) private static IReadOnlyList ReadVercelIgnorePatterns(string sourceRoot) { + // This is a warning-only approximation of .vercelignore, not a full gitignore engine. + // Vercel remains the authority for the actual upload set; this parser only avoids + // noisy .env warnings for common forms like `.env*`, `secrets/`, `!.env.example`, + // `nested/path`, and `*.local`. string vercelIgnorePath = Path.Combine(sourceRoot, ".vercelignore"); if (!File.Exists(vercelIgnorePath)) { @@ -1240,6 +1771,8 @@ private static void CopyDirectory( if (IsVercelDirectory(directoryName)) { + // Keep only project identity. Cache/build metadata belongs to the user's + // checkout and should not be uploaded from Aspire's staged source. CopyVercelProjectLink(directory, Path.Combine(destinationDirectory, directoryName), cancellationToken); continue; } @@ -1262,6 +1795,8 @@ private static void CopyDirectory( } private static bool ShouldSkipStagingDirectory(string directoryName, bool preserveVercelProjectLink) + // These are local checkout/provider cache directories, not app source for Vercel's + // remote Docker build. Linked projects get only .vercel/project.json via a separate path. => IsGitDirectory(directoryName) || IsNodeModulesDirectory(directoryName) || (IsVercelDirectory(directoryName) && !preserveVercelProjectLink); @@ -1302,6 +1837,9 @@ internal static string GetVercelProjectName(VercelDeploymentEntry entry) internal static string GetVercelProjectName(IResource resource) { + // This integration does not synthesize Dockerfiles from arbitrary compute resources. + // The workload must already carry Aspire Dockerfile publish metadata so Vercel receives + // the same source/Dockerfile contract the user opted into. if (!resource.TryGetLastAnnotation(out var dockerfile)) { throw new DistributedApplicationException($"Resource '{resource.Name}' targets Vercel but does not have Aspire Dockerfile build metadata. Use a workload integration that publishes Dockerfile metadata, call PublishAsDockerFile, or configure the resource with WithDockerfile, WithDockerfileFactory, or WithDockerfileBuilder."); @@ -1327,6 +1865,8 @@ private static string GetManagedVercelProjectName(VercelDeploymentEntry entry) return options.ProjectName; } + // The production endpoint contract is project-name based, so managed names must + // be stable and Vercel-valid before deploy starts. string sourceRoot = Path.TrimEndingDirectorySeparator(entry.SourceRoot); string sourceRootName = Path.GetFileName(sourceRoot); @@ -1412,6 +1952,9 @@ private static bool TryReadVercelProjectLink(string sourceRoot, [NotNullWhen(tru if (File.Exists(projectJsonPath)) { + // Vercel CLI writes linked project identity as: + // .vercel/project.json: { "projectId": "...", "projectName": "..." } + // Treat it as provider ownership metadata rather than regenerating a managed name. using var document = JsonDocument.Parse(File.ReadAllText(projectJsonPath)); string? projectName = GetJsonStringProperty(document.RootElement, "projectName"); @@ -1446,6 +1989,8 @@ internal static VercelDeploymentResult GetDeploymentResult(string standardOutput return jsonDeploymentResult; } + // Older CLI versions printed the deployment URL as plain text. Keep the fallback so + // we fail only when no usable URL exists, not because formatting changed slightly. string[] lines = standardOutput .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); @@ -1462,6 +2007,11 @@ internal static VercelDeploymentInspection GetDeploymentInspection(string standa { try { + // Parse the Vercel inspect JSON shapes observed across CLI versions: + // { "readyState": "READY" } + // { "state": "READY" } + // { "deployment": { "readyState": "READY" } } + // { "deployment": { "state": "READY" } } using var document = JsonDocument.Parse(standardOutput); var root = document.RootElement; string? readyState = GetJsonStringProperty(root, "readyState") @@ -1504,6 +2054,10 @@ internal static VercelDeploymentInspection GetDeploymentInspection(string standa private static bool TryGetDeploymentResult(JsonElement root, [NotNullWhen(true)] out VercelDeploymentResult? deploymentResult) { + // Parse the Vercel deploy JSON shapes observed from different CLI versions: + // { "deployment": { "url": "https://...", "id": "..." } } + // { "url": "https://...", "id": "..." } + // Callers fall back to line-based extraction when deploy output is plain text. if (root.TryGetProperty("deployment", out var deployment) && deployment.TryGetProperty("url", out var nestedUrl) && TryGetHttpUrl(nestedUrl, out var nestedDeploymentUrl)) @@ -1578,6 +2132,17 @@ internal sealed record VercelDeploymentPlan(string Environment, VercelDeployment internal sealed record VercelDeploymentPlanEntry(string ResourceName, string DockerfilePath, string DeployCommand, string[] EnvironmentVariables); +internal sealed record VercelEnvironmentConfiguration( + IReadOnlyList> DeploymentEnvironmentVariables, + IReadOnlyList> ProjectEnvironmentVariables) +{ + public static VercelEnvironmentConfiguration Empty { get; } = new([], []); + + public IEnumerable AllEnvironmentVariableNames => + DeploymentEnvironmentVariables.Select(static variable => variable.Key) + .Concat(ProjectEnvironmentVariables.Select(static variable => variable.Key)); +} + internal sealed record VercelDeploymentResult(string? DeploymentId, string DeploymentUrl); internal sealed record VercelDeploymentInspection(string? ReadyState); diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs index f5586d9f7..d3399ea4c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs @@ -3,6 +3,7 @@ using CommunityToolkit.Aspire.Hosting.Vercel; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Net.Sockets; namespace Aspire.Hosting.ApplicationModel; @@ -37,9 +38,11 @@ public ReferenceExpression GetHostAddressExpression(EndpointReference endpointRe if (!IsHttpEndpoint(endpoint)) { throw new InvalidOperationException( - $"Vercel endpoint references support only HTTP or HTTPS endpoints. Endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}' uses scheme '{endpoint.UriScheme}'."); + $"Vercel endpoint references support only HTTP or HTTPS endpoints with HTTP transports. Endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}' uses scheme '{endpoint.UriScheme}' and transport '{endpoint.Transport}'."); } + // Same-deploy references need an address that exists before `vercel deploy` + // finishes. Production project aliases are deterministic; preview URLs are not. string projectName = VercelDeploymentStep.GetVercelProjectName(endpointReference.Resource); return ReferenceExpression.Create($"{projectName}.vercel.app"); } @@ -56,14 +59,23 @@ public ReferenceExpression GetEndpointPropertyExpression(EndpointReferenceExpres var host = GetHostAddressExpression(endpointReference); const int port = 443; + // These expressions model the caller-visible Vercel edge endpoint, not the + // container listener. Vercel terminates TLS and forwards to the runtime's $PORT, + // so service references use HTTPS/443 while TargetPort remains container metadata. return property switch { + // Vercel terminates public HTTPS at the platform edge, so callers always use + // the alias over 443 even when the container listens on $PORT internally. EndpointProperty.Url => ReferenceExpression.Create($"https://{host}"), EndpointProperty.Host or EndpointProperty.IPV4Host => host, EndpointProperty.Port => ReferenceExpression.Create($"{port.ToString(CultureInfo.InvariantCulture)}"), EndpointProperty.TargetPort => endpoint.TargetPort is int targetPort ? ReferenceExpression.Create($"{targetPort.ToString(CultureInfo.InvariantCulture)}") : throw new InvalidOperationException( + // Azure publishers can emit ContainerPortReference placeholders into + // Bicep/Helm. Vercel deploy receives concrete CLI env values, so an + // unresolved TargetPort would become a bogus string rather than a + // target-native reference. $"The endpoint property '{EndpointProperty.TargetPort}' cannot be resolved for endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}' because the endpoint does not define an explicit target port."), EndpointProperty.Scheme => ReferenceExpression.Create($"https"), EndpointProperty.HostAndPort => ReferenceExpression.Create($"{host}:{port.ToString(CultureInfo.InvariantCulture)}"), @@ -73,6 +85,9 @@ public ReferenceExpression GetEndpointPropertyExpression(EndpointReferenceExpres } private static bool IsHttpEndpoint(EndpointAnnotation endpoint) - => string.Equals(endpoint.UriScheme, "http", StringComparison.OrdinalIgnoreCase) - || string.Equals(endpoint.UriScheme, "https", StringComparison.OrdinalIgnoreCase); + => endpoint.Protocol == ProtocolType.Tcp + && (string.Equals(endpoint.UriScheme, "http", StringComparison.OrdinalIgnoreCase) + || string.Equals(endpoint.UriScheme, "https", StringComparison.OrdinalIgnoreCase)) + && (string.Equals(endpoint.Transport, "http", StringComparison.OrdinalIgnoreCase) + || string.Equals(endpoint.Transport, "http2", StringComparison.OrdinalIgnoreCase)); } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs index 94dc0166d..19c8a33f9 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using System.Net.Sockets; using System.Text.Json; using System.Text.Json.Nodes; @@ -366,8 +367,8 @@ public async Task WriteDeploymentPlanWritesExpectedJson() Assert.Equal("vercel", root.GetProperty("environment").GetString()); var deployment = Assert.Single(root.GetProperty("deployments").EnumerateArray()); Assert.Equal("api", deployment.GetProperty("resourceName").GetString()); - Assert.Equal("Dockerfile", deployment.GetProperty("dockerfilePath").GetString()); - Assert.Equal("vercel --cwd deploy --yes", deployment.GetProperty("deployCommand").GetString()); + Assert.Equal("Dockerfile.vercel", deployment.GetProperty("dockerfilePath").GetString()); + Assert.Equal("vercel --cwd deploy --yes --project ", deployment.GetProperty("deployCommand").GetString()); } [Fact] @@ -424,11 +425,44 @@ public async Task WriteDeploymentPlanProcessesEnvironmentVariables() using var document = JsonDocument.Parse(File.ReadAllText(planPath)); var deployment = Assert.Single(document.RootElement.GetProperty("deployments").EnumerateArray()); - Assert.Equal("vercel --cwd deploy --yes --env GREETING=", deployment.GetProperty("deployCommand").GetString()); + Assert.Equal("vercel --cwd deploy --yes --project --env GREETING=", deployment.GetProperty("deployCommand").GetString()); Assert.Equal("GREETING", Assert.Single(deployment.GetProperty("environmentVariables").EnumerateArray()).GetString()); Assert.DoesNotContain("hello", File.ReadAllText(planPath)); } + [Fact] + public async Task WriteDeploymentPlanDoesNotResolveSecretEnvironmentVariableValues() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + var secret = builder.AddParameter("api-key", secret: true); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithEnvironment("API_KEY", secret); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + string planPath = await VercelDeploymentStep.WriteDeploymentPlanAsync( + builder.ExecutionContext, + NullLogger.Instance, + model, + environment, + outputRoot.Path, + TestContext.Current.CancellationToken); + + using var document = JsonDocument.Parse(File.ReadAllText(planPath)); + var deployment = Assert.Single(document.RootElement.GetProperty("deployments").EnumerateArray()); + + Assert.DoesNotContain("--env API_KEY", deployment.GetProperty("deployCommand").GetString(), StringComparison.Ordinal); + Assert.Equal("API_KEY", Assert.Single(deployment.GetProperty("environmentVariables").EnumerateArray()).GetString()); + } + [Fact] public async Task WriteDeploymentPlanThrowsWhenDockerfileIsMissing() { @@ -573,11 +607,38 @@ public async Task WriteDeploymentPlanThrowsForNonHttpEndpoints() Assert.Contains("support only HTTP or HTTPS endpoints", exception.Message); } + [Theory] + [InlineData("http")] + [InlineData("https")] + public async Task WriteDeploymentPlanAllowsExternalHttpEndpoints(string scheme) + { + await AssertWriteDeploymentPlanSucceedsAsync(api => + api.WithEndpoint(targetPort: 8080, scheme: scheme, name: scheme, isExternal: true)); + } + [Fact] - public async Task WriteDeploymentPlanAllowsInternalHttpEndpoints() + public async Task WriteDeploymentPlanThrowsForInternalHttpEndpoints() { - await AssertWriteDeploymentPlanSucceedsAsync(static api => + var exception = await AssertWriteDeploymentPlanThrowsAsync(static api => api.WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: false)); + + Assert.Contains("public platform HTTPS ingress only", exception.Message); + } + + [Fact] + public async Task WriteDeploymentPlanThrowsForNonHttpEndpointTransports() + { + var exception = await AssertWriteDeploymentPlanThrowsAsync(static api => + api.Resource.Annotations.Add(new EndpointAnnotation( + ProtocolType.Tcp, + uriScheme: "http", + transport: "tcp", + name: "tcp-http", + targetPort: 8080, + isExternal: true))); + + Assert.Contains("transport 'tcp'", exception.Message); + Assert.Contains("HTTP transports", exception.Message); } [Fact] @@ -705,7 +766,7 @@ public async Task DeployAsyncStagesManagedProjectUsingSlugifiedProjectName() await VercelDeploymentStep.DeployAsync(context, environment); string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", "invalid-project"); - var invocation = runner.Invocations[0]; + var invocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("deploy")); Assert.Equal(expectedStagingRoot, invocation.WorkingDirectory); var deployment = Assert.Single(ReadSavedState(Assert.Single(stateManager.SavedSections)).Deployments); @@ -740,7 +801,11 @@ public async Task DeployAsyncStagesManagedProjectUsingConfiguredProjectName() await VercelDeploymentStep.DeployAsync(context, environment); string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", "configured-project"); - Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "Dockerfile"))); + Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "Dockerfile.vercel"))); + AssertGeneratedVercelJson(expectedStagingRoot); + var deployInvocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("deploy")); + Assert.Contains("--project", deployInvocation.Arguments); + Assert.Contains("configured-project", deployInvocation.Arguments); var state = ReadSavedState(Assert.Single(stateManager.SavedSections)); var deployment = Assert.Single(state.Deployments); @@ -760,7 +825,7 @@ public void BuildDeployArgumentsIncludesConfiguredOptions() string[] arguments = VercelDeploymentStep.BuildDeployArguments(options, entry); - Assert.Equal(["--scope", "team", "--cwd", "/repo/src/api", "deploy", "--yes", "--prod"], arguments); + Assert.Equal(["--scope", "team", "--cwd", "/repo/src/api", "deploy", "--yes", "--project", "api", "--prod"], arguments); } [Fact] @@ -774,7 +839,7 @@ public void BuildDeployArgumentsIncludesTarget() string[] arguments = VercelDeploymentStep.BuildDeployArguments(options, entry); - Assert.Equal(["--cwd", "/repo/src/api", "deploy", "--yes", "--target", "preview"], arguments); + Assert.Equal(["--cwd", "/repo/src/api", "deploy", "--yes", "--project", "api", "--target", "preview"], arguments); } [Fact] @@ -902,7 +967,7 @@ public async Task BuildDeployArgumentsThrowsForContainerEntrypoint() } [Fact] - public async Task BuildDeployArgumentsThrowsForSecretEnvironmentVariables() + public async Task BuildDeployArgumentsDoesNotPassSecretEnvironmentVariablesOnCommandLine() { using var sourceRoot = TemporaryDirectory.Create(); File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); @@ -919,53 +984,80 @@ public async Task BuildDeployArgumentsThrowsForSecretEnvironmentVariables() var environment = Assert.Single(model.Resources.OfType()); var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); - var exception = await Assert.ThrowsAsync(() => - VercelDeploymentStep.BuildDeployArgumentsAsync( - builder.ExecutionContext, - NullLogger.Instance, - environment.GetVercelOptions(), - entry, - TestContext.Current.CancellationToken)); + string[] arguments = await VercelDeploymentStep.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken); - Assert.Contains("API_KEY", exception.Message); + Assert.DoesNotContain(arguments, argument => argument.Contains("API_KEY", StringComparison.Ordinal)); } [Fact] - public async Task BuildDeployArgumentsThrowsForCompositeSecretEnvironmentVariables() + public async Task DeployAsyncConfiguresSecretEnvironmentVariablesBeforeDeploy() { using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, "", ""), + new VercelCliResult(0, "", ""), + new VercelCliResult(0, "", ""), + new VercelCliResult(0, """ + { + "deployment": { + "id": "dpl_secret", + "url": "https://secret-project.vercel.app" + } + } + """, ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); var secret = builder.AddParameter("api-key", "secret-value", secret: true); - builder.AddVercelEnvironment("vercel"); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel") + .WithVercelProductionDeployments(); builder.AddContainer("api", "api") .WithDockerfile(sourceRoot.Path, "Dockerfile") - .WithEnvironment("AUTH_HEADER", ReferenceExpression.Create($"Bearer {secret.Resource}")); + .WithEnvironment("AUTH_HEADER", ReferenceExpression.Create($"Bearer {secret.Resource}")) + .WithEnvironment("API_KEY", secret) + .WithEnvironment("GREETING", "hello"); using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + string projectName = VercelDeploymentStep.GetVercelProjectName(entry); + var context = CreatePipelineStepContext(builder, app); - var exception = await Assert.ThrowsAsync(() => - VercelDeploymentStep.BuildDeployArgumentsAsync( - builder.ExecutionContext, - NullLogger.Instance, - environment.GetVercelOptions(), - entry, - TestContext.Current.CancellationToken)); + await VercelDeploymentStep.DeployAsync(context, environment); - Assert.Contains("AUTH_HEADER", exception.Message); + string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", projectName); + Assert.Equal(["project", "add", projectName], runner.Invocations[0].Arguments); + Assert.Equal(["--cwd", expectedStagingRoot, "link", "--yes", "--project", projectName], runner.Invocations[1].Arguments); + Assert.Equal(["--cwd", expectedStagingRoot, "env", "add", "API_KEY", "production", "--yes", "--force", "--sensitive"], runner.Invocations[2].Arguments); + Assert.Equal("secret-value", runner.Invocations[2].StandardInput); + Assert.Equal(["--cwd", expectedStagingRoot, "env", "add", "AUTH_HEADER", "production", "--yes", "--force", "--sensitive"], runner.Invocations[3].Arguments); + Assert.Equal("Bearer secret-value", runner.Invocations[3].StandardInput); + Assert.Contains("GREETING=hello", runner.Invocations[4].Arguments); + Assert.DoesNotContain(runner.Invocations[4].Arguments, argument => argument.Contains("API_KEY", StringComparison.Ordinal)); + Assert.DoesNotContain(runner.Invocations[4].Arguments, argument => argument.Contains("AUTH_HEADER", StringComparison.Ordinal)); } [Fact] - public async Task BuildDeployArgumentsThrowsForConnectionStringEnvironmentVariables() + public async Task BuildDeployArgumentsDoesNotPassConnectionStringEnvironmentVariablesOnCommandLine() { using var sourceRoot = TemporaryDirectory.Create(); File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + builder.Configuration["ConnectionStrings:db"] = "Host=example.com;Username=app;Password=secret"; var connectionString = builder.AddConnectionString("db"); builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") @@ -977,15 +1069,14 @@ public async Task BuildDeployArgumentsThrowsForConnectionStringEnvironmentVariab var environment = Assert.Single(model.Resources.OfType()); var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); - var exception = await Assert.ThrowsAsync(() => - VercelDeploymentStep.BuildDeployArgumentsAsync( - builder.ExecutionContext, - NullLogger.Instance, - environment.GetVercelOptions(), - entry, - TestContext.Current.CancellationToken)); + string[] arguments = await VercelDeploymentStep.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken); - Assert.Contains("DATABASE_URL", exception.Message); + Assert.DoesNotContain(arguments, argument => argument.Contains("DATABASE_URL", StringComparison.Ordinal)); } [Fact] @@ -1390,7 +1481,7 @@ public async Task DeployAsyncDoesNotRequireContainerRegistryOrImageManager() await VercelDeploymentStep.DeployAsync(context, environment); Assert.Equal(0, imageManager.CallCount); - Assert.Equal(2, runner.Invocations.Count); + Assert.Equal(3, runner.Invocations.Count); Assert.Single(stateManager.SavedSections); } @@ -1430,14 +1521,16 @@ public async Task DeployAsyncRunsVercelCliAndSavesDeploymentState() await VercelDeploymentStep.DeployAsync(context, environment); - var invocation = runner.Invocations[0]; + var invocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("deploy")); string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", "vercel-state-project"); Assert.Equal("vercel", invocation.FileName); Assert.Equal(expectedStagingRoot, invocation.WorkingDirectory); - Assert.Equal(["--scope", "team", "--cwd", expectedStagingRoot, "deploy", "--yes", "--prod", "--env", "GREETING=hello"], invocation.Arguments); + Assert.Equal(["--scope", "team", "--cwd", expectedStagingRoot, "deploy", "--yes", "--project", "vercel-state-project", "--prod", "--env", "GREETING=hello"], invocation.Arguments); Assert.Null(invocation.StandardInput); - Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "Dockerfile"))); - Assert.Equal(["--scope", "team", "inspect", "https://api.vercel.app", "--wait", "--timeout", "120s", "--format=json"], runner.Invocations[1].Arguments); + Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "Dockerfile.vercel"))); + AssertGeneratedVercelJson(expectedStagingRoot); + var inspectInvocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("inspect")); + Assert.Equal(["--scope", "team", "inspect", "https://api.vercel.app", "--wait", "--timeout", "120s", "--format=json"], inspectInvocation.Arguments); Assert.Collection( context.Summary.Items, @@ -1637,7 +1730,8 @@ public async Task DeployAsyncSkipsIgnoredDirectoriesWhenStagingManagedProjects() await VercelDeploymentStep.DeployAsync(context, environment); string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", "ignored-staging-project"); - Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "Dockerfile"))); + Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "Dockerfile.vercel"))); + AssertGeneratedVercelJson(expectedStagingRoot); Assert.True(File.Exists(Path.Combine(expectedStagingRoot, ".vercelignore"))); Assert.False(Directory.Exists(Path.Combine(expectedStagingRoot, ".git"))); Assert.False(Directory.Exists(Path.Combine(expectedStagingRoot, "node_modules"))); @@ -1645,15 +1739,13 @@ public async Task DeployAsyncSkipsIgnoredDirectoriesWhenStagingManagedProjects() } [Fact] - public async Task DeployAsyncWarnsWhenSourceUploadMayIncludeSensitiveOrHeavyFiles() + public async Task DeployAsyncWarnsWhenSourceUploadMayIncludeEnvironmentFiles() { using var sourceRoot = TemporaryDirectory.Create("source-warning-project"); using var outputRoot = TemporaryDirectory.Create(); using var tempRoot = TemporaryDirectory.Create(); File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); File.WriteAllText(Path.Combine(sourceRoot.Path, ".env"), "SECRET=value"); - Directory.CreateDirectory(Path.Combine(sourceRoot.Path, "bin")); - Directory.CreateDirectory(Path.Combine(sourceRoot.Path, "obj")); var runner = new FakeVercelCliRunner( new VercelCliResult(0, "https://source-warning-project.vercel.app", ""), ReadyInspectResult()); @@ -1675,30 +1767,122 @@ public async Task DeployAsyncWarnsWhenSourceUploadMayIncludeSensitiveOrHeavyFile await VercelDeploymentStep.DeployAsync(context, environment); var warning = Assert.Single(logger.Entries, entry => entry.Level == LogLevel.Warning); - Assert.Contains("source root contains files or directories that may be uploaded to Vercel", warning.Message); + Assert.Contains("source root contains environment files that may be uploaded to Vercel", warning.Message); Assert.Contains(".env", warning.Message); - Assert.Contains("bin/", warning.Message); - Assert.Contains("obj/", warning.Message); Assert.DoesNotContain("SECRET=value", warning.Message); } + [Fact] + public async Task DeployAsyncThrowsWhenSourceRootContainsSymbolicLink() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + using var sourceRoot = TemporaryDirectory.Create(); + using var outsideRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(outsideRoot.Path, "outside.txt"), "outside"); + File.CreateSymbolicLink(Path.Combine(sourceRoot.Path, "outside-link.txt"), Path.Combine(outsideRoot.Path, "outside.txt")); + var runner = new FakeVercelCliRunner(); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.DeployAsync(context, environment)); + + Assert.Contains("symbolic link", exception.Message); + Assert.Contains("outside-link.txt", exception.Message); + Assert.Empty(runner.Invocations); + } + + [Fact] + public async Task DeployAsyncThrowsWhenVercelJsonAlreadyConfiguresServices() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "vercel.json"), """{"services":{"api":{"root":"."}}}"""); + var runner = new FakeVercelCliRunner(); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.DeployAsync(context, environment)); + + Assert.Contains("existing 'services' configuration", exception.Message); + Assert.Empty(runner.Invocations); + } + + [Theory] + [InlineData("build", "{\"build\":{\"env\":{\"SECRET\":\"value\"}}}")] + [InlineData("builds", "{\"builds\":[]}")] + [InlineData("env", "{\"env\":{\"SECRET\":\"value\"}}")] + [InlineData("routes", "{\"routes\":[]}")] + public async Task DeployAsyncThrowsWhenVercelJsonContainsUnsupportedServicesModeTopLevelKey(string key, string vercelJson) + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "vercel.json"), vercelJson); + var runner = new FakeVercelCliRunner(); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.DeployAsync(context, environment)); + + Assert.Contains($"top-level '{key}'", exception.Message); + Assert.Empty(runner.Invocations); + } + [Fact] public void GetSourceUploadWarningPathsHonorsVercelIgnore() { using var sourceRoot = TemporaryDirectory.Create("ignored-source-warning-project"); File.WriteAllText(Path.Combine(sourceRoot.Path, ".vercelignore"), """ .env* - bin/ - obj/ - TestResults/ - coverage/ """); File.WriteAllText(Path.Combine(sourceRoot.Path, ".env.local"), "SECRET=value"); File.WriteAllText(Path.Combine(sourceRoot.Path, ".env.example"), "DOCUMENTED=value"); - Directory.CreateDirectory(Path.Combine(sourceRoot.Path, "bin")); - Directory.CreateDirectory(Path.Combine(sourceRoot.Path, "obj")); - Directory.CreateDirectory(Path.Combine(sourceRoot.Path, "TestResults")); - Directory.CreateDirectory(Path.Combine(sourceRoot.Path, "coverage")); var warnings = VercelDeploymentStep.GetSourceUploadWarningPaths(sourceRoot.Path); @@ -1737,13 +1921,14 @@ COPY server.mjs . await VercelDeploymentStep.DeployAsync(context, environment); - var invocation = runner.Invocations[0]; + var invocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("deploy")); string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", "generated-vercel-project"); Assert.Equal(expectedStagingRoot, invocation.WorkingDirectory); - Assert.Equal(["--cwd", expectedStagingRoot, "deploy", "--yes"], invocation.Arguments); - Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "Dockerfile"))); + Assert.Equal(["--cwd", expectedStagingRoot, "deploy", "--yes", "--project", "generated-vercel-project"], invocation.Arguments); + Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "Dockerfile.vercel"))); Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "server.mjs"))); - Assert.Contains("FROM node:22-alpine", File.ReadAllText(Path.Combine(expectedStagingRoot, "Dockerfile"))); + AssertGeneratedVercelJson(expectedStagingRoot); + Assert.Contains("FROM node:22-alpine", File.ReadAllText(Path.Combine(expectedStagingRoot, "Dockerfile.vercel"))); var savedSection = Assert.Single(stateManager.SavedSections); Assert.Contains("generated-vercel-project", savedSection.Data.ToJsonString()); @@ -1776,11 +1961,12 @@ public async Task DeployAsyncStagesCustomDockerfileNameBeforeRunningVercelCli() await VercelDeploymentStep.DeployAsync(context, environment); - var invocation = runner.Invocations[0]; + var invocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("deploy")); string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", "custom-vercel-project"); Assert.Equal(expectedStagingRoot, invocation.WorkingDirectory); - Assert.Equal(["--cwd", expectedStagingRoot, "deploy", "--yes"], invocation.Arguments); - Assert.Equal("FROM nginx:alpine", File.ReadAllText(Path.Combine(expectedStagingRoot, "Dockerfile"))); + Assert.Equal(["--cwd", expectedStagingRoot, "deploy", "--yes", "--project", "custom-vercel-project"], invocation.Arguments); + Assert.Equal("FROM nginx:alpine", File.ReadAllText(Path.Combine(expectedStagingRoot, "Dockerfile.vercel"))); + AssertGeneratedVercelJson(expectedStagingRoot); Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "index.html"))); } @@ -1835,7 +2021,7 @@ public async Task DeployAsyncThrowsWhenVercelDeployOutputHasNoDeploymentUrl() VercelDeploymentStep.DeployAsync(context, environment)); Assert.Contains("did not contain an HTTP or HTTPS deployment URL", exception.Message); - Assert.Single(runner.Invocations); + Assert.Equal(2, runner.Invocations.Count); Assert.Empty(stateManager.SavedSections); } @@ -2635,6 +2821,19 @@ private static VercelDeploymentState ReadSavedState(DeploymentStateSection secti section.Data.First().Value!.GetValue(), new JsonSerializerOptions(JsonSerializerDefaults.Web))!; + private static void AssertGeneratedVercelJson(string stagingRoot) + { + using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(stagingRoot, "vercel.json"))); + var root = document.RootElement; + var service = root.GetProperty("services").GetProperty("app"); + Assert.Equal("container", service.GetProperty("runtime").GetString()); + Assert.Equal(".", service.GetProperty("root").GetString()); + Assert.Equal("Dockerfile.vercel", service.GetProperty("entrypoint").GetString()); + var rewrite = root.GetProperty("rewrites").EnumerateArray().Last(); + Assert.Equal("/(.*)", rewrite.GetProperty("source").GetString()); + Assert.Equal("app", rewrite.GetProperty("destination").GetProperty("service").GetString()); + } + private sealed class FakeProjectMetadata(string projectPath) : IProjectMetadata { public bool IsFileBasedApp => false; @@ -2690,12 +2889,31 @@ public Task RunAsync( { Invocations.Add(new(fileName, [.. arguments], workingDirectory, standardInput)); + if (IsProjectAdd(arguments)) + { + return Task.FromResult(new VercelCliResult(0, "", "")); + } + var result = _results.Count > 0 ? _results.Dequeue() : new VercelCliResult(0, "", ""); return Task.FromResult(result); } + + private static bool IsProjectAdd(IReadOnlyList arguments) + { + for (int i = 0; i < arguments.Count - 1; i++) + { + if (string.Equals(arguments[i], "project", StringComparison.Ordinal) + && string.Equals(arguments[i + 1], "add", StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } } private sealed class RecordingLogger : ILogger From f75d296cff63a7b182354747508c66f012aa44ef Mon Sep 17 00:00:00 2001 From: David Fowler Date: Thu, 2 Jul 2026 20:55:15 -0700 Subject: [PATCH 18/33] Add Vercel deployment integration Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../README.md | 76 +- .../VercelCliRunner.cs | 5 + .../VercelContainerRegistryClient.cs | 61 + .../VercelDeploymentStep.cs | 2222 +++++++++++------ ...celEnvironmentResourceBuilderExtensions.cs | 80 +- .../VercelResourceContainerImageManager.cs | 49 + .../TypeScriptAppHostTests.cs | 25 + .../VercelEnvironmentTests.cs | 1550 ++++++++++-- 8 files changed, 3146 insertions(+), 922 deletions(-) create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelContainerRegistryClient.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceContainerImageManager.cs diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md index 9b5e5354e..2676932e3 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md @@ -12,7 +12,7 @@ aspire add CommunityToolkit.Aspire.Hosting.Vercel ## Usage example -Add a Vercel environment and a workload with Aspire Dockerfile publish metadata. When Vercel is the only compute environment, Dockerfile-backed workloads target it by default: +Add a Vercel environment and a workload that participates in Aspire's image build/push model. Language integrations such as `AddNodeApp` can emit generated Dockerfile metadata for deploy. When Vercel is the only compute environment, image-build workloads target it by default: ```csharp var builder = DistributedApplication.CreateBuilder(args); @@ -31,10 +31,22 @@ builder.AddProject("api") .PublishAsDockerFile(); ``` +Checked-in Dockerfiles also work for low-level container resources: + +```csharp +builder.AddContainer("api", "api") + .WithDockerfile("../api", "Dockerfile"); +``` + By default, `aspire deploy` runs: ```bash -vercel --cwd deploy --yes --project +vercel link --cwd --yes --project +vercel pull --cwd --yes --environment +docker login vcr.vercel.com --username --password-stdin +aspire build/push -> vcr.vercel.com///app: +docker buildx imagetools inspect --format '{{json .Manifest}}' vcr.vercel.com///app: +vercel deploy --cwd --project --prebuilt --yes ``` Use `WithVercelProductionDeployments` to add `--prod`, `WithVercelTarget` to add `--target`, and `WithVercelScope` to deploy to a team or account scope. @@ -44,43 +56,61 @@ When an AppHost contains multiple compute environments, use Aspire's standard `W ```csharp var vercel = builder.AddVercelEnvironment("vercel"); -builder.AddNodeApp("api", "../api", "server.mjs") +builder.AddContainer("api", "api") + .WithDockerfile("../api", "Dockerfile") .WithComputeEnvironment(vercel); ``` Use `WithVercelProjectName` when an Aspire-managed resource should deploy to a specific Vercel project name instead of inferring one from the source directory: ```csharp -builder.AddNodeApp("api", "../api", "server.mjs") +builder.AddContainer("api", "api") + .WithDockerfile("../api", "Dockerfile") .WithVercelProjectName("my-api"); ``` Linked projects with `.vercel/project.json` keep their existing provider project identity and take precedence over `WithVercelProjectName`. -For low-level container resources, Vercel requires existing Aspire Dockerfile build metadata. Prefer the workload-specific `Add*App` integration when one exists. `WithDockerfile`, `WithDockerfileFactory`, and `WithDockerfileBuilder` are supported as advanced escape hatches. The integration always stages the source root into Aspire's deploy-time temp directory, materializes the Vercel-facing `Dockerfile.vercel` there, writes a services-mode `vercel.json` that routes all traffic to a generated `app` container service, and runs `vercel deploy` from the staged directory so the Vercel CLI does not write `.vercel` metadata into the source tree. Staging skips `.git`, `node_modules`, and unmanaged `.vercel` metadata; linked projects keep only `.vercel/project.json`. Source roots containing symbolic links or reparse points are rejected because staging would otherwise change symlink semantics or copy data from outside the source root. Add a `.vercelignore` file to exclude local files such as `.env`, test artifacts, large assets, and other files that should not be uploaded to Vercel. Deploy emits a warning when root `.env*` files are present and are not covered by `.vercelignore`. +For low-level container resources, Vercel requires Aspire image-build metadata. `WithDockerfile` is supported for checked-in Dockerfiles and Containerfiles, including custom names or paths outside the source root. Generated Dockerfiles (`WithDockerfileFactory` / `WithDockerfileBuilder`) and language integrations such as `AddNodeApp` use Aspire's built-in image build path without staging source or writing generated files into the user checkout. Resource-level `WithContainerRegistry` remains unsupported for Vercel-targeted resources because the integration must push to the Vercel project-owned VCR repository used by the generated Build Output API metadata. Add a `.dockerignore` file to keep local files such as `.env`, test artifacts, large assets, and other files out of the local Docker build context. + +## How it works end to end + +`aspire publish` and `aspire deploy` intentionally do different work: + +1. `aspire publish` validates the targeted resources and writes `vercel-deployments.json`, a deterministic review plan with command shape, Dockerfile/source information, and environment variable names. It does not call Vercel, resolve secrets, or push images. +2. The deploy prerequisite step validates Vercel CLI, Docker buildx, Vercel auth, configured scope, existing state compatibility, Vercel project names, endpoints, and unsupported Aspire concepts before provider mutation. +3. For each resource, the integration creates or links a Vercel project in an Aspire-owned scratch directory, configures sensitive project environment variables with `vercel env add --sensitive` over standard input, and runs `vercel pull` in that scratch directory to obtain `.vercel/project.json`, `.vercel/.env..local`, and the short-lived `VERCEL_OIDC_TOKEN`. +4. The integration decodes only routing claims from the Vercel OIDC JWT payload, configures Vercel Container Registry (`vcr.vercel.com//`) as the resource's Aspire deployment target registry, and lets Aspire's built-in build/push steps build the workload image and push it to VCR. +5. After the push, deploy logs in to VCR again, runs `docker buildx imagetools inspect --format '{{json .Manifest}}'`, and selects the linux/amd64 manifest digest. Live Vercel validation rejected OCI index digests, so the generated artifact references the concrete platform manifest. +6. Deploy writes Vercel Build Output API v3 files under Aspire temp/output storage: `.vercel/project.json`, `.vercel/output/config.json`, and `.vercel/output/functions/index.func/.vc-config.json` with `runtime: "container"` and a digest-pinned VCR `handler`. +7. Deploy runs `vercel deploy --prebuilt`, parses the deployment URL from JSON or plain CLI output, verifies readiness with `vercel inspect --wait --format=json`, records deployment URLs/image digests/state, and adds deployment URLs to the pipeline summary. +8. `aspire destroy` reads saved deployment state first, removes tracked project environment variables from linked projects, deletes only Aspire-managed Vercel projects, treats already-missing projects as converged, and saves partial state after each successful delete so retry remains safe. Non-secret Aspire environment variables configured on the resource are processed during publish/deploy and passed to Vercel as deployment-scoped CLI environment variables: ```csharp -builder.AddNodeApp("api", "../api", "server.mjs") +builder.AddContainer("api", "api") + .WithDockerfile("../api", "Dockerfile") .WithEnvironment("GREETING", "hello"); ``` ```bash -vercel --cwd deploy --yes --project --env GREETING=hello +vercel deploy --cwd --project --prebuilt --yes --env GREETING=hello ``` -Secret parameters, connection strings, and composite values that contain secrets are configured as sensitive Vercel project environment variables before deploy. The integration links the staged project when needed, then sends the value through standard input instead of putting it on the command line: +Secret parameters, connection strings, and composite values that contain secrets are configured as sensitive Vercel project environment variables before deploy. Because `vercel env add` is scoped through Vercel link metadata and does not accept `--project`, the integration links a temporary scratch directory outside the source tree and sends the value through standard input instead of putting it on the command line: ```csharp var apiKey = builder.AddParameter("api-key", secret: true); -builder.AddNodeApp("api", "../api", "server.mjs") +builder.AddContainer("api", "api") + .WithDockerfile("../api", "Dockerfile") .WithEnvironment("API_KEY", apiKey); ``` ```bash -vercel --cwd env add API_KEY production --yes --force --sensitive +vercel link --cwd --yes --project +vercel env add API_KEY production --cwd --yes --force --sensitive ``` Secret project environment variables are written to `production` when `WithVercelProductionDeployments` is used, to the configured `WithVercelTarget` value when one is set, and to `preview` otherwise. Deploy treats secret environment variable names configured in the AppHost as Aspire-owned for that Vercel target and overwrites those values on each deploy. @@ -105,16 +135,24 @@ The `BACKEND_URL` value is deployed as `https://.vercel.app`. E ## Deployment behavior - `aspire start` is unchanged. The Vercel environment is publish/deploy-only. -- `aspire publish` writes a deterministic `vercel-deployments.json` plan for the targeted resources, including the environment variable names passed to Vercel. It does not require a container registry. -- `aspire deploy` validates the Vercel CLI, validates authentication and configured scope, stages the source root, materializes `Dockerfile.vercel` and the required services-mode `vercel.json`, invokes `vercel deploy`, and verifies the resulting deployment with `vercel inspect --wait --format=json` before saving deployment state for each verified resource. Vercel performs the remote source upload/build; Aspire does not build or push container images for this environment. The deployment summary and state record the deployment URL, and production deployments also record the deterministic `https://.vercel.app` production URL. +- `aspire publish` writes a deterministic `vercel-deployments.json` plan for the targeted resources, including the environment variable names passed to Vercel. +- `aspire deploy` validates the Vercel CLI, Docker authentication, and configured scope; creates Aspire-managed Vercel projects when needed; links a temporary scratch directory for project-scoped Vercel CLI operations; configures secret project environment variables; pulls Vercel project settings into that scratch directory for VCR OIDC authentication; configures VCR as the target registry for Aspire's built-in build/push steps; resolves the pushed image tag to a digest; generates a minimal Build Output API directory; invokes `vercel deploy --prebuilt`; and verifies the resulting deployment with `vercel inspect --wait --format=json` before saving deployment state for each verified resource. The deployment summary records the deployment URL, deployment state records the VCR image digest and Build Output API version for diagnostics, and production deployments also record the deterministic `https://.vercel.app` production URL. - `aspire destroy` reads Aspire deployment state first and removes only Vercel projects that were created by this integration for the same environment/scope. Missing state or unmanaged-only state does not require Vercel CLI/auth. State is preserved if project removal fails. +## Validation coverage + +The implementation keeps Vercel-specific behavior unit-testable behind injectable services and pure helpers. Unit tests verify pipeline step registration and direct step actions, Aspire built-in build/push ordering, VCR registry annotations, linux/amd64 build target selection, project/env/destroy state transitions, generated Build Output API files, secret redaction, exact CLI argument boundaries, Docker digest parsing, Vercel deploy/inspect output parsing, compact JWT claim decoding, Vercel dotenv parsing, TypeScript AppHost publish/projection, the TypeScript start smoke when `pwsh` is available, and failure messages for unsupported Aspire concepts. + +The integration was also validated against Vercel end to end during development: Dockerfile workloads were built and pushed to VCR through Aspire's built-in pipeline, deployed with `vercel deploy --prebuilt`, verified with `vercel inspect`, called through the deployed URL, and cleaned up with destroy. + ## Prerequisites -- An Aspire workload with Dockerfile publish metadata, such as a language app resource that emits Dockerfile metadata or a project configured with `PublishAsDockerFile`. +- An Aspire workload that participates in Aspire's image build/push model, such as a .NET project, a language app resource that emits Dockerfile metadata, or a resource configured with `WithDockerfile`. - The deployed container should listen on `$PORT`; the default port is `80`. -- Vercel CLI 54.18.6 or later installed and available on `PATH`. This preview depends on `deploy --env`, `inspect --wait --timeout --format=json`, and `project remove`; older CLI versions are rejected during deploy preflight. +- Vercel CLI 54.18.6 or later installed and available on `PATH`. This preview depends on `vercel link`, `vercel pull`, `deploy --prebuilt`, `deploy --env`, `inspect --wait --timeout --format=json`, and `project remove`; older CLI versions are rejected during deploy preflight. +- Docker CLI with buildx and a running Docker daemon for Aspire's built-in image build/push and VCR digest inspection. - Vercel authentication from an existing CLI login or the `VERCEL_TOKEN` environment variable. +- Vercel project access that allows `vercel pull` to mint the `VERCEL_OIDC_TOKEN` used by VCR. - Any domains, deployment protection, or build-time environment variables configured in Vercel. ## Known limitations @@ -123,11 +161,11 @@ This preview integration intentionally supports a narrow Vercel Dockerfile deplo | Aspire concept | Preview behavior | | --- | --- | -| Dockerfile-backed compute resources | Supported through existing Aspire Dockerfile metadata and Vercel remote builds. | -| Container registries, image build, image push | Not used. Vercel uploads and builds the staged source tree. | +| Image-build compute resources | Supported through Aspire's built-in project/Dockerfile image build and push pipeline, Vercel Container Registry, digest resolution, and Build Output API prebuilt deploy. .NET projects, checked-in custom Dockerfile names, non-root Dockerfile paths, and generated Dockerfiles from language app integrations are supported. | +| Container registries, image build, image push | Uses Vercel Container Registry and the Vercel OIDC token from `vercel pull` as the deployment target registry for Aspire's built-in push steps. Resource-level `WithContainerRegistry` is rejected for Vercel-targeted resources. | | Non-secret environment variables | Passed with `vercel deploy --env KEY=value`; names must use letters, digits, and underscores and values are redacted from publish output. | | Secret parameters and connection strings | Configured as sensitive Vercel project environment variables with `vercel env add --force --sensitive`; values are supplied on stdin and redacted from publish output. Missing secret values are rejected. | -| Docker build args/secrets | Rejected. Vercel runs the Dockerfile build remotely, so configure build-time values in Vercel project settings. | +| Docker build args/secrets | Supported when the underlying Aspire image build integration supports them; Aspire's built-in build step handles the local Docker build before Vercel deploy receives only Build Output API metadata. | | Service discovery, endpoint references, `WithReference` to another resource | Supported for external HTTP(S) endpoints on workloads in the same Vercel production environment by using deterministic `https://.vercel.app` URLs. Rejected for preview/custom targets because those URLs are assigned after deployment. | | Endpoints | External HTTP and HTTPS endpoints with HTTP transports are accepted when they map to one target port. Internal endpoints, non-HTTP(S) endpoints/transports, or multiple target ports are rejected. | | Volumes, bind mounts, Aspire container files | Rejected. Include required files in the source tree or use a Vercel-supported external service for state. | @@ -135,9 +173,9 @@ This preview integration intentionally supports a narrow Vercel Dockerfile deplo | Container entrypoint overrides and Aspire command-line args | Rejected. Configure runtime command behavior through the Dockerfile/workload publish output or Vercel project settings. | | Existing linked Vercel projects | Supported for deploy. Destroy preserves projects that were linked before deploy and only deletes Aspire-created projects recorded in state. | -Managed Vercel project names are inferred from the source directory name when no `.vercel/project.json` link exists and no explicit `WithVercelProjectName` is configured. The integration slugifies inferred names to Vercel's lowercase, hyphenated project-name form before staging and deployment, and rejects duplicate project names within the same Vercel environment, including linked projects and explicitly configured names. Each Aspire resource must map to one distinct Vercel project because production endpoint references use the project-level `https://.vercel.app` URL. This preview intentionally does not yet expose resource-level alias, domain, framework/output, build-setting, deployment-protection, or per-resource target APIs; link each resource to a distinct Vercel project before deploy when you need existing provider project identities. +Managed Vercel project names are inferred from the source directory name when no `.vercel/project.json` link exists and no explicit `WithVercelProjectName` is configured. The integration slugifies inferred names to Vercel's lowercase, hyphenated project-name form before deployment and rejects duplicate project names within the same Vercel environment, including linked projects and explicitly configured names. Each Aspire resource must map to one distinct Vercel project because production endpoint references use the project-level `https://.vercel.app` URL. This preview intentionally does not yet expose resource-level alias, domain, framework/output, build-setting, deployment-protection, or per-resource target APIs; link each resource to a distinct Vercel project before deploy when you need existing provider project identities. -If the source root already contains a `vercel.json`, the integration preserves top-level URL/routing configuration that is valid in Vercel services mode and appends the generated catch-all service rewrite. Existing `services` configuration, legacy `routes`, deprecated top-level `env`/`build`/`builds`, and top-level build/runtime settings such as `framework`, `buildCommand`, or `outputDirectory` are rejected because this preview owns the generated container service shape, catch-all service routing, and AppHost-driven environment variables. +If the source root already contains a `vercel.json`, the integration reads it only for validation; the source file is not modified. Top-level Vercel build, env, routing, function, and services settings are rejected because this preview owns the generated Build Output API container function, catch-all route, and AppHost-driven environment variables. ## Additional documentation diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs index 7e1980fe4..02190e7c3 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs @@ -14,6 +14,9 @@ internal sealed class VercelCliRunner : IVercelCliRunner { public async Task RunAsync(string fileName, IReadOnlyList arguments, string? workingDirectory, CancellationToken cancellationToken, string? standardInput = null) { + // Keep all target CLI calls behind this runner so unit tests can assert exact + // executable/argument/stdin boundaries. Secrets use stdin; arguments are never + // shell-concatenated, which avoids platform quoting differences. ProcessStartInfo startInfo = new() { FileName = fileName, @@ -31,6 +34,8 @@ public async Task RunAsync(string fileName, IReadOnlyList deterministic, reviewable vercel-deployments.json with no provider mutation + // deploy prereq => validate tools/auth, create/link projects, configure VCR registry annotations + // built-in Aspire build/push => build the actual workload image and push it to VCR + // deploy => resolve the pushed tag to a linux/amd64 digest, write Build Output API metadata, + // run `vercel deploy --prebuilt`, verify with `vercel inspect`, then save state + // destroy => use saved state, not the current model, and delete only Aspire-managed projects. + // Keep provider/protocol parsing in small internal helpers so tests can assert exact behavior + // without live Vercel credentials. public const string PublishStepNamePrefix = "vercel-publish-"; public const string DeployPrereqStepNamePrefix = "vercel-deploy-prereq-"; public const string DeployStepNamePrefix = "vercel-deploy-"; @@ -33,25 +45,39 @@ internal static class VercelDeploymentStep private const int DeploymentStateSchemaVersion = 1; private const int VercelProjectNameMaxLength = 100; private const string VercelCliFileName = "vercel"; - private const string VercelDockerfileFileName = "Dockerfile.vercel"; + internal const string DockerCliFileName = "docker"; + private const string VcrRegistry = "vcr.vercel.com"; private const string VercelJsonFileName = "vercel.json"; + private const string VercelProjectFileName = "project.json"; + private const string VercelDirectoryName = ".vercel"; + private const string VercelOutputDirectoryName = "output"; + private const string VercelOidcTokenEnvironmentVariable = "VERCEL_OIDC_TOKEN"; private const string VercelContainerServiceName = "app"; + private const string PushPrereqStepName = "push-prereq"; + private const int VercelBuildOutputApiVersion = 3; private static readonly Version MinimumVercelCliVersion = new(54, 18, 6); - // These keys either bypass services mode or configure build/runtime/env behavior that - // Aspire must own so Dockerfile.vercel, endpoint refs, and secret handling stay coherent. - private static readonly string[] VercelJsonServicesModeUnsupportedKeys = + // These keys either bypass the generated Build Output API contract or configure + // routing/build/runtime/env behavior that Aspire must own so endpoint refs and + // secret handling stay coherent. + private static readonly string[] VercelJsonBuildOutputUnsupportedKeys = [ "build", "builds", "buildCommand", "devCommand", "env", + "experimentalServices", + "experimentalServicesV2", "framework", "functions", + "headers", "ignoreCommand", "installCommand", "outputDirectory", - "routes" + "redirects", + "rewrites", + "routes", + "services" ]; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) @@ -123,10 +149,71 @@ await CreateDeploymentPlanEntriesAsync( public static async Task ValidatePrerequisitesAsync(PipelineStepContext context, VercelEnvironmentResource environment) { + // This is the deploy prerequisite step, not merely static validation. It intentionally + // performs the provider reads/mutations required before Aspire's shared push-prereq + // step can validate registry metadata: project create/link, Vercel pull, VCR login, + // VCR repository ensure, and deployment target annotation setup. + var options = environment.GetVercelOptions(); + var entries = GetDeploymentEntries(context.Model, environment).ToList(); + ValidateEntries(entries); + foreach (var entry in entries) + { + await ValidateVercelJsonAsync(entry.Resource, entry.SourceRoot, context.CancellationToken).ConfigureAwait(false); + } + await ValidateCliPrerequisitesAsync(context, environment).ConfigureAwait(false); + await ValidateDockerDigestInspectionPrerequisitesAsync(context).ConfigureAwait(false); + var runner = context.Services.GetRequiredService(); + var registryClient = context.Services.GetRequiredService(); + await ValidateExistingDeploymentStateAsync(context, environment, options).ConfigureAwait(false); + var entriesByResourceName = GetDeploymentEntriesByResourceName(entries); + + foreach (var entry in entries) + { + await PrepareResourceForBuiltInImagePushAsync( + context, + environment, + options, + runner, + registryClient, + entriesByResourceName, + entry).ConfigureAwait(false); + } + } + + private static async Task ValidateDockerDigestInspectionPrerequisitesAsync(PipelineStepContext context) + { + var runner = context.Services.GetRequiredService(); + var result = await runner.RunAsync(DockerCliFileName, ["buildx", "version"], workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException("validate Docker buildx for VCR image digest inspection", DockerCliFileName, result); + } + } + public static async Task DeployAsync(PipelineStepContext context, VercelEnvironmentResource environment) + { + var options = environment.GetVercelOptions(); + var runner = context.Services.GetRequiredService(); var entries = GetDeploymentEntries(context.Model, environment).ToList(); + ValidateEntries(entries); + // Normal pipeline execution runs the prereq step first. Keep this fallback so the + // deploy delegate remains directly unit-testable and safe if invoked by a custom runner. + if (entries.Any(entry => GetPreparedDeployment(entry.Resource) is null)) + { + await ValidatePrerequisitesAsync(context, environment).ConfigureAwait(false); + } + + foreach (var entry in entries) + { + await DeployEntryAsync( + context, + environment, + options, + runner, + entry).ConfigureAwait(false); + } } public static async Task ValidateCliPrerequisitesAsync(PipelineStepContext context, VercelEnvironmentResource environment) @@ -147,8 +234,9 @@ public static async Task ValidateCliPrerequisitesAsync(PipelineStepContext conte $"Failed to determine Vercel CLI version from '{GetTrimmedOutput(versionOutput)}'. Install Vercel CLI {MinimumVercelCliVersion} or later from https://vercel.com/docs/cli."); } - // The preview relies on newer CLI behavior: deployment-scoped --env, JSON inspect - // output with --wait/--timeout, project removal, and services-mode Dockerfile deploys. + // The preview relies on newer CLI behavior: project-scoped link/pull, + // prebuilt deploys, deployment-scoped --env, JSON inspect output with + // --wait/--timeout, and project removal. if (version < MinimumVercelCliVersion) { throw new DistributedApplicationException( @@ -171,29 +259,198 @@ public static async Task ValidateCliPrerequisitesAsync(PipelineStepContext conte } } - public static async Task DeployAsync(PipelineStepContext context, VercelEnvironmentResource environment) + private static async Task DeployEntryAsync( + PipelineStepContext context, + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + IVercelCliRunner runner, + VercelDeploymentEntry entry) { - var options = environment.GetVercelOptions(); - var runner = context.Services.GetRequiredService(); - var entries = GetDeploymentEntries(context.Model, environment).ToList(); + var preparedDeployment = GetPreparedDeployment(entry.Resource); + if (preparedDeployment is null) + { + throw new DistributedApplicationException($"Resource '{entry.Resource.Name}' was not prepared for Vercel deployment. Run the '{DeployPrereqStepNamePrefix}{environment.Name}' pipeline step before deploying."); + } - ValidateEntries(entries); - await ValidateExistingDeploymentStateAsync(context, environment, options).ConfigureAwait(false); - var entriesByResourceName = GetDeploymentEntriesByResourceName(entries); + // At this point Aspire's built-in build/push steps have pushed the VCR tag selected + // in prereq. Vercel requires the immutable linux/amd64 manifest digest in the Build + // Output API handler, so deploy resolves the tag after push instead of trusting a tag. + var image = await ResolvePushedImageDigestAsync(context, runner, preparedDeployment).ConfigureAwait(false); + + var deploymentResult = await DeployPrebuiltOutputAsync( + context, + runner, + options, + preparedDeployment.Entry, + preparedDeployment.ProjectContext, + image).ConfigureAwait(false); + + string? productionUrl = GetProductionUrl(options, preparedDeployment.ProjectLink.ProjectName); + var stateEntry = CreateSuccessfulDeploymentStateEntry( + preparedDeployment.Entry, + preparedDeployment.ProjectLink, + preparedDeployment.ProjectContext, + deploymentResult, + image, + preparedDeployment.ManagedByAspire, + productionUrl); + + // Persist each verified resource independently. A later resource can fail after + // Vercel has already created earlier projects, and destroy must still know which + // managed projects are safe to remove or retry. + await SaveDeploymentStateEntryAsync(context, environment, options, stateEntry).ConfigureAwait(false); + + AddDeploymentSummary(context, entry.Resource.Name, deploymentResult, productionUrl); + } + + private static async Task EnsureManagedProjectAndSaveInitialStateAsync( + PipelineStepContext context, + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + IVercelCliRunner runner, + VercelDeploymentEntry preparedEntry, + string sourceRoot, + VercelProjectLink projectLink, + PreviousVercelDeployment? previousDeployment) + { + // Create/validate the project before env configuration and VCR auth. Deploy can + // take --project; env configuration and `vercel pull` link only a scratch directory + // because the project-scoped env command has no --project option. + await EnsureManagedProjectAsync(context, runner, options, preparedEntry).ConfigureAwait(false); + + // Record the managed project as soon as Vercel accepts it. Build, env + // configuration, or deploy can still fail afterward, and destroy must be able + // to clean up that partially-created provider object. + await SaveDeploymentStateEntryAsync(context, environment, options, new( + preparedEntry.Resource.Name, + projectLink.ProjectName, + projectLink.ProjectId, + DeploymentId: null, + DeploymentUrl: null, + sourceRoot, + ManagedByAspire: true) + { + ProductionUrl = GetProductionUrl(options, projectLink.ProjectName), + ProjectEnvironmentVariables = previousDeployment?.Entry.ProjectEnvironmentVariables ?? [] + }).ConfigureAwait(false); + } + + public static void ConfigurePipeline(PipelineConfigurationContext context, VercelEnvironmentResource environment) + { + // Pipeline ordering is the main integration point with Aspire's built-in container + // support. Vercel prereq must happen before push-prereq/build/push so the resource has + // a VCR container registry annotation and linux/amd64 build target; Vercel deploy must + // then wait for the resource-specific push step. + var entries = GetDeploymentEntries(context.Model, environment).ToArray(); + if (entries.Length == 0) + { + return; + } + + string prereqStepName = $"{DeployPrereqStepNamePrefix}{environment.Name}"; + var deploySteps = context.GetSteps(environment, "vercel-deploy").ToArray(); + var pushPrereqSteps = context.Steps + .Where(static step => string.Equals(step.Name, PushPrereqStepName, StringComparison.Ordinal)) + .ToArray(); foreach (var entry in entries) { - var preparedEntry = await PrepareDeploymentEntryAsync(context, entry).ConfigureAwait(false); - // A checked-in .vercel/project.json means the user linked an existing provider project, - // so deploy can target it but destroy must not claim ownership of it. - bool managedByAspire = !HasVercelProjectLinkFile(entry.SourceRoot); - if (managedByAspire) + EnsureVercelImagePushOptionsCallback(entry.Resource); + + var buildSteps = context.GetSteps(entry.Resource, WellKnownPipelineTags.BuildCompute); + foreach (var buildStep in buildSteps) + { + AddUnique(buildStep.DependsOnSteps, prereqStepName); + AddUnique(buildStep.RequiredBySteps, WellKnownPipelineSteps.Deploy); + } + + var pushSteps = context.GetSteps(entry.Resource, WellKnownPipelineTags.PushContainerImage).ToArray(); + foreach (var pushStep in pushSteps) + { + AddUnique(pushStep.DependsOnSteps, prereqStepName); + AddUnique(pushStep.RequiredBySteps, WellKnownPipelineSteps.Deploy); + } + + // Aspire's global push prerequisite validates that every pushed image has a + // registry. Vercel discovers the project-owned VCR registry through `vercel pull`, + // so that validation must run after the Vercel prereq has annotated the resources. + foreach (var pushPrereqStep in pushPrereqSteps) + { + AddUnique(pushPrereqStep.DependsOnSteps, prereqStepName); + } + + foreach (var deployStep in deploySteps) { - // Create/validate the project before env configuration. `vercel env add` is - // project-scoped and does not accept --project, while deploy can pass --project. - await EnsureManagedProjectAsync(context, runner, options, preparedEntry).ConfigureAwait(false); + foreach (var pushStep in pushSteps) + { + AddUnique(deployStep.DependsOnSteps, pushStep.Name); + } } + } + } + + private static async Task PrepareResourceForBuiltInImagePushAsync( + PipelineStepContext context, + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + IVercelCliRunner runner, + IVercelContainerRegistryClient registryClient, + IReadOnlyDictionary entriesByResourceName, + VercelDeploymentEntry entry) + { + var preparedEntry = await PrepareDeploymentEntryAsync(context, entry).ConfigureAwait(false); + var projectLink = GetVercelProjectLink(preparedEntry); + var previousDeployment = await GetPreviousDeploymentStateEntryAsync( + context, + environment, + options, + preparedEntry.Resource.Name, + projectLink.ProjectName).ConfigureAwait(false); + // A checked-in .vercel/project.json means the user linked an existing provider project, + // so deploy can target it but destroy must not claim ownership of it. + bool managedByAspire = !HasVercelProjectLinkFile(entry.SourceRoot); + + // Managed projects are recorded before the image build begins. If Docker build, + // project-env configuration, or VCR push fails later, destroy/retry still has enough + // state to clean up the provider project and any tracked project env vars. + if (managedByAspire) + { + await EnsureManagedProjectAndSaveInitialStateAsync( + context, + environment, + options, + runner, + preparedEntry, + entry.SourceRoot, + projectLink, + previousDeployment).ConfigureAwait(false); + } + + var projectContext = await PreparePulledProjectContextAsync( + context, + runner, + options, + preparedEntry, + entriesByResourceName, + previousDeployment).ConfigureAwait(false); + await LoginToVcrAsync(context, runner, projectContext.PulledProject.OidcToken, projectContext.OidcClaims).ConfigureAwait(false); + await registryClient.EnsureRepositoryAsync(projectContext.PulledProject.OidcToken, projectContext.OidcClaims, VercelContainerServiceName, context.CancellationToken).ConfigureAwait(false); + + AddVcrDeploymentAnnotations(environment, preparedEntry, projectLink, projectContext, managedByAspire); + } + + internal static async Task PreparePulledProjectContextAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry preparedEntry, + IReadOnlyDictionary entriesByResourceName, + PreviousVercelDeployment? previousDeployment = null) + { + string projectLinkDirectory = await PrepareProjectEnvironmentDirectoryAsync(context, runner, options, preparedEntry).ConfigureAwait(false); + try + { // Use Aspire's unprocessed values so endpoint references and secrets keep their // graph meaning until Vercel-specific deployment translation happens. var environmentConfiguration = await GetVercelEnvironmentConfigurationAsync( @@ -204,6 +461,7 @@ public static async Task DeployAsync(PipelineStepContext context, VercelEnvironm entriesByResourceName, resolveProjectEnvironmentVariableValues: true, context.CancellationToken).ConfigureAwait(false); + // Vercel resolves project env vars during the provider-side build/runtime. // Configure secret-bearing values before deploy; non-secret per-deployment // values remain on `vercel deploy --env`. @@ -212,132 +470,286 @@ await ConfigureProjectEnvironmentVariablesAsync( runner, options, preparedEntry, - environmentConfiguration.ProjectEnvironmentVariables).ConfigureAwait(false); + projectLinkDirectory, + environmentConfiguration.ProjectEnvironmentVariables, + previousDeployment).ConfigureAwait(false); - string[] arguments = BuildDeployArguments( - options, - preparedEntry.SourceRoot, - GetVercelProjectName(preparedEntry), - environmentConfiguration.DeploymentEnvironmentVariables); + // The VCR image is not enough for deployment. Vercel deploy consumes Build + // Output API metadata tied to a project, and VCR auth is minted by `vercel pull` + // for that linked project. Keep that link/pull in scratch space so the source + // root never receives provider metadata or pulled env files. + var pulledProject = await PullProjectSettingsAsync(context, runner, options, preparedEntry, projectLinkDirectory).ConfigureAwait(false); + var oidcClaims = DecodeUnvalidatedOidcClaims(pulledProject.OidcToken); - var result = await runner.RunAsync(VercelCliFileName, arguments, preparedEntry.SourceRoot, context.CancellationToken).ConfigureAwait(false); + return new(environmentConfiguration, pulledProject, oidcClaims); + } + finally + { + // `vercel pull` can write provider-owned env/config files into the linked + // directory. They may contain project secrets unrelated to the AppHost, so the + // scratch link is never copied, logged, stored in state, or left on disk. + DeleteDirectoryIfExists(projectLinkDirectory); + } + } - if (!result.Succeeded) - { - throw CreateCliException($"deploy resource '{entry.Resource.Name}' to Vercel", VercelCliFileName, result); - } + private static async Task DeployPrebuiltOutputAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry, + VercelPulledProjectContext projectContext, + VercelImageReference image) + { + // The generated Build Output API tree is the provider-owned deploy artifact. + // It points at the immutable VCR digest and is written to temp/output storage so + // `vercel deploy --prebuilt` uploads metadata only, not a staged source tree. + await WriteBuildOutputAsync(entry, projectContext.PulledProject, image.Reference, context.CancellationToken).ConfigureAwait(false); - var deploymentResult = GetDeploymentResult(result.StandardOutput); - await VerifyDeploymentAsync(context, runner, options, entry.Resource.Name, deploymentResult).ConfigureAwait(false); + string[] deployArguments = BuildDeployArguments( + options, + entry.DeployDirectory, + GetVercelProjectOption(entry), + projectContext.EnvironmentConfiguration.DeploymentEnvironmentVariables); - var projectLink = GetVercelProjectLink(preparedEntry); - string? productionUrl = GetProductionUrl(options, projectLink.ProjectName); + var result = await runner.RunAsync(VercelCliFileName, deployArguments, entry.DeployDirectory, context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException($"deploy prebuilt resource '{entry.Resource.Name}' to Vercel", VercelCliFileName, result); + } - var stateEntry = new VercelDeploymentStateEntry( - entry.Resource.Name, - projectLink.ProjectName, - projectLink.ProjectId, - deploymentResult.DeploymentId, - deploymentResult.DeploymentUrl, - entry.SourceRoot, - managedByAspire) - { - ProductionUrl = productionUrl - }; + var deploymentResult = GetDeploymentResult(result.StandardOutput); + await VerifyDeploymentAsync(context, runner, options, entry.Resource.Name, deploymentResult).ConfigureAwait(false); + + return deploymentResult; + } + + private static void AddVcrDeploymentAnnotations( + VercelEnvironmentResource environment, + VercelDeploymentEntry entry, + VercelProjectLink projectLink, + VercelPulledProjectContext projectContext, + bool managedByAspire) + { + EnsureVercelImagePushOptionsCallback(entry.Resource); + + var claims = projectContext.OidcClaims; + if (string.IsNullOrWhiteSpace(claims.Owner) + || string.IsNullOrWhiteSpace(claims.Project)) + { + throw new DistributedApplicationException("The Vercel OIDC token did not include the owner and project claims required to construct a VCR image reference."); + } + + RemoveAnnotations(entry.Resource); + RemoveAnnotations( + entry.Resource, + annotation => ReferenceEquals(annotation.DeploymentTarget, environment)); + + string repository = $"{claims.Owner}/{claims.Project}"; + string tag = $"aspire-{Guid.NewGuid():N}"; + string taggedImageReference = $"{VcrRegistry}/{repository}/{VercelContainerServiceName}:{tag}"; + var registry = new ContainerRegistryResource( + $"{environment.Name}-{entry.Resource.Name}-vcr", + ReferenceExpression.Create($"{VcrRegistry}"), + ReferenceExpression.Create($"{repository}")); + + entry.Resource.Annotations.Add(new DeploymentTargetAnnotation(environment) + { + ComputeEnvironment = entry.Resource.GetComputeEnvironment() ?? environment, + ContainerRegistry = registry + }); + + entry.Resource.Annotations.Add(new VercelPreparedDeploymentAnnotation( + entry, + projectLink, + projectContext, + managedByAspire, + VercelContainerServiceName, + tag, + taggedImageReference)); + } - // Persist each verified resource independently. A later resource can fail after - // Vercel has already created earlier projects, and destroy must still know which - // managed projects are safe to remove or retry. - await SaveDeploymentStateEntryAsync(context, environment, options, stateEntry).ConfigureAwait(false); + private static void EnsureVercelImagePushOptionsCallback(IResource resource) + { + if (resource.Annotations.OfType().Any()) + { + return; + } - context.Summary.Add($"{entry.Resource.Name} Vercel deployment", deploymentResult.DeploymentUrl); - if (productionUrl is not null) + resource.Annotations.Add(new VercelImagePushOptionsCallbackAnnotation()); + resource.Annotations.Add(new ContainerBuildOptionsCallbackAnnotation(context => + { + // Vercel's container runtime requires linux/amd64. Force the shared Aspire + // build path to produce that platform even when the AppHost runs on ARM hosts. + context.TargetPlatform = ContainerTargetPlatform.LinuxAmd64; + })); + resource.Annotations.Add(new ContainerImagePushOptionsCallbackAnnotation(context => + { + var preparedDeployment = GetPreparedDeployment(context.Resource); + if (preparedDeployment is null) { - context.Summary.Add($"{entry.Resource.Name} Vercel production URL", productionUrl); + throw new DistributedApplicationException($"Resource '{context.Resource.Name}' was not prepared with Vercel Container Registry image push settings."); } - } + + context.Options.RemoteImageName = preparedDeployment.RemoteImageName; + context.Options.RemoteImageTag = preparedDeployment.RemoteImageTag; + })); } - internal static string[] BuildDeployArguments(VercelEnvironmentOptionsAnnotation options, VercelDeploymentEntry entry) - => BuildDeployArguments(options, entry.SourceRoot, GetVercelProjectName(entry), environmentVariables: []); + private static VercelPreparedDeploymentAnnotation? GetPreparedDeployment(IResource resource) + => resource.Annotations.OfType().LastOrDefault(); - internal static string[] BuildDestroyProjectArguments(VercelEnvironmentOptionsAnnotation options, string projectName) - { - List arguments = []; + private static async Task ResolvePushedImageDigestAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelPreparedDeploymentAnnotation preparedDeployment) + { + // Re-login before inspection for the same project-scoped VCR-token reason as push: + // Docker credentials are keyed by vcr.vercel.com, while the OIDC token is scoped to + // one Vercel project/repository. + await LoginToVcrAsync( + runner, + preparedDeployment.ProjectContext.PulledProject.OidcToken, + preparedDeployment.ProjectContext.OidcClaims, + context.CancellationToken).ConfigureAwait(false); - if (!string.IsNullOrWhiteSpace(options.Scope)) + string[] arguments = BuildDockerInspectDigestArguments(preparedDeployment.TaggedImageReference); + var result = await runner.RunAsync(DockerCliFileName, arguments, workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded) { - arguments.Add("--scope"); - arguments.Add(options.Scope); + throw CreateCliException($"resolve pushed VCR image digest for resource '{preparedDeployment.Entry.Resource.Name}'", DockerCliFileName, result); } - arguments.Add("project"); - arguments.Add("remove"); - arguments.Add(projectName); + string digest = GetDockerImageDigest(result.StandardOutput); + string digestReference = preparedDeployment.TaggedImageReference[..preparedDeployment.TaggedImageReference.LastIndexOf(':')] + $"@{digest}"; + return new(digestReference, digest); + } - return [.. arguments]; + private static VercelDeploymentStateEntry CreateSuccessfulDeploymentStateEntry( + VercelDeploymentEntry entry, + VercelProjectLink projectLink, + VercelPulledProjectContext projectContext, + VercelDeploymentResult deploymentResult, + VercelImageReference image, + bool managedByAspire, + string? productionUrl) + // State stores ownership, provider IDs/URLs, image digest, and env var names only. + // Never persist secret values or temp project-link files from `vercel pull`. + => new( + entry.Resource.Name, + projectLink.ProjectName, + projectLink.ProjectId ?? projectContext.PulledProject.ProjectId, + deploymentResult.DeploymentId, + deploymentResult.DeploymentUrl, + entry.SourceRoot, + managedByAspire) + { + ProductionUrl = productionUrl, + VcrImageDigest = image.Digest, + BuildOutputApiVersion = VercelBuildOutputApiVersion, + ProjectEnvironmentVariables = [.. projectContext.EnvironmentConfiguration.ProjectEnvironmentVariables + .Select(static variable => variable.Key) + .Order(StringComparer.Ordinal)] + }; + + private static void AddDeploymentSummary( + PipelineStepContext context, + string resourceName, + VercelDeploymentResult deploymentResult, + string? productionUrl) + { + context.Summary.Add($"{resourceName} Vercel deployment", deploymentResult.DeploymentUrl); + if (productionUrl is not null) + { + context.Summary.Add($"{resourceName} Vercel production URL", productionUrl); + } } - internal static string[] BuildAddProjectArguments(VercelEnvironmentOptionsAnnotation options, string projectName) + private static void AddUnique(ICollection values, string value) { - List arguments = []; + if (!values.Contains(value, StringComparer.Ordinal)) + { + values.Add(value); + } + } - if (!string.IsNullOrWhiteSpace(options.Scope)) + private static void RemoveAnnotations(IResource resource, Func? predicate = null) + where TAnnotation : IResourceAnnotation + { + foreach (var annotation in resource.Annotations.OfType().Where(annotation => predicate?.Invoke(annotation) ?? true).ToArray()) { - arguments.Add("--scope"); - arguments.Add(options.Scope); + resource.Annotations.Remove(annotation); } + } + + internal static string[] BuildDeployArguments(VercelEnvironmentOptionsAnnotation options, VercelDeploymentEntry entry) + => BuildDeployArguments(options, GetDeployDirectory(entry), GetVercelProjectOption(entry), environmentVariables: []); + + // Keep CLI argument construction as pure array-returning helpers. Tests assert exact + // argument boundaries so Vercel quirks such as `env add` requiring --cwd, not --project, + // cannot regress into shell-quoted or source-mutating command strings. + internal static string[] BuildDockerInspectDigestArguments(string imageReference) + => ["buildx", "imagetools", "inspect", "--format", "{{json .Manifest}}", imageReference]; + + internal static string[] BuildDestroyProjectArguments(VercelEnvironmentOptionsAnnotation options, string projectName) + { + List arguments = []; arguments.Add("project"); - arguments.Add("add"); + arguments.Add("remove"); arguments.Add(projectName); + AddOptionalScopeArgument(arguments, options); return [.. arguments]; } - internal static string[] BuildLinkProjectArguments( + internal static string[] BuildListProjectEnvironmentVariablesArguments( VercelEnvironmentOptionsAnnotation options, - string sourceRoot, - string projectName) + string projectLinkDirectory, + string targetEnvironment) { List arguments = []; - if (!string.IsNullOrWhiteSpace(options.Scope)) - { - arguments.Add("--scope"); - arguments.Add(options.Scope); - } - + arguments.Add("env"); + arguments.Add("ls"); + arguments.Add(targetEnvironment); + AddOptionalScopeArgument(arguments, options); arguments.Add("--cwd"); - arguments.Add(sourceRoot); - arguments.Add("link"); - arguments.Add("--yes"); - arguments.Add("--project"); + arguments.Add(projectLinkDirectory); + arguments.Add("--format=json"); + + return [.. arguments]; + } + + internal static string[] BuildAddProjectArguments(VercelEnvironmentOptionsAnnotation options, string projectName) + { + List arguments = []; + + arguments.Add("project"); + arguments.Add("add"); arguments.Add(projectName); + AddOptionalScopeArgument(arguments, options); return [.. arguments]; } internal static string[] BuildAddProjectEnvironmentVariableArguments( VercelEnvironmentOptionsAnnotation options, - string sourceRoot, + string projectLinkDirectory, string name, string targetEnvironment) { List arguments = []; - if (!string.IsNullOrWhiteSpace(options.Scope)) - { - arguments.Add("--scope"); - arguments.Add(options.Scope); - } - - arguments.Add("--cwd"); - arguments.Add(sourceRoot); arguments.Add("env"); arguments.Add("add"); arguments.Add(name); arguments.Add(targetEnvironment); + AddOptionalScopeArgument(arguments, options); + // Vercel env commands must run inside a linked project directory. Use the + // Aspire-owned scratch link instead of the source root so provider metadata + // and pulled env files never mutate the user's checkout. + arguments.Add("--cwd"); + arguments.Add(projectLinkDirectory); arguments.Add("--yes"); arguments.Add("--force"); arguments.Add("--sensitive"); @@ -345,38 +757,114 @@ internal static string[] BuildAddProjectEnvironmentVariableArguments( return [.. arguments]; } - internal static string[] BuildInspectDeploymentArguments(VercelEnvironmentOptionsAnnotation options, string deploymentUrl) + internal static string[] BuildRemoveProjectEnvironmentVariableArguments( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string name, + string targetEnvironment) { List arguments = []; - if (!string.IsNullOrWhiteSpace(options.Scope)) - { - arguments.Add("--scope"); - arguments.Add(options.Scope); - } - - arguments.Add("inspect"); - arguments.Add(deploymentUrl); - arguments.Add("--wait"); - arguments.Add("--timeout"); - arguments.Add("120s"); - arguments.Add("--format=json"); + arguments.Add("env"); + arguments.Add("rm"); + arguments.Add(name); + arguments.Add(targetEnvironment); + AddOptionalScopeArgument(arguments, options); + arguments.Add("--cwd"); + arguments.Add(projectLinkDirectory); + arguments.Add("--yes"); return [.. arguments]; } - internal static string[] BuildValidateScopeArguments(VercelEnvironmentOptionsAnnotation options) + internal static string[] BuildLinkProjectArguments( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string projectNameOrId) { List arguments = []; + arguments.Add("link"); + AddOptionalScopeArgument(arguments, options); + arguments.Add("--cwd"); + arguments.Add(projectLinkDirectory); + arguments.Add("--yes"); + arguments.Add("--project"); + arguments.Add(projectNameOrId); + + return [.. arguments]; + } + + internal static string[] BuildPullProjectSettingsArguments( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string targetEnvironment) + { + List arguments = []; + + arguments.Add("pull"); + AddOptionalScopeArgument(arguments, options); + arguments.Add("--cwd"); + arguments.Add(projectLinkDirectory); + arguments.Add("--yes"); + arguments.Add("--environment"); + arguments.Add(targetEnvironment); + + return [.. arguments]; + } + + internal static string[] BuildDockerLoginArguments(string username) + => ["login", VcrRegistry, "--username", username, "--password-stdin"]; + + private static void AddOptionalProjectArgument(List arguments, string? projectNameOrId) + { + if (!string.IsNullOrWhiteSpace(projectNameOrId)) + { + arguments.Add("--project"); + arguments.Add(projectNameOrId); + } + } + + private static void AddOptionalScopeArgument(List arguments, VercelEnvironmentOptionsAnnotation options) + { if (!string.IsNullOrWhiteSpace(options.Scope)) { arguments.Add("--scope"); arguments.Add(options.Scope); } + } + + internal static string[] BuildInspectDeploymentArguments(VercelEnvironmentOptionsAnnotation options, string deploymentUrl) + { + List arguments = []; + + arguments.Add("inspect"); + arguments.Add(deploymentUrl); + AddOptionalScopeArgument(arguments, options); + arguments.Add("--wait"); + arguments.Add("--timeout"); + arguments.Add("120s"); + arguments.Add("--format=json"); + + return [.. arguments]; + } + + internal static string[] BuildValidateScopeArguments(VercelEnvironmentOptionsAnnotation options) + => BuildListProjectsArguments(options); + + internal static string[] BuildListProjectsArguments(VercelEnvironmentOptionsAnnotation options, string? filter = null) + { + List arguments = []; arguments.Add("project"); arguments.Add("ls"); + AddOptionalScopeArgument(arguments, options); + if (!string.IsNullOrWhiteSpace(filter)) + { + arguments.Add("--filter"); + arguments.Add(filter); + } + arguments.Add("--format=json"); return [.. arguments]; @@ -437,8 +925,13 @@ public static async Task DestroyAsync(PipelineStepContext context, VercelEnviron .Distinct(StringComparer.Ordinal) .Order(StringComparer.Ordinal) .ToArray(); + var linkedDeploymentsWithEnvironmentVariables = state.Deployments + .Where(static deployment => !deployment.ManagedByAspire && deployment.ProjectEnvironmentVariables.Length > 0) + .OrderBy(static deployment => deployment.ProjectName, StringComparer.Ordinal) + .ThenBy(static deployment => deployment.ResourceName, StringComparer.Ordinal) + .ToArray(); - if (projects.Length == 0) + if (projects.Length == 0 && linkedDeploymentsWithEnvironmentVariables.Length == 0) { // Linked .vercel projects are valid deploy targets but remain externally owned, // so clearing state is safer than deleting provider projects the user brought. @@ -452,23 +945,48 @@ public static async Task DestroyAsync(PipelineStepContext context, VercelEnviron await ValidateCliPrerequisitesAsync(context, environment).ConfigureAwait(false); var runner = context.Services.GetRequiredService(); - foreach (string projectName in projects) + foreach (var deployment in linkedDeploymentsWithEnvironmentVariables) { - string[] arguments = BuildDestroyProjectArguments(options, projectName); - var result = await runner.RunAsync(VercelCliFileName, arguments, workingDirectory: null, context.CancellationToken, standardInput: "y\n").ConfigureAwait(false); + await RemoveLinkedProjectEnvironmentVariablesAsync( + context, + runner, + options, + environment, + deployment, + GetVercelProjectEnvironmentName(state)).ConfigureAwait(false); + } - if (!result.Succeeded) - { - if (!IsMissingProjectResult(result, projectName)) - { - throw CreateCliException($"destroy Vercel project '{projectName}'", VercelCliFileName, result); - } + if (projects.Length == 0) + { + context.Summary.Add("Vercel destroy", $"No Aspire-managed Vercel deployments were found for environment '{environment.Name}'."); + await stateManager.DeleteSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); + return; + } + foreach (string projectName in projects) + { + if (!await ProjectExistsAsync(context, runner, options, projectName).ConfigureAwait(false)) + { context.Summary.Add("Vercel project already absent", projectName); } else { - context.Summary.Add("Vercel project removed", projectName); + string[] arguments = BuildDestroyProjectArguments(options, projectName); + var result = await runner.RunAsync(VercelCliFileName, arguments, workingDirectory: null, context.CancellationToken, standardInput: "y\n").ConfigureAwait(false); + + if (!result.Succeeded) + { + if (await ProjectExistsAsync(context, runner, options, projectName).ConfigureAwait(false)) + { + throw CreateCliException($"destroy Vercel project '{projectName}'", VercelCliFileName, result); + } + + context.Summary.Add("Vercel project already absent", projectName); + } + else + { + context.Summary.Add("Vercel project removed", projectName); + } } state = RemoveManagedProjectFromDeploymentState(state, projectName); @@ -503,6 +1021,9 @@ internal static async Task BuildDeployArgumentsAsync( IReadOnlyList entries, CancellationToken cancellationToken) { + // Publish plans must be useful without Vercel credentials or secret resolution. + // Secret-bearing values are reduced to names/placeholders here; deploy resolves them + // only when they are sent to Vercel's project env store over stdin. var entriesByResourceName = GetDeploymentEntriesByResourceName(entries); var environmentConfiguration = await GetVercelEnvironmentConfigurationAsync( executionContext, @@ -513,7 +1034,7 @@ internal static async Task BuildDeployArgumentsAsync( resolveProjectEnvironmentVariableValues: false, cancellationToken).ConfigureAwait(false); - return BuildDeployArguments(options, entry.SourceRoot, GetVercelProjectName(entry), environmentConfiguration.DeploymentEnvironmentVariables); + return BuildDeployArguments(options, GetDeployDirectory(entry), GetVercelProjectOption(entry), environmentConfiguration.DeploymentEnvironmentVariables); } private static async Task CreateDeploymentPlanEntriesAsync( @@ -561,260 +1082,750 @@ private static async Task GetVercelEnvironmentCo .BuildAsync(executionContext, logger, cancellationToken) .ConfigureAwait(false); - if (executionConfiguration.Exception is not null) + if (executionConfiguration.Exception is not null) + { + throw new DistributedApplicationException($"Failed to process deployment configuration for resource '{entry.Resource.Name}'.", executionConfiguration.Exception); + } + + ValidateUnsupportedRuntimeConfiguration(entry.Resource, executionConfiguration); + + var environmentVariables = await GetVercelEnvironmentConfigurationAsync( + entry.Resource, + options, + executionConfiguration, + entriesByResourceName, + resolveProjectEnvironmentVariableValues, + cancellationToken).ConfigureAwait(false); + + return environmentVariables; + } + + private static string[] BuildDeployArguments( + VercelEnvironmentOptionsAnnotation options, + string deployDirectory, + string? projectNameOrId, + IReadOnlyList> environmentVariables) + { + List arguments = []; + + arguments.Add("deploy"); + AddOptionalScopeArgument(arguments, options); + arguments.Add("--cwd"); + arguments.Add(deployDirectory); + AddOptionalProjectArgument(arguments, projectNameOrId); + arguments.Add("--prebuilt"); + arguments.Add("--yes"); + + if (options.Production) + { + arguments.Add("--prod"); + } + + if (!string.IsNullOrWhiteSpace(options.Target)) + { + arguments.Add("--target"); + arguments.Add(options.Target); + } + + foreach (var environmentVariable in environmentVariables.OrderBy(static variable => variable.Key, StringComparer.Ordinal)) + { + arguments.Add("--env"); + arguments.Add($"{environmentVariable.Key}={environmentVariable.Value}"); + } + + return [.. arguments]; + } + + private static string BuildDisplayDeployCommand( + VercelEnvironmentOptionsAnnotation options, + string resourceName, + IReadOnlyList> environmentVariables) + { + // The plan should explain the command shape without leaking concrete source roots, + // project names, or environment values that may be machine- or account-specific. + var displayEnvironmentVariables = environmentVariables + .Select(static environmentVariable => new KeyValuePair(environmentVariable.Key, "")) + .ToArray(); + + string displayImage = $"vcr.vercel.com///{VercelContainerServiceName}:"; + string displayDeployDirectory = $"<{resourceName}-build-output>"; + string displayProject = $"<{resourceName}-vercel-project>"; + return $"vercel pull --cwd <{resourceName}-vercel-project-link> --yes --environment {GetVercelProjectEnvironmentName(options)} && aspire build/push {resourceName} -> {displayImage} && docker {string.Join(" ", BuildDockerInspectDigestArguments(displayImage))} && vercel {string.Join(" ", BuildDeployArguments(options, displayDeployDirectory, displayProject, displayEnvironmentVariables))}"; + } + + private static async Task ValidateExistingDeploymentStateAsync( + PipelineStepContext context, + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options) + { + var stateManager = context.Services.GetRequiredService(); + var stateSection = await stateManager.AcquireSectionAsync(GetStateSectionName(environment), context.CancellationToken).ConfigureAwait(false); + var existingState = ReadDeploymentState(stateSection); + if (existingState is not null) + { + ValidateDeploymentState(environment, options, existingState); + } + } + + private static async Task GetPreviousDeploymentStateEntryAsync( + PipelineStepContext context, + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + string resourceName, + string projectName) + { + var stateManager = context.Services.GetRequiredService(); + var stateSection = await stateManager.AcquireSectionAsync(GetStateSectionName(environment), context.CancellationToken).ConfigureAwait(false); + var existingState = ReadDeploymentState(stateSection); + if (existingState is null) + { + return null; + } + + ValidateDeploymentState(environment, options, existingState); + var entry = existingState.Deployments.FirstOrDefault(deployment => + string.Equals(deployment.ResourceName, resourceName, StringComparison.Ordinal) + && string.Equals(deployment.ProjectName, projectName, StringComparison.Ordinal)); + + return entry is null + ? null + : new(entry, GetVercelProjectEnvironmentName(existingState)); + } + + private static async Task SaveDeploymentStateEntryAsync( + PipelineStepContext context, + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentStateEntry deployment) + { + var stateManager = context.Services.GetRequiredService(); + var stateSection = await stateManager.AcquireSectionAsync(GetStateSectionName(environment), context.CancellationToken).ConfigureAwait(false); + var existingState = ReadDeploymentState(stateSection); + var state = existingState is null + ? CreateDeploymentState(environment, options, [deployment]) + : MergeDeploymentState(environment, options, existingState, deployment); + + stateSection.SetValue(JsonSerializer.Serialize(state, JsonOptions)); + + await stateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); + } + + private static VercelDeploymentState CreateDeploymentState( + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentStateEntry[] deployments) + => new( + DeploymentStateSchemaVersion, + environment.Name, + NormalizeScope(options.Scope), + NormalizeTarget(options.Target), + options.Production, + deployments); + + private static VercelDeploymentState MergeDeploymentState( + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentState existingState, + VercelDeploymentStateEntry deployment) + { + ValidateDeploymentState(environment, options, existingState); + + return CreateDeploymentState( + environment, + options, + [ + .. existingState.Deployments.Where(existing => + !string.Equals(existing.ResourceName, deployment.ResourceName, StringComparison.Ordinal) + || !string.Equals(existing.ProjectName, deployment.ProjectName, StringComparison.Ordinal)), + deployment + ]); + } + + private static VercelDeploymentState? ReadDeploymentState(DeploymentStateSection stateSection) + { + // DeploymentStateSection storage shape has changed across Aspire builds. Accept the + // known wrappers so destroy can still clean up projects created by an older CLI. + if (stateSection.Data.TryGetPropertyValue("value", out JsonNode? value) + && value is not null) + { + return DeserializeDeploymentState(value); + } + + value = stateSection.Data.FirstOrDefault().Value; + if (value is not null) + { + return DeserializeDeploymentState(value); + } + + if (stateSection.Data.ContainsKey("schemaVersion")) + { + return stateSection.Data.Deserialize(JsonOptions); + } + + return null; + } + + private static VercelDeploymentState? DeserializeDeploymentState(JsonNode value) + { + return value.GetValueKind() == JsonValueKind.String + ? JsonSerializer.Deserialize(value.GetValue(), JsonOptions) + : value.Deserialize(JsonOptions); + } + + private static void ValidateDeploymentState( + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentState state) + { + if (state.SchemaVersion != DeploymentStateSchemaVersion) + { + throw new DistributedApplicationException($"Vercel deployment state for environment '{environment.Name}' uses unsupported schema version '{state.SchemaVersion}'. Redeploy the environment before destroying it."); + } + + if (!string.Equals(state.Environment, environment.Name, StringComparison.Ordinal)) + { + throw new DistributedApplicationException($"Vercel deployment state for environment '{state.Environment}' cannot be used to destroy environment '{environment.Name}'."); + } + + string? configuredScope = NormalizeScope(options.Scope); + if (!string.Equals(state.Scope, configuredScope, StringComparison.Ordinal)) + { + string stateScope = string.IsNullOrWhiteSpace(state.Scope) ? "" : state.Scope; + string requestedScope = string.IsNullOrWhiteSpace(configuredScope) ? "" : configuredScope; + throw new DistributedApplicationException($"Vercel deployment state for environment '{environment.Name}' was created for scope '{stateScope}', but destroy is configured for scope '{requestedScope}'. Use the same Vercel scope that created the deployment state."); + } + } + + private static string? NormalizeScope(string? scope) + => string.IsNullOrWhiteSpace(scope) ? null : scope; + + private static string? NormalizeTarget(string? target) + => string.IsNullOrWhiteSpace(target) ? null : target; + + private static string? GetProductionUrl(VercelEnvironmentOptionsAnnotation options, string projectName) + => options.Production ? $"https://{projectName}.vercel.app" : null; + + private static string GetDeployDirectory(VercelDeploymentEntry entry) + => string.IsNullOrWhiteSpace(entry.DeployDirectory) ? entry.SourceRoot : entry.DeployDirectory; + + private static VercelDeploymentState RemoveManagedProjectFromDeploymentState(VercelDeploymentState state, string projectName) + => state with + { + Deployments = state.Deployments + .Where(deployment => !deployment.ManagedByAspire || !string.Equals(deployment.ProjectName, projectName, StringComparison.Ordinal)) + .ToArray() + }; + + private static string GetStateSectionName(VercelEnvironmentResource environment) => $"{StateSectionNamePrefix}{environment.Name}"; + + private static async Task ProjectExistsAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + string projectName) + { + // Avoid treating localized or reformatted CLI errors as provider state. The Vercel + // CLI exposes project lists as JSON, so destroy checks exact project names before + // deleting and again after a failed delete to distinguish races from real failures. + string[] arguments = BuildListProjectsArguments(options, projectName); + var result = await runner.RunAsync(VercelCliFileName, arguments, workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException($"list Vercel projects while checking for '{projectName}'", VercelCliFileName, result); + } + + return ProjectListContainsProject(result.StandardOutput, projectName); + } + + private static async Task ProjectEnvironmentVariableExistsAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string name, + string targetEnvironment) + { + // `vercel env rm` reports absence as human text, but `vercel env ls --format=json` + // returns the linked project's exact keys. Use that provider read for idempotent + // stale-secret cleanup instead of parsing failure prose. + string[] arguments = BuildListProjectEnvironmentVariablesArguments(options, projectLinkDirectory, targetEnvironment); + var result = await runner.RunAsync(VercelCliFileName, arguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException($"list Vercel project environment variables before removing '{name}'", VercelCliFileName, result); + } + + return EnvironmentVariableListContainsName(result.StandardOutput, name); + } + + private static async Task ConfigureProjectEnvironmentVariablesAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry, + string projectLinkDirectory, + IReadOnlyList> environmentVariables, + PreviousVercelDeployment? previousDeployment) + { + var currentNames = environmentVariables + .Select(static variable => variable.Key) + .ToHashSet(StringComparer.Ordinal); + + if (previousDeployment is not null) + { + string[] staleNames = previousDeployment.Entry.ProjectEnvironmentVariables + .Where(name => !currentNames.Contains(name)) + .Order(StringComparer.Ordinal) + .ToArray(); + + await RemoveProjectEnvironmentVariablesAsync( + context, + runner, + options, + projectLinkDirectory, + entry.Resource.Name, + staleNames, + previousDeployment.ProjectEnvironment).ConfigureAwait(false); + } + + if (environmentVariables.Count == 0) + { + return; + } + + string targetEnvironment = GetVercelProjectEnvironmentName(options); + foreach (var environmentVariable in environmentVariables.OrderBy(static variable => variable.Key, StringComparer.Ordinal)) + { + string[] arguments = BuildAddProjectEnvironmentVariableArguments(options, projectLinkDirectory, environmentVariable.Key, targetEnvironment); + var result = await runner.RunAsync(VercelCliFileName, arguments, projectLinkDirectory, context.CancellationToken, standardInput: environmentVariable.Value).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException($"configure Vercel project environment variable '{environmentVariable.Key}' for resource '{entry.Resource.Name}'", VercelCliFileName, result); + } + } + } + + private static async Task RemoveProjectEnvironmentVariablesAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string resourceName, + IReadOnlyList names, + string targetEnvironment) + { + foreach (string name in names) + { + if (!await ProjectEnvironmentVariableExistsAsync(context, runner, options, projectLinkDirectory, name, targetEnvironment).ConfigureAwait(false)) + { + continue; + } + + string[] arguments = BuildRemoveProjectEnvironmentVariableArguments(options, projectLinkDirectory, name, targetEnvironment); + var result = await runner.RunAsync(VercelCliFileName, arguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded + && await ProjectEnvironmentVariableExistsAsync(context, runner, options, projectLinkDirectory, name, targetEnvironment).ConfigureAwait(false)) + { + throw CreateCliException($"remove stale Vercel project environment variable '{name}' for resource '{resourceName}'", VercelCliFileName, result); + } + } + } + + private static async Task RemoveLinkedProjectEnvironmentVariablesAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + VercelEnvironmentResource environment, + VercelDeploymentStateEntry deployment, + string targetEnvironment) + { + var outputService = context.Services.GetRequiredService(); + string projectLinkDirectory = Path.Combine(outputService.GetTempDirectory(environment), ".vercel-projects", deployment.ProjectName); + DeleteDirectoryIfExists(projectLinkDirectory); + Directory.CreateDirectory(projectLinkDirectory); + + try + { + string[] linkArguments = BuildLinkProjectArguments(options, projectLinkDirectory, deployment.ProjectId ?? deployment.ProjectName); + var linkResult = await runner.RunAsync(VercelCliFileName, linkArguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); + if (!linkResult.Succeeded) + { + throw CreateCliException($"link Vercel project '{deployment.ProjectName}' for environment variable cleanup", VercelCliFileName, linkResult); + } + + await RemoveProjectEnvironmentVariablesAsync( + context, + runner, + options, + projectLinkDirectory, + deployment.ResourceName, + deployment.ProjectEnvironmentVariables, + targetEnvironment).ConfigureAwait(false); + } + finally + { + DeleteDirectoryIfExists(projectLinkDirectory); + } + } + + private static async Task PrepareProjectEnvironmentDirectoryAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry) + { + var outputService = context.Services.GetRequiredService(); + string projectLinkDirectory = Path.Combine(outputService.GetTempDirectory(entry.Resource), ".vercel-project"); + if (Directory.Exists(projectLinkDirectory)) { - throw new DistributedApplicationException($"Failed to process deployment configuration for resource '{entry.Resource.Name}'.", executionConfiguration.Exception); + Directory.Delete(projectLinkDirectory, recursive: true); } - ValidateUnsupportedRuntimeConfiguration(entry.Resource, executionConfiguration); + Directory.CreateDirectory(projectLinkDirectory); - var environmentVariables = await GetVercelEnvironmentConfigurationAsync( - entry.Resource, - options, - executionConfiguration, - entriesByResourceName, - resolveProjectEnvironmentVariableValues, - cancellationToken).ConfigureAwait(false); + // `vercel env add` is project-scoped but intentionally does not accept --project. + // Link a scratch directory instead of the source root so secret configuration can use + // the CLI's native project lookup without writing .vercel metadata into user code. + string[] linkArguments = BuildLinkProjectArguments(options, projectLinkDirectory, GetVercelProjectOption(entry)); + var result = await runner.RunAsync(VercelCliFileName, linkArguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException($"prepare temporary Vercel project link for resource '{entry.Resource.Name}'", VercelCliFileName, result); + } - return environmentVariables; + return projectLinkDirectory; } - private static string[] BuildDeployArguments( + private static async Task PullProjectSettingsAsync( + PipelineStepContext context, + IVercelCliRunner runner, VercelEnvironmentOptionsAnnotation options, - string sourceRoot, - string projectName, - IReadOnlyList> environmentVariables) + VercelDeploymentEntry entry, + string projectLinkDirectory) { - List arguments = []; - - if (!string.IsNullOrWhiteSpace(options.Scope)) + string targetEnvironment = GetVercelProjectEnvironmentName(options); + string[] arguments = BuildPullProjectSettingsArguments(options, projectLinkDirectory, targetEnvironment); + var result = await runner.RunAsync(VercelCliFileName, arguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded) { - arguments.Add("--scope"); - arguments.Add(options.Scope); + throw CreateCliException($"pull Vercel project settings for resource '{entry.Resource.Name}'", VercelCliFileName, result); } - arguments.Add("--cwd"); - arguments.Add(sourceRoot); - arguments.Add("deploy"); - arguments.Add("--yes"); - arguments.Add("--project"); - arguments.Add(projectName); + string vercelDirectory = Path.Combine(projectLinkDirectory, VercelDirectoryName); + string projectJsonPath = Path.Combine(vercelDirectory, VercelProjectFileName); + string environmentPath = Path.Combine(vercelDirectory, $".env.{targetEnvironment}.local"); - if (options.Production) + if (!File.Exists(projectJsonPath)) { - arguments.Add("--prod"); + throw new DistributedApplicationException($"Vercel pull did not write expected project settings file '{projectJsonPath}' for resource '{entry.Resource.Name}'."); } - if (!string.IsNullOrWhiteSpace(options.Target)) + if (!File.Exists(environmentPath)) { - arguments.Add("--target"); - arguments.Add(options.Target); + throw new DistributedApplicationException($"Vercel pull did not write expected environment file '{environmentPath}' for resource '{entry.Resource.Name}'."); } - foreach (var environmentVariable in environmentVariables.OrderBy(static variable => variable.Key, StringComparer.Ordinal)) + var environmentVariables = ParseDotEnvFile(await File.ReadAllLinesAsync(environmentPath, context.CancellationToken).ConfigureAwait(false)); + if (!environmentVariables.TryGetValue(VercelOidcTokenEnvironmentVariable, out string? oidcToken) + || string.IsNullOrWhiteSpace(oidcToken)) { - arguments.Add("--env"); - arguments.Add($"{environmentVariable.Key}={environmentVariable.Value}"); + throw new DistributedApplicationException($"Vercel pull did not provide {VercelOidcTokenEnvironmentVariable}, which is required to authenticate local Docker builds to VCR."); } - return [.. arguments]; - } + string projectJsonContent = await File.ReadAllTextAsync(projectJsonPath, context.CancellationToken).ConfigureAwait(false); + var project = ReadVercelProjectSettings(projectJsonPath, projectJsonContent); - private static string BuildDisplayDeployCommand( - VercelEnvironmentOptionsAnnotation options, - string resourceName, - IReadOnlyList> environmentVariables) - { - // The plan should explain the command shape without leaking concrete source roots, - // project names, or environment values that may be machine- or account-specific. - var displayEnvironmentVariables = environmentVariables - .Select(static environmentVariable => new KeyValuePair(environmentVariable.Key, "")) - .ToArray(); + // `vercel pull` materializes project secrets next to the scratch link so local + // builders can read them. This integration only needs the short-lived OIDC token + // and project metadata; delete the env files before creating deploy artifacts. + DeleteIfExists(environmentPath); + DeleteIfExists(Path.Combine(projectLinkDirectory, ".env.local")); - return $"vercel {string.Join(" ", BuildDeployArguments(options, $"<{resourceName}-source-root>", projectName: $"<{resourceName}-project>", displayEnvironmentVariables))}"; + return new(project.ProjectName, project.ProjectId, project.OrgId, projectJsonContent, oidcToken); } - private static async Task ValidateExistingDeploymentStateAsync( + private static async Task LoginToVcrAsync( PipelineStepContext context, - VercelEnvironmentResource environment, - VercelEnvironmentOptionsAnnotation options) + IVercelCliRunner runner, + string oidcToken, + VercelOidcClaims claims) + => await LoginToVcrAsync(runner, oidcToken, claims, context.CancellationToken).ConfigureAwait(false); + + internal static async Task LoginToVcrAsync( + IVercelCliRunner runner, + string oidcToken, + VercelOidcClaims claims, + CancellationToken cancellationToken) { - var stateManager = context.Services.GetRequiredService(); - var stateSection = await stateManager.AcquireSectionAsync(GetStateSectionName(environment), context.CancellationToken).ConfigureAwait(false); - var existingState = ReadDeploymentState(stateSection); - if (existingState is not null) + if (string.IsNullOrWhiteSpace(claims.OwnerId)) { - ValidateDeploymentState(environment, options, existingState); + throw new DistributedApplicationException("The Vercel OIDC token did not include the owner_id claim required to authenticate to VCR."); + } + + string[] arguments = BuildDockerLoginArguments(claims.OwnerId); + var result = await runner.RunAsync(DockerCliFileName, arguments, workingDirectory: null, cancellationToken, standardInput: oidcToken).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException("authenticate Docker to VCR", DockerCliFileName, result); } } - private static async Task SaveDeploymentStateEntryAsync( - PipelineStepContext context, - VercelEnvironmentResource environment, - VercelEnvironmentOptionsAnnotation options, - VercelDeploymentStateEntry deployment) + internal static async Task WriteBuildOutputAsync( + VercelDeploymentEntry entry, + VercelPulledProject project, + string imageReference, + CancellationToken cancellationToken) { - var stateManager = context.Services.GetRequiredService(); - var stateSection = await stateManager.AcquireSectionAsync(GetStateSectionName(environment), context.CancellationToken).ConfigureAwait(false); - var existingState = ReadDeploymentState(stateSection); - var state = existingState is null - ? CreateDeploymentState(environment, options, [deployment]) - : MergeDeploymentState(environment, options, existingState, deployment); + // Vercel Build Output API v3 expects: + // .vercel/project.json copied project identity from `vercel pull` + // .vercel/output/config.json routes and API version + // .vercel/output/functions/index.func/.vc-config.json + // { "runtime": "container", "handler": "@sha256:..." } + // There is intentionally no user source copy here; Aspire's build/push pipeline has + // already built the image, and Vercel deploy uploads only metadata that points at it. + string vercelDirectory = Path.Combine(entry.DeployDirectory, VercelDirectoryName); + string outputDirectory = Path.Combine(vercelDirectory, VercelOutputDirectoryName); + string functionDirectory = Path.Combine(outputDirectory, "functions", "index.func"); + Directory.CreateDirectory(functionDirectory); - stateSection.SetValue(JsonSerializer.Serialize(state, JsonOptions)); + await File.WriteAllTextAsync(Path.Combine(vercelDirectory, VercelProjectFileName), project.ProjectJsonContent, cancellationToken).ConfigureAwait(false); - await stateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); - } + var outputConfig = new JsonObject + { + ["version"] = VercelBuildOutputApiVersion, + ["routes"] = new JsonArray + { + new JsonObject + { + ["handle"] = "filesystem" + }, + new JsonObject + { + ["src"] = "/(.*)", + ["dest"] = "/index" + } + } + }; - private static VercelDeploymentState CreateDeploymentState( - VercelEnvironmentResource environment, - VercelEnvironmentOptionsAnnotation options, - VercelDeploymentStateEntry[] deployments) - => new( - DeploymentStateSchemaVersion, - environment.Name, - NormalizeScope(options.Scope), - NormalizeTarget(options.Target), - options.Production, - deployments); + var functionConfig = new JsonObject + { + ["handler"] = imageReference, + ["runtime"] = "container", + ["environment"] = new JsonObject() + }; - private static VercelDeploymentState MergeDeploymentState( - VercelEnvironmentResource environment, - VercelEnvironmentOptionsAnnotation options, - VercelDeploymentState existingState, - VercelDeploymentStateEntry deployment) + await File.WriteAllTextAsync(Path.Combine(outputDirectory, "config.json"), outputConfig.ToJsonString(JsonOptions), cancellationToken).ConfigureAwait(false); + await File.WriteAllTextAsync(Path.Combine(functionDirectory, ".vc-config.json"), functionConfig.ToJsonString(JsonOptions), cancellationToken).ConfigureAwait(false); + } + + private static VercelPulledProjectSettings ReadVercelProjectSettings(string projectJsonPath, string projectJsonContent) { - ValidateDeploymentState(environment, options, existingState); + try + { + // `vercel pull` writes `.vercel/project.json`. Only project identity fields are + // needed: they select the linked provider project and are safe to persist in state. + using var document = JsonDocument.Parse(projectJsonContent); + var root = document.RootElement; - return CreateDeploymentState( - environment, - options, - [ - .. existingState.Deployments.Where(existing => - !string.Equals(existing.ResourceName, deployment.ResourceName, StringComparison.Ordinal) - || !string.Equals(existing.ProjectName, deployment.ProjectName, StringComparison.Ordinal)), - deployment - ]); + string projectName = root.TryGetProperty("projectName", out var projectNameElement) && projectNameElement.ValueKind == JsonValueKind.String + ? projectNameElement.GetString() ?? string.Empty + : string.Empty; + string? projectId = root.TryGetProperty("projectId", out var projectIdElement) && projectIdElement.ValueKind == JsonValueKind.String + ? projectIdElement.GetString() + : null; + string? orgId = root.TryGetProperty("orgId", out var orgIdElement) && orgIdElement.ValueKind == JsonValueKind.String + ? orgIdElement.GetString() + : null; + + return new(projectName, projectId, orgId); + } + catch (JsonException ex) + { + throw new DistributedApplicationException($"Vercel project settings file '{projectJsonPath}' is invalid JSON.", ex); + } } - private static VercelDeploymentState? ReadDeploymentState(DeploymentStateSection stateSection) + internal static string GetDockerImageDigest(string output) { - // DeploymentStateSection storage shape has changed across Aspire builds. Accept the - // known wrappers so destroy can still clean up projects created by an older CLI. - if (stateSection.Data.TryGetPropertyValue("value", out JsonNode? value) - && value is not null) + string trimmed = output.Trim(); + if (trimmed.StartsWith("\"", StringComparison.Ordinal)) { - return DeserializeDeploymentState(value); + try + { + // `docker buildx imagetools inspect --format '{{json .Digest}}'` style output + // is a JSON string. Older experiments used this shape before Vercel required + // selecting the concrete linux/amd64 manifest from the OCI index. + string? digest = JsonSerializer.Deserialize(trimmed); + if (IsSha256Digest(digest)) + { + return digest!; + } + } + catch (JsonException ex) + { + throw new DistributedApplicationException("Docker returned invalid JSON while resolving the pushed VCR image digest.", ex); + } } - value = stateSection.Data.FirstOrDefault().Value; - if (value is not null) + if (trimmed.StartsWith("{", StringComparison.Ordinal)) { - return DeserializeDeploymentState(value); + try + { + // Current path uses `--format '{{json .Manifest}}'`. Docker may return an OCI + // image index with a `manifests[]` array, or a single manifest object. Vercel + // rejected index digests in live smoke tests, so prefer the linux/amd64 child. + using var document = JsonDocument.Parse(trimmed); + var root = document.RootElement; + + if (root.TryGetProperty("manifests", out var manifests) && manifests.ValueKind == JsonValueKind.Array) + { + foreach (var manifest in manifests.EnumerateArray()) + { + if (manifest.TryGetProperty("platform", out var platform) + && platform.TryGetProperty("os", out var osElement) + && platform.TryGetProperty("architecture", out var architectureElement) + && string.Equals(osElement.GetString(), "linux", StringComparison.OrdinalIgnoreCase) + && string.Equals(architectureElement.GetString(), "amd64", StringComparison.OrdinalIgnoreCase) + && TryGetJsonString(manifest, "digest", out var platformDigest) + && IsSha256Digest(platformDigest)) + { + return platformDigest!; + } + } + + throw new DistributedApplicationException("Docker did not return a linux/amd64 manifest digest for the pushed VCR image. Vercel requires linux/amd64 container images."); + } + + if (TryGetJsonString(root, "digest", out var digest) && IsSha256Digest(digest)) + { + return digest!; + } + } + catch (JsonException ex) + { + throw new DistributedApplicationException("Docker returned invalid JSON while resolving the pushed VCR image digest.", ex); + } } - if (stateSection.Data.ContainsKey("schemaVersion")) + var match = Regex.Match(trimmed, @"sha256:[a-fA-F0-9]{64}", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); + if (match.Success) { - return stateSection.Data.Deserialize(JsonOptions); + return match.Value; } - return null; + throw new DistributedApplicationException($"Docker did not return a valid sha256 image digest. Output: {GetTrimmedOutput(output)}"); } - private static VercelDeploymentState? DeserializeDeploymentState(JsonNode value) + private static bool TryGetJsonString(JsonElement element, string propertyName, [NotNullWhen(true)] out string? value) { - return value.GetValueKind() == JsonValueKind.String - ? JsonSerializer.Deserialize(value.GetValue(), JsonOptions) - : value.Deserialize(JsonOptions); + value = element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + + return !string.IsNullOrWhiteSpace(value); } - private static void ValidateDeploymentState( - VercelEnvironmentResource environment, - VercelEnvironmentOptionsAnnotation options, - VercelDeploymentState state) + internal static VercelOidcClaims DecodeUnvalidatedOidcClaims(string token) { - if (state.SchemaVersion != DeploymentStateSchemaVersion) + string[] parts = token.Split('.'); + if (parts.Length != 3) { - throw new DistributedApplicationException($"Vercel deployment state for environment '{environment.Name}' uses unsupported schema version '{state.SchemaVersion}'. Redeploy the environment before destroying it."); + throw new DistributedApplicationException("The Vercel OIDC token is not a valid compact JWT."); } - if (!string.Equals(state.Environment, environment.Name, StringComparison.Ordinal)) + try { - throw new DistributedApplicationException($"Vercel deployment state for environment '{state.Environment}' cannot be used to destroy environment '{environment.Name}'."); - } + // This is an unvalidated decode of the Vercel-issued token from `vercel pull`. + // Docker/Vercel validate the token when it is used; here we only need routing + // metadata such as owner_id/project to construct the VCR login and repository. + byte[] payloadBytes = Convert.FromBase64String(PadBase64Url(parts[1])); + using var document = JsonDocument.Parse(payloadBytes); + var root = document.RootElement; - string? configuredScope = NormalizeScope(options.Scope); - if (!string.Equals(state.Scope, configuredScope, StringComparison.Ordinal)) + return new( + GetStringClaim(root, "owner_id"), + GetStringClaim(root, "owner"), + GetStringClaim(root, "project"), + GetStringClaim(root, "project_id")); + } + catch (Exception ex) when (ex is FormatException or JsonException) { - string stateScope = string.IsNullOrWhiteSpace(state.Scope) ? "" : state.Scope; - string requestedScope = string.IsNullOrWhiteSpace(configuredScope) ? "" : configuredScope; - throw new DistributedApplicationException($"Vercel deployment state for environment '{environment.Name}' was created for scope '{stateScope}', but destroy is configured for scope '{requestedScope}'. Use the same Vercel scope that created the deployment state."); + throw new DistributedApplicationException("The Vercel OIDC token payload could not be decoded.", ex); } } - private static string? NormalizeScope(string? scope) - => string.IsNullOrWhiteSpace(scope) ? null : scope; + private static string? GetStringClaim(JsonElement root, string name) + => root.TryGetProperty(name, out var element) && element.ValueKind == JsonValueKind.String + ? element.GetString() + : null; - private static string? NormalizeTarget(string? target) - => string.IsNullOrWhiteSpace(target) ? null : target; + private static string PadBase64Url(string value) + { + string padded = value.Replace('-', '+').Replace('_', '/'); + return padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '='); + } - private static string? GetProductionUrl(VercelEnvironmentOptionsAnnotation options, string projectName) - => options.Production ? $"https://{projectName}.vercel.app" : null; + internal static Dictionary ParseDotEnvFile(IEnumerable lines) + { + // Vercel writes dotenv files such as `.vercel/.env.production.local` during pull. + // We only need the VERCEL_OIDC_TOKEN line. This intentionally supports the subset the + // CLI emits: comments/blank lines, KEY=value, single/double quoted values, and common + // backslash escapes. It is not a general dotenv evaluator with interpolation. + Dictionary values = new(StringComparer.Ordinal); - private static VercelDeploymentState RemoveManagedProjectFromDeploymentState(VercelDeploymentState state, string projectName) - => state with + foreach (string rawLine in lines) { - Deployments = state.Deployments - .Where(deployment => !deployment.ManagedByAspire || !string.Equals(deployment.ProjectName, projectName, StringComparison.Ordinal)) - .ToArray() - }; + string line = rawLine.Trim(); + if (line.Length == 0 || line[0] == '#') + { + continue; + } - private static bool IsMissingProjectResult(VercelCliResult result, string projectName) - { - string output = $"{result.StandardOutput}{Environment.NewLine}{result.StandardError}"; - return output.Contains(projectName, StringComparison.OrdinalIgnoreCase) - && (output.Contains("not found", StringComparison.OrdinalIgnoreCase) - || output.Contains("could not find", StringComparison.OrdinalIgnoreCase) - || output.Contains("does not exist", StringComparison.OrdinalIgnoreCase) - || output.Contains("doesn't exist", StringComparison.OrdinalIgnoreCase)); - } + int separator = line.IndexOf('='); + if (separator <= 0) + { + continue; + } - private static string GetStateSectionName(VercelEnvironmentResource environment) => $"{StateSectionNamePrefix}{environment.Name}"; + string key = line[..separator].Trim(); + string value = line[(separator + 1)..].Trim(); + values[key] = UnquoteDotEnvValue(value); + } - private static async Task ConfigureProjectEnvironmentVariablesAsync( - PipelineStepContext context, - IVercelCliRunner runner, - VercelEnvironmentOptionsAnnotation options, - VercelDeploymentEntry entry, - IReadOnlyList> environmentVariables) + return values; + } + + private static string UnquoteDotEnvValue(string value) { - if (environmentVariables.Count == 0) + if (value.Length >= 2 + && ((value[0] == '"' && value[^1] == '"') + || (value[0] == '\'' && value[^1] == '\''))) { - return; + value = value[1..^1]; } - string projectName = GetVercelProjectName(entry); - if (!HasVercelProjectLinkFile(entry.SourceRoot)) + return value.Replace("\\n", "\n", StringComparison.Ordinal) + .Replace("\\r", "\r", StringComparison.Ordinal) + .Replace("\\\"", "\"", StringComparison.Ordinal) + .Replace("\\\\", "\\", StringComparison.Ordinal); + } + + private static bool IsSha256Digest([NotNullWhen(true)] string? value) + => value is not null && Regex.IsMatch(value, "^sha256:[a-f0-9]{64}$", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); + + private static void DeleteIfExists(string path) + { + if (File.Exists(path)) { - // `vercel env add` does not accept --project, so link only the staged copy - // before writing provider-managed secret values. - string[] linkArguments = BuildLinkProjectArguments(options, entry.SourceRoot, projectName); - var linkResult = await runner.RunAsync(VercelCliFileName, linkArguments, entry.SourceRoot, context.CancellationToken).ConfigureAwait(false); - if (!linkResult.Succeeded) - { - throw CreateCliException($"link Vercel project '{projectName}' for resource '{entry.Resource.Name}'", VercelCliFileName, linkResult); - } + File.Delete(path); } + } - string targetEnvironment = GetVercelProjectEnvironmentName(options); - foreach (var environmentVariable in environmentVariables.OrderBy(static variable => variable.Key, StringComparer.Ordinal)) + private static void DeleteDirectoryIfExists(string path) + { + if (Directory.Exists(path)) { - string[] arguments = BuildAddProjectEnvironmentVariableArguments(options, entry.SourceRoot, environmentVariable.Key, targetEnvironment); - var result = await runner.RunAsync(VercelCliFileName, arguments, entry.SourceRoot, context.CancellationToken, standardInput: environmentVariable.Value).ConfigureAwait(false); - if (!result.Succeeded) - { - throw CreateCliException($"configure Vercel project environment variable '{environmentVariable.Key}' for resource '{entry.Resource.Name}'", VercelCliFileName, result); - } + Directory.Delete(path, recursive: true); } } @@ -824,6 +1835,9 @@ private static async Task EnsureManagedProjectAsync( VercelEnvironmentOptionsAnnotation options, VercelDeploymentEntry entry) { + // `vercel project add` is idempotent for the current login/scope in the CLI versions + // this integration supports: it creates the project or validates that it already + // exists and is accessible. Failure here means deploy should not proceed to image push. string projectName = GetVercelProjectName(entry); string[] arguments = BuildAddProjectArguments(options, projectName); var result = await runner.RunAsync(VercelCliFileName, arguments, workingDirectory: null, context.CancellationToken).ConfigureAwait(false); @@ -843,6 +1857,16 @@ private static string GetVercelProjectEnvironmentName(VercelEnvironmentOptionsAn return string.IsNullOrWhiteSpace(options.Target) ? "preview" : options.Target; } + private static string GetVercelProjectEnvironmentName(VercelDeploymentState state) + { + if (state.Production) + { + return "production"; + } + + return string.IsNullOrWhiteSpace(state.Target) ? "preview" : state.Target; + } + private static async Task GetVercelEnvironmentConfigurationAsync( IResource resource, VercelEnvironmentOptionsAnnotation options, @@ -1149,8 +2173,9 @@ private static void ValidateUnsupportedRuntimeConfiguration( { // These Aspire concepts have no faithful Vercel Dockerfile-deploy equivalent in this // preview. Rejecting them is safer than silently dropping entrypoint, args, or build - // values that would change the workload's deployed behavior. Vercel builds remotely - // from uploaded source, so Aspire cannot apply late command/build overrides after upload. + // values that would change the workload's deployed behavior. Aspire's built-in + // image build/push pipeline owns build-time Docker options; this validation only + // rejects runtime concepts the Vercel Build Output API path cannot preserve. if (resource is ContainerResource { Entrypoint: not null }) { throw new DistributedApplicationException( @@ -1162,13 +2187,6 @@ private static void ValidateUnsupportedRuntimeConfiguration( throw new DistributedApplicationException( $"Resource '{resource.Name}' configures Aspire command-line arguments, but Vercel Dockerfile deployments cannot override Docker CMD/ENTRYPOINT. Configure the workload's publish behavior or express the values as environment variables."); } - - if (resource.TryGetLastAnnotation(out var dockerfile) - && (dockerfile.BuildArguments.Count > 0 || dockerfile.BuildSecrets.Count > 0)) - { - throw new DistributedApplicationException( - $"Resource '{resource.Name}' configures Aspire Docker build arguments or build secrets. Vercel runs the Dockerfile build itself, so configure build-time values in Vercel instead."); - } } private static bool ContainsSecretReference(object? value) @@ -1243,28 +2261,45 @@ private static IReadOnlyDictionary GetDeploymentE internal static IEnumerable GetDeploymentEntries(DistributedApplicationModel model, VercelEnvironmentResource environment) { + var computeEnvironments = model.Resources.OfType().Take(2).ToArray(); + // Match Aspire's single-environment convention only when Vercel is the sole compute + // environment. With mixed targets, implicit selection would accidentally deploy + // untargeted resources to every environment. + bool allowImplicitTargeting = computeEnvironments.Length == 1 && ReferenceEquals(computeEnvironments[0], environment); + foreach (var resource in model.Resources.OfType()) { - if (!IsTargetedToEnvironment(resource, environment)) + if (!IsTargetedToEnvironment(resource, environment, allowImplicitTargeting)) + { + continue; + } + + if (resource.TryGetLastAnnotation(out var dockerfile)) { + yield return new(resource, dockerfile.ContextPath, dockerfile.DockerfilePath, dockerfile); continue; } - if (!resource.TryGetLastAnnotation(out var dockerfile)) + if (resource is ProjectResource project) { - throw new DistributedApplicationException($"Resource '{resource.Name}' targets Vercel but does not have Aspire Dockerfile build metadata. Use a workload integration that publishes Dockerfile metadata, call PublishAsDockerFile, or configure the resource with WithDockerfile, WithDockerfileFactory, or WithDockerfileBuilder."); + string projectPath = project.GetProjectMetadata().ProjectPath; + string sourceRoot = Path.GetDirectoryName(projectPath) + ?? throw new DistributedApplicationException($"Project resource '{resource.Name}' has project path '{projectPath}' without a containing directory."); + yield return new(resource, sourceRoot); + continue; } - yield return new(resource, dockerfile.ContextPath, dockerfile.DockerfilePath, dockerfile); + throw new DistributedApplicationException($"Resource '{resource.Name}' targets Vercel but is not an Aspire image build resource. Use a .NET project, a workload integration that publishes Dockerfile metadata, call PublishAsDockerFile, or configure the resource with WithDockerfile, WithDockerfileFactory, or WithDockerfileBuilder."); } } - private static bool IsTargetedToEnvironment(IResource resource, VercelEnvironmentResource environment) + private static bool IsTargetedToEnvironment(IResource resource, VercelEnvironmentResource environment, bool allowImplicitTargeting) { var computeEnvironment = resource.GetComputeEnvironment(); // Match Aspire's single-environment convention: when Vercel is the only compute - // environment, Dockerfile-backed workloads implicitly target it. - return computeEnvironment is null || ReferenceEquals(computeEnvironment, environment); + // environment, image-build workloads implicitly target it. + return ReferenceEquals(computeEnvironment, environment) + || (computeEnvironment is null && allowImplicitTargeting); } private static void ValidateEntries(IReadOnlyList entries) @@ -1274,7 +2309,7 @@ private static void ValidateEntries(IReadOnlyList entries // cannot be projected rather than letting a later Vercel CLI call fail opaquely. if (entries.Count == 0) { - throw new DistributedApplicationException("No Dockerfile-backed compute resources target Vercel. Add a workload with Aspire Dockerfile publish metadata, or use WithComputeEnvironment to target Vercel when multiple compute environments are present."); + throw new DistributedApplicationException("No image-build compute resources target Vercel. Add a .NET project, a workload with Aspire Dockerfile publish metadata, or use WithComputeEnvironment to target Vercel when multiple compute environments are present."); } foreach (var entry in entries) @@ -1284,7 +2319,7 @@ private static void ValidateEntries(IReadOnlyList entries throw new DistributedApplicationException($"The Vercel source root '{entry.SourceRoot}' for resource '{entry.Resource.Name}' does not exist."); } - if (entry.Dockerfile.DockerfileFactory is null && !File.Exists(entry.DockerfilePath)) + if (entry.Dockerfile is { DockerfileFactory: null } && !File.Exists(entry.DockerfilePath!)) { throw new DistributedApplicationException($"The Vercel Dockerfile '{entry.DockerfilePath}' for resource '{entry.Resource.Name}' does not exist. Configure the resource with an existing Dockerfile or Aspire-generated Dockerfile metadata."); } @@ -1330,6 +2365,12 @@ private static void ValidateUnsupportedResourceModel(VercelDeploymentEntry entry // or another deployment-target semantics that Vercel's Dockerfile deploy cannot // project without changing what the user modeled in the AppHost. If Vercel gains a // native equivalent later, add the mapping and tests here instead of silently ignoring it. + if (resource.Annotations.OfType().Any()) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures Aspire generic container registry/image push metadata, but Vercel deployments use Vercel Container Registry through a provider-owned prebuilt artifact. Remove WithContainerRegistry before deploying with the Aspire Vercel integration."); + } + if (resource.Annotations.OfType().Any()) { throw new DistributedApplicationException( @@ -1341,7 +2382,7 @@ private static void ValidateUnsupportedResourceModel(VercelDeploymentEntry entry || resource.Annotations.OfType().Any()) { throw new DistributedApplicationException( - $"Resource '{resource.Name}' configures Aspire container file mounts, but Vercel Dockerfile deployments upload the source tree and Dockerfile only. Include required files in the source tree or generated Dockerfile output instead."); + $"Resource '{resource.Name}' configures Aspire container file mounts, but Vercel Dockerfile deployments build the checked-in source tree directly. Include required files in the source tree or deploy this resource outside the Aspire Vercel integration."); } if (resource.Annotations.OfType().Any() @@ -1424,428 +2465,85 @@ private static void ValidateProjectName(VercelDeploymentEntry entry) private static async Task PrepareDeploymentEntryAsync(PipelineStepContext context, VercelDeploymentEntry entry) { - var outputService = context.Services.GetRequiredService(); - string stagingRoot = GetStagingSourceRoot(outputService.GetTempDirectory(entry.Resource), entry); + await ValidateVercelJsonAsync(entry.Resource, entry.SourceRoot, context.CancellationToken).ConfigureAwait(false); - if (Directory.Exists(stagingRoot)) + var outputService = context.Services.GetRequiredService(); + string tempDirectory = outputService.GetTempDirectory(entry.Resource); + string deployDirectory = Path.Combine(tempDirectory, "vercel-build-output"); + Directory.CreateDirectory(tempDirectory); + if (Directory.Exists(deployDirectory)) { - Directory.Delete(stagingRoot, recursive: true); + Directory.Delete(deployDirectory, recursive: true); } - LogSourceUploadWarnings(context.Logger, entry); - - // Vercel deploy uploads the working tree rooted at --cwd. Aspire Dockerfile metadata - // can point at generated files or Dockerfiles outside that root, so stage a temporary - // Vercel-shaped source tree instead of mutating the user's checkout. - bool preserveVercelProjectLink = HasVercelProjectLinkFile(entry.SourceRoot); - // Staging uses regular file copies. Reject links up front so we do not - // accidentally dereference outside-root content or change Docker context semantics. - ValidateNoSymbolicLinks(entry, preserveVercelProjectLink, context.CancellationToken); - - CopyDirectory( - entry.SourceRoot, - stagingRoot, - preserveVercelProjectLink, - context.CancellationToken); - - await WriteVercelProjectConfigurationAsync(entry.Resource, stagingRoot, context.CancellationToken).ConfigureAwait(false); - - string stagedDockerfilePath = Path.Combine(stagingRoot, VercelDockerfileFileName); - DockerfileFactoryContext dockerfileContext = new() - { - CancellationToken = context.CancellationToken, - Resource = entry.Resource, - Services = context.Services - }; - - await entry.Dockerfile.EmitDockerfileArtifactsAsync(dockerfileContext, stagedDockerfilePath).ConfigureAwait(false); + // This is an output-only Build Output API root, not a staged copy of the source. + // Aspire's built-in build/push steps read the real source/project directly; Vercel + // deploy receives only generated metadata that points at the digest already pushed to VCR. + Directory.CreateDirectory(deployDirectory); return entry with { - SourceRoot = stagingRoot, - DockerfilePath = stagedDockerfilePath - }; - } - - private static async Task WriteVercelProjectConfigurationAsync( - IResource resource, - string stagingRoot, - CancellationToken cancellationToken) - { - string vercelJsonPath = Path.Combine(stagingRoot, VercelJsonFileName); - JsonObject root; - if (File.Exists(vercelJsonPath)) - { - try - { - root = JsonNode.Parse(await File.ReadAllTextAsync(vercelJsonPath, cancellationToken).ConfigureAwait(false)) as JsonObject - ?? throw new DistributedApplicationException($"Resource '{resource.Name}' source root contains '{VercelJsonFileName}', but it is not a JSON object."); - } - catch (JsonException ex) - { - throw new DistributedApplicationException($"Resource '{resource.Name}' source root contains invalid '{VercelJsonFileName}'.", ex); - } - } - else - { - root = []; - } - - if (root.ContainsKey("services")) - { - // Do not merge an existing services block. Service names, roots, and rewrites - // are part of the deployment contract used by endpoint references, so a partial - // merge could deploy successfully but route traffic to a user-defined service. - throw new DistributedApplicationException( - $"Resource '{resource.Name}' source root contains '{VercelJsonFileName}' with an existing 'services' configuration. The Vercel preview integration owns the generated container service configuration; remove the existing services block or deploy this project outside the Aspire Vercel integration."); - } - - var unsupportedKey = VercelJsonServicesModeUnsupportedKeys.FirstOrDefault(root.ContainsKey); - if (unsupportedKey is not null) - { - throw new DistributedApplicationException( - $"Resource '{resource.Name}' source root contains '{VercelJsonFileName}' with top-level '{unsupportedKey}', but the Aspire Vercel integration owns the generated services-mode container and catch-all routing configuration. Move that setting into the Dockerfile, AppHost environment variables, or Vercel project settings before deploying with the Aspire Vercel integration."); - } - - // Dockerfile.vercel is only honored by Vercel's services/container runtime path, - // so generate the minimal service shape instead of relying on framework detection. - root["services"] = new JsonObject - { - [VercelContainerServiceName] = new JsonObject - { - ["runtime"] = "container", - ["root"] = ".", - ["entrypoint"] = VercelDockerfileFileName - } - }; - - JsonObject catchAllRewrite = new() - { - ["source"] = "/(.*)", - ["destination"] = new JsonObject - { - ["service"] = VercelContainerServiceName - } + TempDirectory = tempDirectory, + DeployDirectory = deployDirectory }; - - // Services mode routes requests to named services explicitly. The generated catch-all - // rewrite makes Aspire's single Dockerfile-backed workload behave like one public app - // endpoint instead of relying on Vercel framework routing. - if (root["rewrites"] is null) - { - root["rewrites"] = new JsonArray(catchAllRewrite); - } - else if (root["rewrites"] is JsonArray rewrites) - { - rewrites.Add(catchAllRewrite); - } - else - { - throw new DistributedApplicationException( - $"Resource '{resource.Name}' source root contains '{VercelJsonFileName}' with a 'rewrites' value that is not an array."); - } - - await File.WriteAllTextAsync(vercelJsonPath, root.ToJsonString(JsonOptions), cancellationToken).ConfigureAwait(false); } - private static bool RequiresStaging(VercelDeploymentEntry entry) - { - // Publish output should point at the Vercel-facing artifact shape. Generated or - // non-root Dockerfiles are materialized as Dockerfile.vercel in the staged tree. - if (entry.Dockerfile.DockerfileFactory is not null) - { - return true; - } - - string dockerfileDirectory = Path.GetDirectoryName(Path.GetFullPath(entry.DockerfilePath)) ?? string.Empty; - - return !PathEquals(dockerfileDirectory, entry.SourceRoot) - || !string.Equals(Path.GetFileName(entry.DockerfilePath), VercelDockerfileFileName, GetPathStringComparison()); - } - - private static string GetDisplayDockerfilePath(VercelDeploymentEntry entry) - { - if (RequiresStaging(entry)) - { - return VercelDockerfileFileName; - } - - return Path.GetRelativePath(entry.SourceRoot, entry.DockerfilePath); - } - - private static string GetStagingSourceRoot(string tempDirectory, VercelDeploymentEntry entry) - { - // Use provider identity for the staged folder when possible. Vercel commands use CWD - // and link metadata, and stable staging names make logs/output easier to correlate - // without writing anything back to the source checkout. - string sourceRoot = Path.TrimEndingDirectorySeparator(entry.SourceRoot); - string sourceRootName = HasVercelProjectLinkFile(entry.SourceRoot) - ? Path.GetFileName(sourceRoot) - : GetManagedVercelProjectName(entry); - - if (string.IsNullOrWhiteSpace(sourceRootName)) - { - sourceRootName = entry.Resource.Name; - } - - return Path.Combine(tempDirectory, sourceRootName); - } - - private static void ValidateNoSymbolicLinks( - VercelDeploymentEntry entry, - bool preserveVercelProjectLink, - CancellationToken cancellationToken) - { - ValidateNoSymbolicLinks( - entry.Resource, - entry.SourceRoot, - entry.SourceRoot, - preserveVercelProjectLink, - cancellationToken); - } - - private static void ValidateNoSymbolicLinks( + private static async Task ValidateVercelJsonAsync( IResource resource, string sourceRoot, - string directory, - bool preserveVercelProjectLink, CancellationToken cancellationToken) { - foreach (string path in Directory.EnumerateFileSystemEntries(directory)) - { - cancellationToken.ThrowIfCancellationRequested(); - - var attributes = File.GetAttributes(path); - string relativePath = Path.GetRelativePath(sourceRoot, path); - if ((attributes & FileAttributes.ReparsePoint) != 0) - { - throw new DistributedApplicationException( - $"Resource '{resource.Name}' source root contains symbolic link or reparse point '{relativePath}'. Vercel staging copies files into an upload directory and does not preserve symlink semantics. Replace the link with real files inside the source root or adjust the Dockerfile source context."); - } - - if ((attributes & FileAttributes.Directory) != 0) - { - string directoryName = Path.GetFileName(path); - if (ShouldSkipStagingDirectory(directoryName, preserveVercelProjectLink)) - { - continue; - } - - ValidateNoSymbolicLinks( - resource, - sourceRoot, - path, - preserveVercelProjectLink: false, - cancellationToken); - } - } - } - - private static void LogSourceUploadWarnings(ILogger logger, VercelDeploymentEntry entry) - { - var warningPaths = GetSourceUploadWarningPaths(entry.SourceRoot); - if (warningPaths.Count == 0) + string vercelJsonPath = Path.Combine(sourceRoot, VercelJsonFileName); + if (!File.Exists(vercelJsonPath)) { return; } - logger.LogWarning( - "Resource '{ResourceName}' source root contains environment files that may be uploaded to Vercel: {Paths}. Add or update .vercelignore to exclude sensitive content.", - entry.Resource.Name, - string.Join(", ", warningPaths)); - } - - internal static IReadOnlyList GetSourceUploadWarningPaths(string sourceRoot) - { - var ignorePatterns = ReadVercelIgnorePatterns(sourceRoot); - List warningPaths = []; - - foreach (string file in Directory.EnumerateFiles(sourceRoot, ".env*")) - { - string fileName = Path.GetFileName(file); - if (IsExampleEnvironmentFile(fileName)) - { - continue; - } - - if (!IsIgnoredByVercelIgnore(fileName, isDirectory: false, ignorePatterns)) - { - warningPaths.Add(fileName); - } - } - - return [.. warningPaths.Order(StringComparer.Ordinal)]; - } - - private static bool IsExampleEnvironmentFile(string fileName) - => fileName.Equals(".env.example", GetPathStringComparison()) - || fileName.Equals(".env.sample", GetPathStringComparison()) - || fileName.Equals(".env.template", GetPathStringComparison()); - - private static IReadOnlyList ReadVercelIgnorePatterns(string sourceRoot) - { - // This is a warning-only approximation of .vercelignore, not a full gitignore engine. - // Vercel remains the authority for the actual upload set; this parser only avoids - // noisy .env warnings for common forms like `.env*`, `secrets/`, `!.env.example`, - // `nested/path`, and `*.local`. - string vercelIgnorePath = Path.Combine(sourceRoot, ".vercelignore"); - if (!File.Exists(vercelIgnorePath)) - { - return []; - } - - return [.. File.ReadAllLines(vercelIgnorePath) - .Select(static line => line.Trim()) - .Where(static line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith('#'))]; - } - - private static bool IsIgnoredByVercelIgnore(string relativePath, bool isDirectory, IReadOnlyList ignorePatterns) - { - bool ignored = false; - foreach (string pattern in ignorePatterns) - { - bool negated = pattern.StartsWith('!'); - string normalizedPattern = negated ? pattern[1..] : pattern; - if (string.IsNullOrWhiteSpace(normalizedPattern)) - { - continue; - } - - if (VercelIgnorePatternMatches(relativePath, isDirectory, normalizedPattern)) - { - ignored = !negated; - } - } - - return ignored; - } - - private static bool VercelIgnorePatternMatches(string relativePath, bool isDirectory, string pattern) - { - bool directoryOnly = pattern.EndsWith('/'); - if (directoryOnly && !isDirectory) - { - return false; - } - - string normalizedPattern = pattern.Trim('/'); - if (string.IsNullOrWhiteSpace(normalizedPattern)) - { - return false; - } - - string normalizedPath = relativePath.Replace(Path.DirectorySeparatorChar, '/'); - if (normalizedPattern.Contains('/')) - { - return WildcardMatches(normalizedPath, normalizedPattern); - } - - return normalizedPath - .Split('/', StringSplitOptions.RemoveEmptyEntries) - .Any(segment => WildcardMatches(segment, normalizedPattern)); - } - - private static bool WildcardMatches(string value, string pattern) - { - string regexPattern = $"^{Regex.Escape(pattern).Replace("\\*", ".*", StringComparison.Ordinal).Replace("\\?", ".", StringComparison.Ordinal)}$"; - return Regex.IsMatch(value, regexPattern, RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); - } - - private static void CopyDirectory( - string sourceDirectory, - string destinationDirectory, - bool preserveVercelProjectLink, - CancellationToken cancellationToken) - { - Directory.CreateDirectory(destinationDirectory); - - foreach (string directory in Directory.EnumerateDirectories(sourceDirectory)) + JsonObject root; + try { - cancellationToken.ThrowIfCancellationRequested(); - - string directoryName = Path.GetFileName(directory); - if (ShouldSkipStagingDirectory(directoryName, preserveVercelProjectLink)) - { - continue; - } - - if (IsVercelDirectory(directoryName)) - { - // Keep only project identity. Cache/build metadata belongs to the user's - // checkout and should not be uploaded from Aspire's staged source. - CopyVercelProjectLink(directory, Path.Combine(destinationDirectory, directoryName), cancellationToken); - continue; - } - - CopyDirectory( - directory, - Path.Combine(destinationDirectory, directoryName), - preserveVercelProjectLink: false, - cancellationToken); + root = JsonNode.Parse(await File.ReadAllTextAsync(vercelJsonPath, cancellationToken).ConfigureAwait(false)) as JsonObject + ?? throw new DistributedApplicationException($"Resource '{resource.Name}' source root contains '{VercelJsonFileName}', but it is not a JSON object."); } - - foreach (string file in Directory.EnumerateFiles(sourceDirectory)) + catch (JsonException ex) { - cancellationToken.ThrowIfCancellationRequested(); - - string destinationFile = Path.Combine(destinationDirectory, Path.GetFileName(file)); - Directory.CreateDirectory(Path.GetDirectoryName(destinationFile)!); - File.Copy(file, destinationFile, overwrite: true); + throw new DistributedApplicationException($"Resource '{resource.Name}' source root contains invalid '{VercelJsonFileName}'.", ex); } - } - private static bool ShouldSkipStagingDirectory(string directoryName, bool preserveVercelProjectLink) - // These are local checkout/provider cache directories, not app source for Vercel's - // remote Docker build. Linked projects get only .vercel/project.json via a separate path. - => IsGitDirectory(directoryName) - || IsNodeModulesDirectory(directoryName) - || (IsVercelDirectory(directoryName) && !preserveVercelProjectLink); - - private static void CopyVercelProjectLink(string sourceDirectory, string destinationDirectory, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - string projectJsonPath = Path.Combine(sourceDirectory, "project.json"); - if (!File.Exists(projectJsonPath)) + var unsupportedKey = VercelJsonBuildOutputUnsupportedKeys.FirstOrDefault(root.ContainsKey); + if (unsupportedKey is not null) { - return; + throw new DistributedApplicationException( + $"Resource '{resource.Name}' source root contains '{VercelJsonFileName}' with top-level '{unsupportedKey}', but the Aspire Vercel integration owns the generated Build Output API container function and catch-all routing configuration. Move that setting into the Dockerfile, AppHost environment variables, or Vercel project settings before deploying with the Aspire Vercel integration."); } - - Directory.CreateDirectory(destinationDirectory); - File.Copy(projectJsonPath, Path.Combine(destinationDirectory, "project.json"), overwrite: true); } - private static bool IsGitDirectory(string directoryName) - => string.Equals(directoryName, ".git", GetPathStringComparison()); - - private static bool IsNodeModulesDirectory(string directoryName) - => string.Equals(directoryName, "node_modules", GetPathStringComparison()); - - private static bool IsVercelDirectory(string directoryName) - => string.Equals(directoryName, ".vercel", GetPathStringComparison()); - - private static bool PathEquals(string path, string otherPath) - => string.Equals(Path.GetFullPath(path), Path.GetFullPath(otherPath), GetPathStringComparison()); - - private static StringComparison GetPathStringComparison() - => OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; + private static string GetDisplayDockerfilePath(VercelDeploymentEntry entry) + => entry.Dockerfile is null + ? "" + : entry.Dockerfile.DockerfileFactory is null + ? Path.GetRelativePath(entry.SourceRoot, entry.DockerfilePath!) + : ""; internal static string GetVercelProjectName(VercelDeploymentEntry entry) => GetVercelProjectLink(entry).ProjectName; internal static string GetVercelProjectName(IResource resource) { - // This integration does not synthesize Dockerfiles from arbitrary compute resources. - // The workload must already carry Aspire Dockerfile publish metadata so Vercel receives - // the same source/Dockerfile contract the user opted into. - if (!resource.TryGetLastAnnotation(out var dockerfile)) + if (resource.TryGetLastAnnotation(out var dockerfile)) + { + return GetVercelProjectName(new VercelDeploymentEntry(resource, dockerfile.ContextPath, dockerfile.DockerfilePath, dockerfile)); + } + + if (resource is ProjectResource project) { - throw new DistributedApplicationException($"Resource '{resource.Name}' targets Vercel but does not have Aspire Dockerfile build metadata. Use a workload integration that publishes Dockerfile metadata, call PublishAsDockerFile, or configure the resource with WithDockerfile, WithDockerfileFactory, or WithDockerfileBuilder."); + string projectPath = project.GetProjectMetadata().ProjectPath; + string sourceRoot = Path.GetDirectoryName(projectPath) + ?? throw new DistributedApplicationException($"Project resource '{resource.Name}' has project path '{projectPath}' without a containing directory."); + return GetVercelProjectName(new VercelDeploymentEntry(resource, sourceRoot)); } - return GetVercelProjectName(new VercelDeploymentEntry(resource, dockerfile.ContextPath, dockerfile.DockerfilePath, dockerfile)); + throw new DistributedApplicationException($"Resource '{resource.Name}' targets Vercel but is not an Aspire image build resource. Use a .NET project, a workload integration that publishes Dockerfile metadata, call PublishAsDockerFile, or configure the resource with WithDockerfile, WithDockerfileFactory, or WithDockerfileBuilder."); } private static VercelProjectLink GetVercelProjectLink(VercelDeploymentEntry entry) @@ -1858,6 +2556,14 @@ private static VercelProjectLink GetVercelProjectLink(VercelDeploymentEntry entr return new(GetManagedVercelProjectName(entry), ProjectId: null); } + private static string GetVercelProjectOption(VercelDeploymentEntry entry) + { + var projectLink = GetVercelProjectLink(entry); + return string.IsNullOrWhiteSpace(projectLink.ProjectId) + ? projectLink.ProjectName + : projectLink.ProjectId; + } + private static string GetManagedVercelProjectName(VercelDeploymentEntry entry) { if (entry.Resource.TryGetLastAnnotation(out var options)) @@ -1953,8 +2659,9 @@ private static bool TryReadVercelProjectLink(string sourceRoot, [NotNullWhen(tru if (File.Exists(projectJsonPath)) { // Vercel CLI writes linked project identity as: - // .vercel/project.json: { "projectId": "...", "projectName": "..." } - // Treat it as provider ownership metadata rather than regenerating a managed name. + // .vercel/project.json: { "projectId": "...", "orgId": "...", "projectName": "..." } + // Treat it as user/provider ownership metadata rather than regenerating a managed + // name. Destroy preserves these linked projects and only removes tracked env vars. using var document = JsonDocument.Parse(File.ReadAllText(projectJsonPath)); string? projectName = GetJsonStringProperty(document.RootElement, "projectName"); @@ -1976,6 +2683,66 @@ private static bool TryReadVercelProjectLink(string sourceRoot, [NotNullWhen(tru ? property.GetString() : null; + internal static bool ProjectListContainsProject(string standardOutput, string projectName) + { + try + { + using var document = JsonDocument.Parse(standardOutput); + foreach (var project in EnumerateJsonArrayOrNamedArray(document.RootElement, "projects")) + { + if (string.Equals(GetJsonStringProperty(project, "name"), projectName, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + catch (JsonException ex) + { + throw new DistributedApplicationException("Failed to parse JSON output from 'vercel project ls'.", ex); + } + } + + internal static bool EnvironmentVariableListContainsName(string standardOutput, string name) + { + try + { + using var document = JsonDocument.Parse(standardOutput); + foreach (var environmentVariable in EnumerateJsonArrayOrNamedArray(document.RootElement, "envs")) + { + if (string.Equals(GetJsonStringProperty(environmentVariable, "key"), name, StringComparison.Ordinal) + && string.IsNullOrWhiteSpace(GetJsonStringProperty(environmentVariable, "gitBranch"))) + { + return true; + } + } + + return false; + } + catch (JsonException ex) + { + throw new DistributedApplicationException("Failed to parse JSON output from 'vercel env ls'.", ex); + } + } + + private static JsonElement.ArrayEnumerator EnumerateJsonArrayOrNamedArray(JsonElement root, string propertyName) + { + if (root.ValueKind == JsonValueKind.Array) + { + return root.EnumerateArray(); + } + + if (root.ValueKind == JsonValueKind.Object + && root.TryGetProperty(propertyName, out var array) + && array.ValueKind == JsonValueKind.Array) + { + return array.EnumerateArray(); + } + + throw new JsonException($"Expected JSON array or object property '{propertyName}'."); + } + private static string GetVercelProjectJsonPath(string sourceRoot) => Path.Combine(sourceRoot, ".vercel", "project.json"); @@ -1984,6 +2751,8 @@ internal static string GetDeploymentUrl(string standardOutput) internal static VercelDeploymentResult GetDeploymentResult(string standardOutput) { + // `vercel deploy` output has changed between CLI versions and flags. Prefer structured + // JSON when present, then fall back to the last plain HTTP(S) URL printed by the CLI. if (TryGetJsonDeploymentResult(standardOutput) is { } jsonDeploymentResult) { return jsonDeploymentResult; @@ -2099,6 +2868,8 @@ private static bool IsHttpUrl([NotNullWhen(true)] string? url) internal static bool TryGetVercelCliVersion(string output, [NotNullWhen(true)] out Version? version) { + // The CLI can print banners/warnings around the version. Extract the first semantic + // x.y.z token instead of requiring a line to be exactly the version string. var match = Regex.Match(output, @"(?\d+)\.(?\d+)\.(?\d+)(?!\d)", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); if (!match.Success) { @@ -2126,7 +2897,13 @@ private static DistributedApplicationException CreateCliException(string operati } } -internal sealed record VercelDeploymentEntry(IResource Resource, string SourceRoot, string DockerfilePath, DockerfileBuildAnnotation Dockerfile); +internal sealed record VercelDeploymentEntry( + IResource Resource, + string SourceRoot, + string? DockerfilePath = null, + DockerfileBuildAnnotation? Dockerfile = null, + string TempDirectory = "", + string DeployDirectory = ""); internal sealed record VercelDeploymentPlan(string Environment, VercelDeploymentPlanEntry[] Deployments); @@ -2147,6 +2924,8 @@ internal sealed record VercelDeploymentResult(string? DeploymentId, string Deplo internal sealed record VercelDeploymentInspection(string? ReadyState); +internal sealed record PreviousVercelDeployment(VercelDeploymentStateEntry Entry, string ProjectEnvironment); + internal sealed record VercelDeploymentState( int SchemaVersion, string Environment, @@ -2165,6 +2944,43 @@ internal sealed record VercelDeploymentStateEntry( bool ManagedByAspire) { public string? ProductionUrl { get; init; } + + public string? VcrImageDigest { get; init; } + + public int? BuildOutputApiVersion { get; init; } + + public string[] ProjectEnvironmentVariables { get; init; } = []; +} + +internal sealed record VercelImageReference(string Reference, string Digest); + +internal sealed record VercelPreparedDeploymentAnnotation( + VercelDeploymentEntry Entry, + VercelProjectLink ProjectLink, + VercelPulledProjectContext ProjectContext, + bool ManagedByAspire, + string RemoteImageName, + string RemoteImageTag, + string TaggedImageReference) : IResourceAnnotation; + +internal sealed class VercelImagePushOptionsCallbackAnnotation : IResourceAnnotation +{ } internal sealed record VercelProjectLink(string ProjectName, string? ProjectId); + +internal sealed record VercelPulledProject( + string ProjectName, + string? ProjectId, + string? OrgId, + string ProjectJsonContent, + string OidcToken); + +internal sealed record VercelPulledProjectContext( + VercelEnvironmentConfiguration EnvironmentConfiguration, + VercelPulledProject PulledProject, + VercelOidcClaims OidcClaims); + +internal sealed record VercelPulledProjectSettings(string ProjectName, string? ProjectId, string? OrgId); + +internal sealed record VercelOidcClaims(string? OwnerId, string? Owner, string? Project, string? ProjectId); diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs index c6b5148cf..686aca4d0 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs @@ -1,6 +1,8 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Publishing; using CommunityToolkit.Aspire.Hosting.Vercel; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using System.Diagnostics.CodeAnalysis; @@ -19,8 +21,8 @@ public static class VercelEnvironmentResourceBuilderExtensions /// The Aspire resource name for the Vercel environment. /// A resource builder for the Vercel environment. /// - /// The Vercel environment is not added to the local run model. During publish and deploy it validates Dockerfile build metadata for - /// resources and, during deploy, invokes the Vercel CLI using the current login or VERCEL_TOKEN. + /// The Vercel environment is not added to the local run model. During publish and deploy it validates image-build resources and, + /// during deploy, invokes the Vercel CLI using the current login or VERCEL_TOKEN. /// [AspireExport] public static IResourceBuilder AddVercelEnvironment( @@ -31,12 +33,18 @@ public static IResourceBuilder AddVercelEnvironment( ArgumentException.ThrowIfNullOrEmpty(name); builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + builder.Services.DecorateVercelContainerImageManager(); var resource = new VercelEnvironmentResource(name); var resourceBuilder = builder.ExecutionContext.IsRunMode ? builder.CreateResourceBuilder(resource) : builder.AddResource(resource); + // The actions are plain delegates over injectable services (CLI runner, registry + // client, state/output managers). Tests materialize the pipeline steps and invoke + // these delegates directly so publish/prereq/deploy/destroy logic does not require a + // live Aspire CLI pipeline process. return resourceBuilder .WithAnnotation(new VercelEnvironmentOptionsAnnotation(), ResourceAnnotationMutationBehavior.Replace) .WithPipelineStepFactory(_ => @@ -53,7 +61,7 @@ public static IResourceBuilder AddVercelEnvironment( new PipelineStep { Name = $"{VercelDeploymentStep.DeployPrereqStepNamePrefix}{resource.Name}", - Description = $"Validate Vercel CLI prerequisites for '{resource.Name}'.", + Description = $"Prepare Vercel projects and VCR image push settings for '{resource.Name}'.", Resource = resource, DependsOnSteps = [WellKnownPipelineSteps.ValidateComputeEnvironments], RequiredBySteps = [WellKnownPipelineSteps.Deploy], @@ -64,6 +72,7 @@ public static IResourceBuilder AddVercelEnvironment( Name = $"{VercelDeploymentStep.DeployStepNamePrefix}{resource.Name}", Description = $"Deploy resources to Vercel environment '{resource.Name}'.", Resource = resource, + Tags = ["vercel-deploy"], DependsOnSteps = [$"{VercelDeploymentStep.DeployPrereqStepNamePrefix}{resource.Name}"], RequiredBySteps = [WellKnownPipelineSteps.Deploy], Action = context => VercelDeploymentStep.DeployAsync(context, resource) @@ -76,7 +85,8 @@ public static IResourceBuilder AddVercelEnvironment( RequiredBySteps = [WellKnownPipelineSteps.Destroy], Action = context => VercelDeploymentStep.DestroyAsync(context, resource) } - ]); + ]) + .WithAnnotation(new PipelineConfigurationAnnotation(context => VercelDeploymentStep.ConfigurePipeline(context, resource))); } /// @@ -139,4 +149,66 @@ private static IResourceBuilder WithVercelOptions( return builder.WithAnnotation(updated, ResourceAnnotationMutationBehavior.Replace); } + private static void DecorateVercelContainerImageManager(this IServiceCollection services) + { + // Aspire owns image build/push. Vercel only needs to re-authenticate project-scoped + // VCR credentials immediately before Vercel-targeted pushes. Decorate the existing + // image manager once and leave all non-Vercel resources on the original path. + if (services.Any(static descriptor => descriptor.ServiceType == typeof(VercelContainerImageManagerDecorationMarker))) + { + return; + } + + int descriptorIndex = -1; + for (int i = services.Count - 1; i >= 0; i--) + { + if (services[i].ServiceType == typeof(IResourceContainerImageManager)) + { + descriptorIndex = i; + break; + } + } + + if (descriptorIndex < 0) + { + return; + } + + var descriptor = services[descriptorIndex]; + services.RemoveAt(descriptorIndex); + services.Insert( + descriptorIndex, + ServiceDescriptor.Describe( + typeof(IResourceContainerImageManager), + serviceProvider => + { + var inner = (IResourceContainerImageManager)CreateService(serviceProvider, descriptor); + return ActivatorUtilities.CreateInstance(serviceProvider, inner); + }, + descriptor.Lifetime)); + services.AddSingleton(); + } + + private static object CreateService(IServiceProvider serviceProvider, ServiceDescriptor descriptor) + { + if (descriptor.ImplementationInstance is not null) + { + return descriptor.ImplementationInstance; + } + + if (descriptor.ImplementationFactory is not null) + { + return descriptor.ImplementationFactory(serviceProvider)!; + } + + if (descriptor.ImplementationType is not null) + { + return ActivatorUtilities.CreateInstance(serviceProvider, descriptor.ImplementationType); + } + + throw new InvalidOperationException($"The {nameof(IResourceContainerImageManager)} service registration is missing an implementation."); + } + + private sealed class VercelContainerImageManagerDecorationMarker; + } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceContainerImageManager.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceContainerImageManager.cs new file mode 100644 index 000000000..1335cfa7c --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceContainerImageManager.cs @@ -0,0 +1,49 @@ +#pragma warning disable ASPIREPIPELINES003 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Publishing; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal sealed class VercelResourceContainerImageManager( + IResourceContainerImageManager inner, + IVercelCliRunner runner) : IResourceContainerImageManager +{ + private readonly SemaphoreSlim _pushLock = new(1, 1); + + public Task BuildImageAsync(IResource resource, CancellationToken cancellationToken = default) + => inner.BuildImageAsync(resource, cancellationToken); + + public Task BuildImagesAsync(IEnumerable resources, CancellationToken cancellationToken = default) + => inner.BuildImagesAsync(resources, cancellationToken); + + public async Task PushImageAsync(IResource resource, CancellationToken cancellationToken) + { + var preparedDeployment = resource.Annotations.OfType().LastOrDefault(); + if (preparedDeployment is null) + { + await inner.PushImageAsync(resource, cancellationToken).ConfigureAwait(false); + return; + } + + // VCR OIDC tokens are scoped to a Vercel project, while Docker stores credentials + // by registry host. Built-in Aspire push steps can run in parallel, so serialize + // Vercel pushes and login immediately before each push to avoid cross-project token races. + await _pushLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await VercelDeploymentStep.LoginToVcrAsync( + runner, + preparedDeployment.ProjectContext.PulledProject.OidcToken, + preparedDeployment.ProjectContext.OidcClaims, + cancellationToken).ConfigureAwait(false); + + await inner.PushImageAsync(resource, cancellationToken).ConfigureAwait(false); + } + finally + { + _pushLock.Release(); + } + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/TypeScriptAppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/TypeScriptAppHostTests.cs index 2e8284cf8..cd79a81c8 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/TypeScriptAppHostTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/TypeScriptAppHostTests.cs @@ -8,6 +8,11 @@ public class TypeScriptAppHostTests [Fact] public async Task TypeScriptAppHostCompilesAndStarts() { + if (!IsOnPath("pwsh")) + { + Assert.Skip("TypeScript AppHost start smoke requires PowerShell (pwsh) because the TypeScript AppHost test harness launches Aspire scripts through pwsh."); + } + await TypeScriptAppHostTest.Run( appHostProject: "CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript", packageName: "CommunityToolkit.Aspire.Hosting.Vercel", @@ -52,4 +57,24 @@ public async Task TypeScriptAppHostPublishesWithExplicitComputeEnvironment() } } } + + private static bool IsOnPath(string fileName) + { + string path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + foreach (string directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + string candidate = Path.Combine(directory, fileName); + if (File.Exists(candidate)) + { + return true; + } + + if (OperatingSystem.IsWindows() && File.Exists(candidate + ".exe")) + { + return true; + } + } + + return false; + } } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs index 19c8a33f9..63de9cf39 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs @@ -2,6 +2,7 @@ #pragma warning disable ASPIREPIPELINES002 #pragma warning disable ASPIREPIPELINES003 #pragma warning disable ASPIREPIPELINES004 +#pragma warning disable ASPIRECOMPUTE003 #pragma warning disable ASPIREPROBES001 #pragma warning disable CTASPIREVERCEL001 @@ -177,6 +178,169 @@ public async Task AddVercelEnvironmentRegistersExpectedPipelineSteps() }); } + [Fact] + public async Task VercelDeployStepDependsOnBuiltInImagePushSteps() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var api = Assert.Single(model.Resources.OfType()); + var steps = await CreateConfiguredPipelineStepsAsync(builder, app); + + var buildStep = Assert.Single(steps, step => step.Name == "build-api"); + var pushStep = Assert.Single(steps, step => step.Name == "push-api"); + var pushPrereqStep = Assert.Single(steps, step => step.Name == "push-prereq"); + var deployStep = Assert.Single(steps, step => step.Name == "vercel-deploy-vercel"); + + Assert.Contains("vercel-deploy-prereq-vercel", buildStep.DependsOnSteps); + Assert.Contains(WellKnownPipelineSteps.Deploy, buildStep.RequiredBySteps); + Assert.Contains("vercel-deploy-prereq-vercel", pushPrereqStep.DependsOnSteps); + Assert.Contains("vercel-deploy-prereq-vercel", pushStep.DependsOnSteps); + Assert.Contains(WellKnownPipelineSteps.Deploy, pushStep.RequiredBySteps); + Assert.Contains("push-api", deployStep.DependsOnSteps); + Assert.Contains(api.Annotations, annotation => annotation is ContainerImagePushOptionsCallbackAnnotation); + } + + [Fact] + public async Task VercelPipelineStepActionsCanBeUnitTested() + { + using var sourceRoot = TemporaryDirectory.Create("pipeline-action-project"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, "https://pipeline-action-project.vercel.app", ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var steps = await CreateConfiguredPipelineStepsAsync(builder, app); + var context = CreatePipelineStepContext(builder, app); + var publishStep = Assert.Single(steps, step => step.Name == "vercel-publish-vercel"); + var prereqStep = Assert.Single(steps, step => step.Name == "vercel-deploy-prereq-vercel"); + var deployStep = Assert.Single(steps, step => step.Name == "vercel-deploy-vercel"); + var destroyStep = Assert.Single(steps, step => step.Name == "vercel-destroy-vercel"); + + await publishStep.Action(context); + await prereqStep.Action(context); + await deployStep.Action(context); + await destroyStep.Action(context); + + Assert.True(File.Exists(Path.Combine(outputRoot.Path, VercelDeploymentStep.DeploymentPlanFileName))); + Assert.Contains(runner.Invocations, static invocation => invocation.FileName == "docker" && invocation.Arguments.Contains("imagetools")); + Assert.Contains(runner.Invocations, static invocation => invocation.FileName == "vercel" && invocation.Arguments.Contains("deploy")); + Assert.Contains(runner.Invocations, static invocation => invocation.FileName == "vercel" && invocation.Arguments is ["project", "remove", ..]); + Assert.NotEmpty(stateManager.SavedSections); + Assert.NotEmpty(stateManager.DeletedSections); + Assert.Contains(context.Summary.Items, item => item.Key == "Vercel deployment plan"); + Assert.Contains(context.Summary.Items, item => item.Key == "api Vercel deployment"); + Assert.Contains(context.Summary.Items, item => item.Key == "Vercel project removed"); + } + + [Fact] + public async Task ValidatePrerequisitesConfiguresVcrAsDeploymentTargetRegistry() + { + using var sourceRoot = TemporaryDirectory.Create("vcr-registry-project"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var registryClient = new FakeVercelContainerRegistryClient(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(new FakeVercelCliRunner()); + builder.Services.AddSingleton(registryClient); + builder.Services.AddSingleton(new FakeDeploymentStateManager()); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var api = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.ValidatePrerequisitesAsync(context, environment); + + var target = Assert.Single(api.Annotations.OfType()); + Assert.Same(environment, target.DeploymentTarget); + Assert.Same(environment, target.ComputeEnvironment); + Assert.NotNull(target.ContainerRegistry); + Assert.Equal("vcr.vercel.com", await target.ContainerRegistry.Endpoint.GetValueAsync(TestContext.Current.CancellationToken)); + Assert.Equal("test-team/test-project", await target.ContainerRegistry.Repository!.GetValueAsync(TestContext.Current.CancellationToken)); + Assert.Equal([ "app" ], registryClient.Repositories); + + var pushOptions = new ContainerImagePushOptions(); + var pushContext = new ContainerImagePushOptionsCallbackContext + { + Resource = api, + Options = pushOptions, + CancellationToken = TestContext.Current.CancellationToken + }; + foreach (var annotation in api.Annotations.OfType()) + { + await annotation.Callback(pushContext); + } + + Assert.Equal("app", pushOptions.RemoteImageName); + Assert.StartsWith("aspire-", pushOptions.RemoteImageTag, StringComparison.Ordinal); + + var buildContext = new ContainerBuildOptionsCallbackContext( + api, + app.Services, + NullLogger.Instance, + TestContext.Current.CancellationToken, + builder.ExecutionContext); + foreach (var annotation in api.Annotations.OfType()) + { + await annotation.Callback(buildContext); + } + + Assert.Equal(ContainerTargetPlatform.LinuxAmd64, buildContext.TargetPlatform); + } + + [Fact] + public async Task VercelImageManagerLogsInBeforeEachProjectScopedPush() + { + var runner = new FakeVercelCliRunner(); + var inner = new VerifyingResourceContainerImageManager(runner); + var manager = new VercelResourceContainerImageManager(inner, runner); + var api = new ContainerResource("api"); + var backend = new ContainerResource("backend"); + api.Annotations.Add(CreatePreparedDeployment(api, "api-token", "api-project")); + backend.Annotations.Add(CreatePreparedDeployment(backend, "backend-token", "backend-project")); + + await Task.WhenAll( + manager.PushImageAsync(api, TestContext.Current.CancellationToken), + manager.PushImageAsync(backend, TestContext.Current.CancellationToken)); + + var loginInvocations = runner.Invocations + .Where(static invocation => invocation.FileName == "docker" && invocation.Arguments.Contains("login")) + .ToArray(); + Assert.Equal(2, loginInvocations.Length); + Assert.Contains(loginInvocations, static invocation => invocation.StandardInput == "api-token"); + Assert.Contains(loginInvocations, static invocation => invocation.StandardInput == "backend-token"); + Assert.Equal(2, inner.PushedResources.Count); + } + [Fact] public async Task DestroyPipelineStepDoesNotValidateVercelCliWhenStateIsMissing() { @@ -185,6 +349,7 @@ public async Task DestroyPipelineStepDoesNotValidateVercelCliWhenStateIsMissing( var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.AddVercelEnvironment("vercel"); @@ -245,6 +410,30 @@ public void PublishProjectAsDockerFileIsDiscoveredInPublishMode() Assert.Same(api, Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)).Resource); } + [Fact] + public void ProjectResourceIsDiscoveredInPublishMode() + { + using var sourceRoot = TemporaryDirectory.Create(); + string projectPath = Path.Combine(sourceRoot.Path, "Api.csproj"); + File.WriteAllText(projectPath, ""); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.AddVercelEnvironment("vercel"); + var projectResource = new ProjectResource("api"); + builder.AddResource(projectResource) + .WithAnnotation(new FakeProjectMetadata(projectPath)); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + Assert.Same(projectResource, entry.Resource); + Assert.Equal(sourceRoot.Path, entry.SourceRoot); + Assert.Null(entry.Dockerfile); + Assert.Null(entry.DockerfilePath); + } + [Fact] public void PublishExecutableAsDockerFileIsDiscoveredInPublishMode() { @@ -290,7 +479,7 @@ COPY server.mjs . var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); Assert.IsAssignableFrom(entry.Resource); Assert.Equal(sourceRoot.Path, entry.SourceRoot); - Assert.NotNull(entry.Dockerfile.DockerfileFactory); + Assert.NotNull(entry.Dockerfile!.DockerfileFactory); } [Fact] @@ -343,6 +532,24 @@ public void ExplicitComputeEnvironmentSelectsVercelResourceWhenMultipleExist() Assert.Equal("worker", otherEntry.Resource.Name); } + [Fact] + public void UntargetedResourcesAreNotImplicitlySelectedWhenMultipleComputeEnvironmentsExist() + { + using var sourceRoot = TemporaryDirectory.Create("vercel-api-project"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.AddVercelEnvironment("vercel"); + builder.AddVercelEnvironment("other"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environments = model.Resources.OfType().ToArray(); + + Assert.All(environments, environment => Assert.Empty(VercelDeploymentStep.GetDeploymentEntries(model, environment))); + } + [Fact] public async Task WriteDeploymentPlanWritesExpectedJson() { @@ -367,8 +574,12 @@ public async Task WriteDeploymentPlanWritesExpectedJson() Assert.Equal("vercel", root.GetProperty("environment").GetString()); var deployment = Assert.Single(root.GetProperty("deployments").EnumerateArray()); Assert.Equal("api", deployment.GetProperty("resourceName").GetString()); - Assert.Equal("Dockerfile.vercel", deployment.GetProperty("dockerfilePath").GetString()); - Assert.Equal("vercel --cwd deploy --yes --project ", deployment.GetProperty("deployCommand").GetString()); + Assert.Equal("Dockerfile", deployment.GetProperty("dockerfilePath").GetString()); + string deployCommand = deployment.GetProperty("deployCommand").GetString()!; + Assert.Contains("vercel pull --cwd --yes --environment preview", deployCommand); + Assert.Contains("aspire build/push api -> vcr.vercel.com///app:", deployCommand); + Assert.Contains("docker buildx imagetools inspect --format {{json .Manifest}} vcr.vercel.com///app:", deployCommand); + Assert.Contains("vercel deploy --cwd --project --prebuilt --yes", deployCommand); } [Fact] @@ -425,7 +636,9 @@ public async Task WriteDeploymentPlanProcessesEnvironmentVariables() using var document = JsonDocument.Parse(File.ReadAllText(planPath)); var deployment = Assert.Single(document.RootElement.GetProperty("deployments").EnumerateArray()); - Assert.Equal("vercel --cwd deploy --yes --project --env GREETING=", deployment.GetProperty("deployCommand").GetString()); + string deployCommand = deployment.GetProperty("deployCommand").GetString()!; + Assert.Contains("aspire build/push api -> vcr.vercel.com///app:", deployCommand); + Assert.Contains("vercel deploy --cwd --project --prebuilt --yes --env GREETING=", deployCommand); Assert.Equal("GREETING", Assert.Single(deployment.GetProperty("environmentVariables").EnumerateArray()).GetString()); Assert.DoesNotContain("hello", File.ReadAllText(planPath)); } @@ -522,7 +735,7 @@ public async Task WriteDeploymentPlanThrowsWhenContainerHasNoSourceRoot() var exception = await Assert.ThrowsAsync(() => VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken)); - Assert.Contains("does not have Aspire Dockerfile build metadata", exception.Message); + Assert.Contains("is not an Aspire image build resource", exception.Message); Assert.Contains("WithDockerfile", exception.Message); } @@ -588,6 +801,31 @@ public async Task WriteDeploymentPlanThrowsForWaitDependencies() Assert.Contains("wait/dependency ordering", exception.Message); } + [Fact] + public async Task WriteDeploymentPlanThrowsForGenericContainerRegistry() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + builder.AddVercelEnvironment("vercel"); + var registry = builder.AddContainerRegistry("registry", "registry.example.com"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithContainerRegistry(registry); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken)); + + Assert.Contains("generic container registry/image push metadata", exception.Message); + Assert.Contains("WithContainerRegistry", exception.Message); + } + [Fact] public async Task WriteDeploymentPlanThrowsForMultipleEndpointTargetPorts() { @@ -740,7 +978,7 @@ public async Task WriteDeploymentPlanThrowsForConfiguredProjectNameCollisions() } [Fact] - public async Task DeployAsyncStagesManagedProjectUsingSlugifiedProjectName() + public async Task DeployAsyncUsesOriginalSourceRootWithSlugifiedProjectName() { using var sourceRoot = TemporaryDirectory.Create("Invalid_Project"); using var outputRoot = TemporaryDirectory.Create(); @@ -753,6 +991,7 @@ public async Task DeployAsyncStagesManagedProjectUsingSlugifiedProjectName() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); builder.AddVercelEnvironment("vercel"); @@ -765,16 +1004,19 @@ public async Task DeployAsyncStagesManagedProjectUsingSlugifiedProjectName() await VercelDeploymentStep.DeployAsync(context, environment); - string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", "invalid-project"); - var invocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("deploy")); - Assert.Equal(expectedStagingRoot, invocation.WorkingDirectory); + var digestInvocation = Assert.Single(runner.Invocations, invocation => invocation.FileName == "docker" && invocation.Arguments.Contains("imagetools")); + Assert.Null(digestInvocation.WorkingDirectory); + Assert.StartsWith("vcr.vercel.com/test-team/test-project/app:", digestInvocation.Arguments[^1], StringComparison.Ordinal); + var deployInvocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("deploy")); + Assert.Contains("--project", deployInvocation.Arguments); + Assert.Contains("invalid-project", deployInvocation.Arguments); - var deployment = Assert.Single(ReadSavedState(Assert.Single(stateManager.SavedSections)).Deployments); + var deployment = Assert.Single(ReadSavedState(stateManager.SavedSections[^1]).Deployments); Assert.Equal("invalid-project", deployment.ProjectName); } [Fact] - public async Task DeployAsyncStagesManagedProjectUsingConfiguredProjectName() + public async Task DeployAsyncUsesGeneratedLocalConfigWithConfiguredProjectName() { using var sourceRoot = TemporaryDirectory.Create("source-folder"); using var outputRoot = TemporaryDirectory.Create(); @@ -787,6 +1029,7 @@ public async Task DeployAsyncStagesManagedProjectUsingConfiguredProjectName() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); builder.AddVercelEnvironment("vercel"); @@ -800,14 +1043,17 @@ public async Task DeployAsyncStagesManagedProjectUsingConfiguredProjectName() await VercelDeploymentStep.DeployAsync(context, environment); - string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", "configured-project"); - Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "Dockerfile.vercel"))); - AssertGeneratedVercelJson(expectedStagingRoot); + string expectedDeployDirectory = Path.Combine(tempRoot.Path, "api", "vercel-build-output"); + AssertGeneratedBuildOutput(expectedDeployDirectory); + Assert.False(File.Exists(Path.Combine(sourceRoot.Path, "vercel.json"))); + Assert.Contains(runner.Invocations, invocation => invocation.Arguments.Contains("link")); var deployInvocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("deploy")); + Assert.Equal(expectedDeployDirectory, deployInvocation.WorkingDirectory); Assert.Contains("--project", deployInvocation.Arguments); Assert.Contains("configured-project", deployInvocation.Arguments); + Assert.DoesNotContain("--local-config", deployInvocation.Arguments); - var state = ReadSavedState(Assert.Single(stateManager.SavedSections)); + var state = ReadSavedState(stateManager.SavedSections[^1]); var deployment = Assert.Single(state.Deployments); Assert.Equal("configured-project", deployment.ProjectName); Assert.True(deployment.ManagedByAspire); @@ -825,7 +1071,156 @@ public void BuildDeployArgumentsIncludesConfiguredOptions() string[] arguments = VercelDeploymentStep.BuildDeployArguments(options, entry); - Assert.Equal(["--scope", "team", "--cwd", "/repo/src/api", "deploy", "--yes", "--project", "api", "--prod"], arguments); + Assert.Equal(["deploy", "--scope", "team", "--cwd", "/repo/src/api", "--project", "api", "--prebuilt", "--yes", "--prod"], arguments); + } + + [Fact] + public void BuildDockerInspectDigestArgumentsUsesBuildxImagetools() + { + string[] arguments = VercelDeploymentStep.BuildDockerInspectDigestArguments("vcr.vercel.com/team/project/app:tag"); + + Assert.Equal(["buildx", "imagetools", "inspect", "--format", "{{json .Manifest}}", "vcr.vercel.com/team/project/app:tag"], arguments); + } + + [Fact] + public void GetDockerImageDigestParsesJsonString() + { + string digest = VercelDeploymentStep.GetDockerImageDigest($"\"{FakeVercelCliRunner.FakeImageDigest}\""); + + Assert.Equal(FakeVercelCliRunner.FakeImageDigest, digest); + } + + [Fact] + public void GetDockerImageDigestSelectsLinuxAmd64ManifestDigest() + { + string digest = VercelDeploymentStep.GetDockerImageDigest(FakeVercelCliRunner.FakeImageManifestJson); + + Assert.Equal(FakeVercelCliRunner.FakeImageDigest, digest); + } + + [Fact] + public void GetDockerImageDigestThrowsWhenManifestDoesNotContainLinuxAmd64Digest() + { + const string manifestJson = """ + { + "schemaVersion": 2, + "manifests": [ + { + "digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "platform": { + "architecture": "arm64", + "os": "linux" + } + } + ] + } + """; + + var exception = Assert.Throws(() => + VercelDeploymentStep.GetDockerImageDigest(manifestJson)); + + Assert.Contains("linux/amd64 manifest digest", exception.Message); + } + + [Fact] + public void DecodeUnvalidatedOidcClaimsParsesCompactJwtPayload() + { + string token = CreateTestJwt(""" + { + "owner_id": "team_123", + "owner": "test-team", + "project": "test-project", + "project_id": "prj_123" + } + """); + + var claims = VercelDeploymentStep.DecodeUnvalidatedOidcClaims(token); + + Assert.Equal("team_123", claims.OwnerId); + Assert.Equal("test-team", claims.Owner); + Assert.Equal("test-project", claims.Project); + Assert.Equal("prj_123", claims.ProjectId); + } + + [Fact] + public void DecodeUnvalidatedOidcClaimsRejectsNonCompactJwt() + { + var exception = Assert.Throws(() => + VercelDeploymentStep.DecodeUnvalidatedOidcClaims("not-a-jwt")); + + Assert.Contains("compact JWT", exception.Message); + } + + [Fact] + public void ParseDotEnvFileParsesVercelPullOutputSubset() + { + var values = VercelDeploymentStep.ParseDotEnvFile( + [ + "", + "# comment", + "VERCEL_OIDC_TOKEN=\"header.payload.signature\"", + "MULTILINE=\"line1\\nline2\"", + "QUOTED='single quoted'", + "ESCAPED=\"quote\\\"slash\\\\\"", + "MALFORMED" + ]); + + Assert.Equal("header.payload.signature", values["VERCEL_OIDC_TOKEN"]); + Assert.Equal("line1\nline2", values["MULTILINE"]); + Assert.Equal("single quoted", values["QUOTED"]); + Assert.Equal("quote\"slash\\", values["ESCAPED"]); + Assert.DoesNotContain("MALFORMED", values.Keys); + } + + [Fact] + public void ProjectListContainsProjectUsesExactJsonProjectName() + { + Assert.True(VercelDeploymentStep.ProjectListContainsProject(ProjectListJson("api", "worker"), "api")); + Assert.False(VercelDeploymentStep.ProjectListContainsProject(ProjectListJson("api-preview"), "api")); + } + + [Fact] + public void EnvironmentVariableListContainsNameUsesExactJsonKeyAndSkipsBranchScopedVariables() + { + const string output = """ + { + "envs": [ + { + "key": "API_KEY", + "target": ["preview"], + "gitBranch": "feature" + }, + { + "key": "OLD_KEY", + "target": ["preview"] + } + ] + } + """; + + Assert.False(VercelDeploymentStep.EnvironmentVariableListContainsName(output, "API_KEY")); + Assert.True(VercelDeploymentStep.EnvironmentVariableListContainsName(output, "OLD_KEY")); + Assert.False(VercelDeploymentStep.EnvironmentVariableListContainsName(output, "KEY")); + } + + [Fact] + public void BuildProjectEnvironmentVariableArgumentsUseLinkedScratchDirectory() + { + var options = new VercelEnvironmentOptionsAnnotation + { + Scope = "team" + }; + + string[] linkArguments = VercelDeploymentStep.BuildLinkProjectArguments(options, "/tmp/vercel-link", "api-project"); + string[] envArguments = VercelDeploymentStep.BuildAddProjectEnvironmentVariableArguments(options, "/tmp/vercel-link", "API_KEY", "production"); + string[] removeEnvArguments = VercelDeploymentStep.BuildRemoveProjectEnvironmentVariableArguments(options, "/tmp/vercel-link", "API_KEY", "production"); + string[] listEnvArguments = VercelDeploymentStep.BuildListProjectEnvironmentVariablesArguments(options, "/tmp/vercel-link", "production"); + + Assert.Equal(["link", "--scope", "team", "--cwd", "/tmp/vercel-link", "--yes", "--project", "api-project"], linkArguments); + Assert.Equal(["env", "add", "API_KEY", "production", "--scope", "team", "--cwd", "/tmp/vercel-link", "--yes", "--force", "--sensitive"], envArguments); + Assert.Equal(["env", "rm", "API_KEY", "production", "--scope", "team", "--cwd", "/tmp/vercel-link", "--yes"], removeEnvArguments); + Assert.Equal(["env", "ls", "production", "--scope", "team", "--cwd", "/tmp/vercel-link", "--format=json"], listEnvArguments); + Assert.DoesNotContain("--project", envArguments); } [Fact] @@ -839,7 +1234,7 @@ public void BuildDeployArgumentsIncludesTarget() string[] arguments = VercelDeploymentStep.BuildDeployArguments(options, entry); - Assert.Equal(["--cwd", "/repo/src/api", "deploy", "--yes", "--project", "api", "--target", "preview"], arguments); + Assert.Equal(["deploy", "--cwd", "/repo/src/api", "--project", "api", "--prebuilt", "--yes", "--target", "preview"], arguments); } [Fact] @@ -852,7 +1247,7 @@ public void BuildDestroyProjectArgumentsIncludesConfiguredOptions() string[] arguments = VercelDeploymentStep.BuildDestroyProjectArguments(options, "api"); - Assert.Equal(["--scope", "team", "project", "remove", "api"], arguments); + Assert.Equal(["project", "remove", "api", "--scope", "team"], arguments); } [Fact] @@ -865,7 +1260,20 @@ public void BuildValidateScopeArgumentsIncludesConfiguredOptions() string[] arguments = VercelDeploymentStep.BuildValidateScopeArguments(options); - Assert.Equal(["--scope", "team", "project", "ls", "--format=json"], arguments); + Assert.Equal(["project", "ls", "--scope", "team", "--format=json"], arguments); + } + + [Fact] + public void BuildListProjectsArgumentsIncludesFilter() + { + var options = new VercelEnvironmentOptionsAnnotation + { + Scope = "team" + }; + + string[] arguments = VercelDeploymentStep.BuildListProjectsArguments(options, "api"); + + Assert.Equal(["project", "ls", "--scope", "team", "--filter", "api", "--format=json"], arguments); } [Fact] @@ -878,7 +1286,7 @@ public void BuildInspectDeploymentArgumentsIncludesConfiguredOptions() string[] arguments = VercelDeploymentStep.BuildInspectDeploymentArguments(options, "https://api.vercel.app"); - Assert.Equal(["--scope", "team", "inspect", "https://api.vercel.app", "--wait", "--timeout", "120s", "--format=json"], arguments); + Assert.Equal(["inspect", "https://api.vercel.app", "--scope", "team", "--wait", "--timeout", "120s", "--format=json"], arguments); } [Fact] @@ -1002,9 +1410,6 @@ public async Task DeployAsyncConfiguresSecretEnvironmentVariablesBeforeDeploy() using var tempRoot = TemporaryDirectory.Create(); File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); var runner = new FakeVercelCliRunner( - new VercelCliResult(0, "", ""), - new VercelCliResult(0, "", ""), - new VercelCliResult(0, "", ""), new VercelCliResult(0, """ { "deployment": { @@ -1019,6 +1424,7 @@ public async Task DeployAsyncConfiguresSecretEnvironmentVariablesBeforeDeploy() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); var secret = builder.AddParameter("api-key", "secret-value", secret: true); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); builder.AddVercelEnvironment("vercel") @@ -1038,56 +1444,188 @@ public async Task DeployAsyncConfiguresSecretEnvironmentVariablesBeforeDeploy() await VercelDeploymentStep.DeployAsync(context, environment); - string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", projectName); - Assert.Equal(["project", "add", projectName], runner.Invocations[0].Arguments); - Assert.Equal(["--cwd", expectedStagingRoot, "link", "--yes", "--project", projectName], runner.Invocations[1].Arguments); - Assert.Equal(["--cwd", expectedStagingRoot, "env", "add", "API_KEY", "production", "--yes", "--force", "--sensitive"], runner.Invocations[2].Arguments); - Assert.Equal("secret-value", runner.Invocations[2].StandardInput); - Assert.Equal(["--cwd", expectedStagingRoot, "env", "add", "AUTH_HEADER", "production", "--yes", "--force", "--sensitive"], runner.Invocations[3].Arguments); - Assert.Equal("Bearer secret-value", runner.Invocations[3].StandardInput); - Assert.Contains("GREETING=hello", runner.Invocations[4].Arguments); - Assert.DoesNotContain(runner.Invocations[4].Arguments, argument => argument.Contains("API_KEY", StringComparison.Ordinal)); - Assert.DoesNotContain(runner.Invocations[4].Arguments, argument => argument.Contains("AUTH_HEADER", StringComparison.Ordinal)); + string expectedProjectLinkDirectory = Path.Combine(tempRoot.Path, "api", ".vercel-project"); + string expectedDeployDirectory = Path.Combine(tempRoot.Path, "api", "vercel-build-output"); + Assert.Contains(runner.Invocations, invocation => invocation.Arguments.SequenceEqual(["project", "add", projectName])); + var linkInvocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("link")); + Assert.Equal(["link", "--cwd", expectedProjectLinkDirectory, "--yes", "--project", projectName], linkInvocation.Arguments); + Assert.Equal(expectedProjectLinkDirectory, linkInvocation.WorkingDirectory); + var apiKeyInvocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("API_KEY")); + Assert.Equal(["env", "add", "API_KEY", "production", "--cwd", expectedProjectLinkDirectory, "--yes", "--force", "--sensitive"], apiKeyInvocation.Arguments); + Assert.Equal("secret-value", apiKeyInvocation.StandardInput); + Assert.Equal(expectedProjectLinkDirectory, apiKeyInvocation.WorkingDirectory); + var authHeaderInvocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("AUTH_HEADER")); + Assert.Equal(["env", "add", "AUTH_HEADER", "production", "--cwd", expectedProjectLinkDirectory, "--yes", "--force", "--sensitive"], authHeaderInvocation.Arguments); + Assert.Equal("Bearer secret-value", authHeaderInvocation.StandardInput); + Assert.Equal(expectedProjectLinkDirectory, authHeaderInvocation.WorkingDirectory); + Assert.Contains(runner.Invocations, invocation => invocation.Arguments.SequenceEqual(["pull", "--cwd", expectedProjectLinkDirectory, "--yes", "--environment", "production"])); + Assert.Contains(runner.Invocations, invocation => invocation.FileName == "docker" && invocation.Arguments.SequenceEqual(["login", "vcr.vercel.com", "--username", "team_test", "--password-stdin"])); + Assert.Contains(runner.Invocations, invocation => invocation.FileName == "docker" && invocation.Arguments.Contains("imagetools")); + AssertGeneratedBuildOutput(expectedDeployDirectory); + var deployInvocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("deploy")); + Assert.Contains("GREETING=hello", deployInvocation.Arguments); + Assert.DoesNotContain(deployInvocation.Arguments, argument => argument.Contains("API_KEY", StringComparison.Ordinal)); + Assert.DoesNotContain(deployInvocation.Arguments, argument => argument.Contains("AUTH_HEADER", StringComparison.Ordinal)); + + var deployment = Assert.Single(ReadSavedState(stateManager.SavedSections[^1]).Deployments); + Assert.Equal(["API_KEY", "AUTH_HEADER"], deployment.ProjectEnvironmentVariables); + Assert.DoesNotContain("secret-value", JsonSerializer.Serialize(ReadSavedState(stateManager.SavedSections[^1])), StringComparison.Ordinal); } [Fact] - public async Task BuildDeployArgumentsDoesNotPassConnectionStringEnvironmentVariablesOnCommandLine() + public async Task DeployAsyncRemovesStaleProjectEnvironmentVariablesFromPreviousState() { - using var sourceRoot = TemporaryDirectory.Create(); + using var sourceRoot = TemporaryDirectory.Create("stale-env-project"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, "https://stale-env-project.vercel.app", ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); - builder.Configuration["ConnectionStrings:db"] = "Host=example.com;Username=app;Password=secret"; - var connectionString = builder.AddConnectionString("db"); + var secret = builder.AddParameter("api-key", "secret-value", secret: true); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") .WithDockerfile(sourceRoot.Path, "Dockerfile") - .WithEnvironment("DATABASE_URL", connectionString); + .WithEnvironment("API_KEY", secret); using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + string projectName = VercelDeploymentStep.GetVercelProjectName(entry); + stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( + SchemaVersion: 1, + Environment: "vercel", + Scope: null, + Target: null, + Production: false, + Deployments: + [ + new( + ResourceName: "api", + ProjectName: projectName, + ProjectId: null, + DeploymentId: "dpl_previous", + DeploymentUrl: "https://previous.vercel.app", + SourceRoot: sourceRoot.Path, + ManagedByAspire: true) + { + ProjectEnvironmentVariables = ["API_KEY", "OLD_KEY"] + } + ]), new JsonSerializerOptions(JsonSerializerDefaults.Web))); + var context = CreatePipelineStepContext(builder, app); - string[] arguments = await VercelDeploymentStep.BuildDeployArgumentsAsync( - builder.ExecutionContext, - NullLogger.Instance, - environment.GetVercelOptions(), - entry, - TestContext.Current.CancellationToken); + await VercelDeploymentStep.DeployAsync(context, environment); - Assert.DoesNotContain(arguments, argument => argument.Contains("DATABASE_URL", StringComparison.Ordinal)); + string expectedProjectLinkDirectory = Path.Combine(tempRoot.Path, "api", ".vercel-project"); + Assert.Contains(runner.Invocations, invocation => invocation.Arguments.SequenceEqual(["env", "rm", "OLD_KEY", "preview", "--cwd", expectedProjectLinkDirectory, "--yes"])); + Assert.DoesNotContain(runner.Invocations, invocation => invocation.Arguments.SequenceEqual(["env", "rm", "API_KEY", "preview", "--cwd", expectedProjectLinkDirectory, "--yes"])); + var deployment = Assert.Single(ReadSavedState(stateManager.SavedSections[^1]).Deployments); + Assert.Equal(["API_KEY"], deployment.ProjectEnvironmentVariables); } [Fact] - public async Task BuildDeployArgumentsThrowsForInvalidEnvironmentVariableNames() + public async Task ValidatePrerequisitesPreservesPreviousProjectEnvironmentVariablesUntilDeploySucceeds() { - using var sourceRoot = TemporaryDirectory.Create(); + using var sourceRoot = TemporaryDirectory.Create("retry-safe-env-project"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner(); + var stateManager = new FakeDeploymentStateManager(); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); - builder.AddVercelEnvironment("vercel"); - builder.AddContainer("api", "api") + var secret = builder.AddParameter("api-key", "secret-value", secret: true); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithEnvironment("API_KEY", secret); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + string projectName = VercelDeploymentStep.GetVercelProjectName(entry); + stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( + SchemaVersion: 1, + Environment: "vercel", + Scope: null, + Target: null, + Production: false, + Deployments: + [ + new( + ResourceName: "api", + ProjectName: projectName, + ProjectId: null, + DeploymentId: "dpl_previous", + DeploymentUrl: "https://previous.vercel.app", + SourceRoot: sourceRoot.Path, + ManagedByAspire: true) + { + ProjectEnvironmentVariables = ["API_KEY", "OLD_KEY"] + } + ]), new JsonSerializerOptions(JsonSerializerDefaults.Web))); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.ValidatePrerequisitesAsync(context, environment); + + string expectedProjectLinkDirectory = Path.Combine(tempRoot.Path, "api", ".vercel-project"); + Assert.Contains(runner.Invocations, invocation => invocation.Arguments.SequenceEqual(["env", "rm", "OLD_KEY", "preview", "--cwd", expectedProjectLinkDirectory, "--yes"])); + var initialState = ReadSavedState(Assert.Single(stateManager.SavedSections)); + var deployment = Assert.Single(initialState.Deployments); + Assert.Equal(["API_KEY", "OLD_KEY"], deployment.ProjectEnvironmentVariables); + } + + [Fact] + public async Task BuildDeployArgumentsDoesNotPassConnectionStringEnvironmentVariablesOnCommandLine() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + builder.Configuration["ConnectionStrings:db"] = "Host=example.com;Username=app;Password=secret"; + var connectionString = builder.AddConnectionString("db"); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithEnvironment("DATABASE_URL", connectionString); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + + string[] arguments = await VercelDeploymentStep.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken); + + Assert.DoesNotContain(arguments, argument => argument.Contains("DATABASE_URL", StringComparison.Ordinal)); + } + + [Fact] + public async Task BuildDeployArgumentsThrowsForInvalidEnvironmentVariableNames() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") .WithDockerfile(sourceRoot.Path, "Dockerfile") .WithEnvironment("INVALID-NAME", "value"); @@ -1424,7 +1962,7 @@ public async Task BuildDeployArgumentsThrowsForCommandLineArguments() } [Fact] - public async Task BuildDeployArgumentsThrowsForDockerBuildArguments() + public async Task BuildDeployArgumentsAllowsDockerBuildArguments() { using var sourceRoot = TemporaryDirectory.Create(); File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); @@ -1440,19 +1978,18 @@ public async Task BuildDeployArgumentsThrowsForDockerBuildArguments() var environment = Assert.Single(model.Resources.OfType()); var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); - var exception = await Assert.ThrowsAsync(() => - VercelDeploymentStep.BuildDeployArgumentsAsync( - builder.ExecutionContext, - NullLogger.Instance, - environment.GetVercelOptions(), - entry, - TestContext.Current.CancellationToken)); + string[] arguments = await VercelDeploymentStep.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken); - Assert.Contains("build arguments", exception.Message); + Assert.Contains("--prebuilt", arguments); } [Fact] - public async Task DeployAsyncDoesNotRequireContainerRegistryOrImageManager() + public async Task DeployAsyncDoesNotBuildOrPushImagesItself() { using var sourceRoot = TemporaryDirectory.Create("registry-free-project"); using var outputRoot = TemporaryDirectory.Create(); @@ -1467,6 +2004,7 @@ public async Task DeployAsyncDoesNotRequireContainerRegistryOrImageManager() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); builder.Services.AddSingleton(imageManager); @@ -1481,8 +2019,8 @@ public async Task DeployAsyncDoesNotRequireContainerRegistryOrImageManager() await VercelDeploymentStep.DeployAsync(context, environment); Assert.Equal(0, imageManager.CallCount); - Assert.Equal(3, runner.Invocations.Count); - Assert.Single(stateManager.SavedSections); + Assert.Equal(11, runner.Invocations.Count); + Assert.Equal(2, stateManager.SavedSections.Count); } [Fact] @@ -1505,6 +2043,7 @@ public async Task DeployAsyncRunsVercelCliAndSavesDeploymentState() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); var vercel = builder.AddVercelEnvironment("vercel") @@ -1521,16 +2060,19 @@ public async Task DeployAsyncRunsVercelCliAndSavesDeploymentState() await VercelDeploymentStep.DeployAsync(context, environment); + var digestInvocation = Assert.Single(runner.Invocations, invocation => invocation.FileName == "docker" && invocation.Arguments.Contains("imagetools")); var invocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("deploy")); - string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", "vercel-state-project"); + string expectedDeployDirectory = Path.Combine(tempRoot.Path, "api", "vercel-build-output"); + Assert.Equal(VercelDeploymentStep.BuildDockerInspectDigestArguments(digestInvocation.Arguments[^1]), digestInvocation.Arguments); + Assert.StartsWith("vcr.vercel.com/test-team/test-project/app:", digestInvocation.Arguments[^1], StringComparison.Ordinal); Assert.Equal("vercel", invocation.FileName); - Assert.Equal(expectedStagingRoot, invocation.WorkingDirectory); - Assert.Equal(["--scope", "team", "--cwd", expectedStagingRoot, "deploy", "--yes", "--project", "vercel-state-project", "--prod", "--env", "GREETING=hello"], invocation.Arguments); + Assert.Equal(expectedDeployDirectory, invocation.WorkingDirectory); + Assert.Equal(["deploy", "--scope", "team", "--cwd", expectedDeployDirectory, "--project", "vercel-state-project", "--prebuilt", "--yes", "--prod", "--env", "GREETING=hello"], invocation.Arguments); Assert.Null(invocation.StandardInput); - Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "Dockerfile.vercel"))); - AssertGeneratedVercelJson(expectedStagingRoot); - var inspectInvocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("inspect")); - Assert.Equal(["--scope", "team", "inspect", "https://api.vercel.app", "--wait", "--timeout", "120s", "--format=json"], inspectInvocation.Arguments); + AssertGeneratedBuildOutput(expectedDeployDirectory); + Assert.False(File.Exists(Path.Combine(sourceRoot.Path, "vercel.json"))); + var inspectInvocation = Assert.Single(runner.Invocations, invocation => invocation.FileName == "vercel" && invocation.Arguments.Contains("inspect")); + Assert.Equal(["inspect", "https://api.vercel.app", "--scope", "team", "--wait", "--timeout", "120s", "--format=json"], inspectInvocation.Arguments); Assert.Collection( context.Summary.Items, @@ -1545,7 +2087,8 @@ public async Task DeployAsyncRunsVercelCliAndSavesDeploymentState() Assert.Equal("https://vercel-state-project.vercel.app", summary.Value); }); - var savedSection = Assert.Single(stateManager.SavedSections); + Assert.Equal(2, stateManager.SavedSections.Count); + var savedSection = stateManager.SavedSections[^1]; Assert.Equal("communitytoolkit.vercel.vercel", savedSection.SectionName); string stateJson = savedSection.Data.First().Value!.GetValue(); Assert.Contains("schemaVersion", stateJson); @@ -1557,6 +2100,8 @@ public async Task DeployAsyncRunsVercelCliAndSavesDeploymentState() var deployment = Assert.Single(state.Deployments); Assert.True(state.Production); Assert.Equal("https://vercel-state-project.vercel.app", deployment.ProductionUrl); + Assert.Equal(FakeVercelCliRunner.FakeImageDigest, deployment.VcrImageDigest); + Assert.Equal(3, deployment.BuildOutputApiVersion); } [Fact] @@ -1579,6 +2124,7 @@ public async Task DeployAsyncSavesVerifiedResourcesBeforeLaterResourceFails() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); builder.AddVercelEnvironment("vercel"); @@ -1595,10 +2141,21 @@ public async Task DeployAsyncSavesVerifiedResourcesBeforeLaterResourceFails() VercelDeploymentStep.DeployAsync(context, environment)); Assert.Contains("worker deploy failed", exception.Message); - var state = ReadSavedState(Assert.Single(stateManager.SavedSections)); - var deployment = Assert.Single(state.Deployments); - Assert.Equal("api", deployment.ResourceName); - Assert.Equal("partial-api", deployment.ProjectName); + var state = ReadSavedState(stateManager.SavedSections[^1]); + Assert.Collection( + state.Deployments.OrderBy(static deployment => deployment.ResourceName), + deployment => + { + Assert.Equal("api", deployment.ResourceName); + Assert.Equal("partial-api", deployment.ProjectName); + Assert.Equal("https://partial-api.vercel.app", deployment.DeploymentUrl); + }, + deployment => + { + Assert.Equal("worker", deployment.ResourceName); + Assert.Equal("partial-worker", deployment.ProjectName); + Assert.Null(deployment.DeploymentUrl); + }); } [Fact] @@ -1624,6 +2181,7 @@ public async Task DeployAsyncPreservesPreviousManagedStateForResourcesRemovedFro var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); builder.AddVercelEnvironment("vercel"); @@ -1636,7 +2194,7 @@ public async Task DeployAsyncPreservesPreviousManagedStateForResourcesRemovedFro await VercelDeploymentStep.DeployAsync(context, environment); - var state = ReadSavedState(Assert.Single(stateManager.SavedSections)); + var state = ReadSavedState(stateManager.SavedSections[^1]); Assert.Collection( state.Deployments.OrderBy(static deployment => deployment.ResourceName), deployment => @@ -1666,6 +2224,7 @@ public async Task DeployAsyncMarksLinkedVercelProjectsAsUnmanaged() "projectName": "linked-project" } """); + string linkedProjectJson = File.ReadAllText(Path.Combine(sourceRoot.Path, ".vercel", "project.json")); var runner = new FakeVercelCliRunner( new VercelCliResult(0, "https://linked-project.vercel.app", ""), ReadyInspectResult()); @@ -1673,6 +2232,7 @@ public async Task DeployAsyncMarksLinkedVercelProjectsAsUnmanaged() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); builder.AddVercelEnvironment("vercel"); @@ -1685,25 +2245,29 @@ public async Task DeployAsyncMarksLinkedVercelProjectsAsUnmanaged() await VercelDeploymentStep.DeployAsync(context, environment); - var state = ReadSavedState(Assert.Single(stateManager.SavedSections)); + var state = ReadSavedState(stateManager.SavedSections[^1]); var deployment = Assert.Single(state.Deployments); Assert.Equal("linked-project", deployment.ProjectName); Assert.Equal("prj_linked", deployment.ProjectId); Assert.False(deployment.ManagedByAspire); - string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", "linked-project-source"); - Assert.True(File.Exists(Path.Combine(expectedStagingRoot, ".vercel", "project.json"))); - Assert.False(File.Exists(Path.Combine(expectedStagingRoot, ".vercel", "cache.json"))); + var deployInvocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("deploy")); + Assert.Equal(Path.Combine(tempRoot.Path, "api", "vercel-build-output"), deployInvocation.WorkingDirectory); + Assert.Contains("--project", deployInvocation.Arguments); + Assert.Contains("prj_linked", deployInvocation.Arguments); + Assert.False(Directory.Exists(Path.Combine(tempRoot.Path, "api", ".vercel"))); + Assert.Equal(linkedProjectJson, File.ReadAllText(Path.Combine(sourceRoot.Path, ".vercel", "project.json"))); + Assert.True(File.Exists(Path.Combine(sourceRoot.Path, ".vercel", "cache.json"))); } [Fact] - public async Task DeployAsyncSkipsIgnoredDirectoriesWhenStagingManagedProjects() + public async Task DeployAsyncDoesNotCopySourceRoot() { - using var sourceRoot = TemporaryDirectory.Create("ignored-staging-project"); + using var sourceRoot = TemporaryDirectory.Create("uncopied-source-project"); using var outputRoot = TemporaryDirectory.Create(); using var tempRoot = TemporaryDirectory.Create(); File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); - File.WriteAllText(Path.Combine(sourceRoot.Path, ".vercelignore"), "node_modules"); + File.WriteAllText(Path.Combine(sourceRoot.Path, ".dockerignore"), "node_modules"); Directory.CreateDirectory(Path.Combine(sourceRoot.Path, ".git")); File.WriteAllText(Path.Combine(sourceRoot.Path, ".git", "config"), "ignored"); Directory.CreateDirectory(Path.Combine(sourceRoot.Path, "node_modules", "pkg")); @@ -1717,6 +2281,7 @@ public async Task DeployAsyncSkipsIgnoredDirectoriesWhenStagingManagedProjects() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); builder.AddVercelEnvironment("vercel"); @@ -1729,51 +2294,19 @@ public async Task DeployAsyncSkipsIgnoredDirectoriesWhenStagingManagedProjects() await VercelDeploymentStep.DeployAsync(context, environment); - string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", "ignored-staging-project"); - Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "Dockerfile.vercel"))); - AssertGeneratedVercelJson(expectedStagingRoot); - Assert.True(File.Exists(Path.Combine(expectedStagingRoot, ".vercelignore"))); - Assert.False(Directory.Exists(Path.Combine(expectedStagingRoot, ".git"))); - Assert.False(Directory.Exists(Path.Combine(expectedStagingRoot, "node_modules"))); - Assert.False(Directory.Exists(Path.Combine(expectedStagingRoot, ".vercel"))); + string tempResourceRoot = Path.Combine(tempRoot.Path, "api"); + AssertGeneratedBuildOutput(Path.Combine(tempResourceRoot, "vercel-build-output")); + Assert.False(File.Exists(Path.Combine(tempResourceRoot, "vercel.json"))); + Assert.False(File.Exists(Path.Combine(tempResourceRoot, "Dockerfile"))); + Assert.False(File.Exists(Path.Combine(tempResourceRoot, ".dockerignore"))); + Assert.False(Directory.Exists(Path.Combine(tempResourceRoot, ".git"))); + Assert.False(Directory.Exists(Path.Combine(tempResourceRoot, "node_modules"))); + Assert.False(Directory.Exists(Path.Combine(tempResourceRoot, ".vercel"))); + Assert.True(Directory.Exists(Path.Combine(tempResourceRoot, "vercel-build-output", ".vercel", "output"))); } [Fact] - public async Task DeployAsyncWarnsWhenSourceUploadMayIncludeEnvironmentFiles() - { - using var sourceRoot = TemporaryDirectory.Create("source-warning-project"); - using var outputRoot = TemporaryDirectory.Create(); - using var tempRoot = TemporaryDirectory.Create(); - File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); - File.WriteAllText(Path.Combine(sourceRoot.Path, ".env"), "SECRET=value"); - var runner = new FakeVercelCliRunner( - new VercelCliResult(0, "https://source-warning-project.vercel.app", ""), - ReadyInspectResult()); - var stateManager = new FakeDeploymentStateManager(); - var logger = new RecordingLogger(); - - var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); - builder.Services.AddSingleton(runner); - builder.Services.AddSingleton(stateManager); - builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); - builder.AddVercelEnvironment("vercel"); - builder.AddContainer("api", "api") - .WithDockerfile(sourceRoot.Path, "Dockerfile"); - - using var app = builder.Build(); - var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); - var context = CreatePipelineStepContext(builder, app, logger); - - await VercelDeploymentStep.DeployAsync(context, environment); - - var warning = Assert.Single(logger.Entries, entry => entry.Level == LogLevel.Warning); - Assert.Contains("source root contains environment files that may be uploaded to Vercel", warning.Message); - Assert.Contains(".env", warning.Message); - Assert.DoesNotContain("SECRET=value", warning.Message); - } - - [Fact] - public async Task DeployAsyncThrowsWhenSourceRootContainsSymbolicLink() + public async Task DeployAsyncAllowsSourceRootSymbolicLinks() { if (OperatingSystem.IsWindows()) { @@ -1787,11 +2320,14 @@ public async Task DeployAsyncThrowsWhenSourceRootContainsSymbolicLink() File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); File.WriteAllText(Path.Combine(outsideRoot.Path, "outside.txt"), "outside"); File.CreateSymbolicLink(Path.Combine(sourceRoot.Path, "outside-link.txt"), Path.Combine(outsideRoot.Path, "outside.txt")); - var runner = new FakeVercelCliRunner(); + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, "https://symlink.vercel.app", ""), + ReadyInspectResult()); var stateManager = new FakeDeploymentStateManager(); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); builder.AddVercelEnvironment("vercel"); @@ -1802,12 +2338,9 @@ public async Task DeployAsyncThrowsWhenSourceRootContainsSymbolicLink() var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); var context = CreatePipelineStepContext(builder, app); - var exception = await Assert.ThrowsAsync(() => - VercelDeploymentStep.DeployAsync(context, environment)); + await VercelDeploymentStep.DeployAsync(context, environment); - Assert.Contains("symbolic link", exception.Message); - Assert.Contains("outside-link.txt", exception.Message); - Assert.Empty(runner.Invocations); + Assert.Contains(runner.Invocations, invocation => invocation.FileName == "docker" && invocation.Arguments.Contains("imagetools")); } [Fact] @@ -1823,6 +2356,7 @@ public async Task DeployAsyncThrowsWhenVercelJsonAlreadyConfiguresServices() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); builder.AddVercelEnvironment("vercel"); @@ -1836,7 +2370,7 @@ public async Task DeployAsyncThrowsWhenVercelJsonAlreadyConfiguresServices() var exception = await Assert.ThrowsAsync(() => VercelDeploymentStep.DeployAsync(context, environment)); - Assert.Contains("existing 'services' configuration", exception.Message); + Assert.Contains("top-level 'services'", exception.Message); Assert.Empty(runner.Invocations); } @@ -1857,6 +2391,7 @@ public async Task DeployAsyncThrowsWhenVercelJsonContainsUnsupportedServicesMode var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); builder.AddVercelEnvironment("vercel"); @@ -1875,22 +2410,7 @@ public async Task DeployAsyncThrowsWhenVercelJsonContainsUnsupportedServicesMode } [Fact] - public void GetSourceUploadWarningPathsHonorsVercelIgnore() - { - using var sourceRoot = TemporaryDirectory.Create("ignored-source-warning-project"); - File.WriteAllText(Path.Combine(sourceRoot.Path, ".vercelignore"), """ - .env* - """); - File.WriteAllText(Path.Combine(sourceRoot.Path, ".env.local"), "SECRET=value"); - File.WriteAllText(Path.Combine(sourceRoot.Path, ".env.example"), "DOCUMENTED=value"); - - var warnings = VercelDeploymentStep.GetSourceUploadWarningPaths(sourceRoot.Path); - - Assert.Empty(warnings); - } - - [Fact] - public async Task DeployAsyncStagesGeneratedDockerfileBeforeRunningVercelCli() + public async Task DeployAsyncAllowsGeneratedDockerfile() { using var sourceRoot = TemporaryDirectory.Create("generated-vercel-project"); using var outputRoot = TemporaryDirectory.Create(); @@ -1903,16 +2423,19 @@ public async Task DeployAsyncStagesGeneratedDockerfileBeforeRunningVercelCli() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); builder.AddVercelEnvironment("vercel"); - builder.AddContainer("api", "api") + var api = builder.AddContainer("api", "api") .WithDockerfileFactory(sourceRoot.Path, _ => Task.FromResult(""" FROM node:22-alpine WORKDIR /app COPY server.mjs . CMD ["node", "server.mjs"] """)); + Assert.True(api.Resource.TryGetLastAnnotation(out var dockerfile)); + dockerfile.BuildContextIgnoreContent = "node_modules\n.env*\n"; using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -1921,21 +2444,16 @@ COPY server.mjs . await VercelDeploymentStep.DeployAsync(context, environment); - var invocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("deploy")); - string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", "generated-vercel-project"); - Assert.Equal(expectedStagingRoot, invocation.WorkingDirectory); - Assert.Equal(["--cwd", expectedStagingRoot, "deploy", "--yes", "--project", "generated-vercel-project"], invocation.Arguments); - Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "Dockerfile.vercel"))); - Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "server.mjs"))); - AssertGeneratedVercelJson(expectedStagingRoot); - Assert.Contains("FROM node:22-alpine", File.ReadAllText(Path.Combine(expectedStagingRoot, "Dockerfile.vercel"))); - - var savedSection = Assert.Single(stateManager.SavedSections); - Assert.Contains("generated-vercel-project", savedSection.Data.ToJsonString()); + Assert.Contains(runner.Invocations, invocation => invocation.FileName == "docker" && invocation.Arguments.Contains("imagetools")); + Assert.False(File.Exists(Path.Combine(sourceRoot.Path, "Dockerfile"))); + Assert.False(File.Exists(Path.Combine(sourceRoot.Path, "Dockerfile.dockerignore"))); + Assert.False(File.Exists(Path.Combine(sourceRoot.Path, "Dockerfile.generated"))); + Assert.False(File.Exists(Path.Combine(sourceRoot.Path, "Dockerfile.generated.dockerignore"))); + Assert.NotEmpty(stateManager.SavedSections); } [Fact] - public async Task DeployAsyncStagesCustomDockerfileNameBeforeRunningVercelCli() + public async Task DeployAsyncAllowsCustomDockerfileName() { using var sourceRoot = TemporaryDirectory.Create("custom-vercel-project"); using var outputRoot = TemporaryDirectory.Create(); @@ -1949,6 +2467,7 @@ public async Task DeployAsyncStagesCustomDockerfileNameBeforeRunningVercelCli() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); builder.AddVercelEnvironment("vercel"); @@ -1961,13 +2480,8 @@ public async Task DeployAsyncStagesCustomDockerfileNameBeforeRunningVercelCli() await VercelDeploymentStep.DeployAsync(context, environment); - var invocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("deploy")); - string expectedStagingRoot = Path.Combine(tempRoot.Path, "api", "custom-vercel-project"); - Assert.Equal(expectedStagingRoot, invocation.WorkingDirectory); - Assert.Equal(["--cwd", expectedStagingRoot, "deploy", "--yes", "--project", "custom-vercel-project"], invocation.Arguments); - Assert.Equal("FROM nginx:alpine", File.ReadAllText(Path.Combine(expectedStagingRoot, "Dockerfile.vercel"))); - AssertGeneratedVercelJson(expectedStagingRoot); - Assert.True(File.Exists(Path.Combine(expectedStagingRoot, "index.html"))); + Assert.Contains(runner.Invocations, invocation => invocation.FileName == "docker" && invocation.Arguments.Contains("imagetools")); + Assert.NotEmpty(stateManager.SavedSections); } [Fact] @@ -1975,10 +2489,14 @@ public async Task DeployAsyncThrowsWhenVercelCliFails() { using var sourceRoot = TemporaryDirectory.Create(); File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); - var runner = new FakeVercelCliRunner(new VercelCliResult(1, "ignored stdout", "deploy failed")); + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, "54.18.6", ""), + new VercelCliResult(0, "test-user", ""), + new VercelCliResult(1, "ignored stdout", "deploy failed")); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(new FakeDeploymentStateManager()); builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") @@ -1992,7 +2510,7 @@ public async Task DeployAsyncThrowsWhenVercelCliFails() var exception = await Assert.ThrowsAsync(() => VercelDeploymentStep.DeployAsync(context, environment)); - Assert.Contains("Failed to deploy resource 'api' to Vercel using 'vercel' (exit code 1). deploy failed", exception.Message); + Assert.Contains("Failed to deploy prebuilt resource 'api' to Vercel using 'vercel' (exit code 1). deploy failed", exception.Message); } [Fact] @@ -2007,6 +2525,7 @@ public async Task DeployAsyncThrowsWhenVercelDeployOutputHasNoDeploymentUrl() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); builder.AddVercelEnvironment("vercel"); @@ -2021,8 +2540,10 @@ public async Task DeployAsyncThrowsWhenVercelDeployOutputHasNoDeploymentUrl() VercelDeploymentStep.DeployAsync(context, environment)); Assert.Contains("did not contain an HTTP or HTTPS deployment URL", exception.Message); - Assert.Equal(2, runner.Invocations.Count); - Assert.Empty(stateManager.SavedSections); + Assert.Equal(10, runner.Invocations.Count); + var state = ReadSavedState(Assert.Single(stateManager.SavedSections)); + var deployment = Assert.Single(state.Deployments); + Assert.Null(deployment.DeploymentUrl); } [Fact] @@ -2039,6 +2560,7 @@ public async Task DeployAsyncThrowsWhenVercelInspectFails() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); builder.AddVercelEnvironment("vercel"); @@ -2054,7 +2576,9 @@ public async Task DeployAsyncThrowsWhenVercelInspectFails() VercelDeploymentStep.DeployAsync(context, environment)); Assert.Contains("Failed to verify Vercel deployment for resource 'api' using 'vercel' (exit code 1). inspect failed", exception.Message); - Assert.Empty(stateManager.SavedSections); + var state = ReadSavedState(Assert.Single(stateManager.SavedSections)); + var deployment = Assert.Single(state.Deployments); + Assert.Null(deployment.DeploymentUrl); } [Fact] @@ -2071,6 +2595,7 @@ public async Task DeployAsyncThrowsWhenVercelInspectOmitsReadyState() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); builder.AddVercelEnvironment("vercel"); @@ -2085,7 +2610,9 @@ public async Task DeployAsyncThrowsWhenVercelInspectOmitsReadyState() VercelDeploymentStep.DeployAsync(context, environment)); Assert.Contains("did not include a deployment ready state", exception.Message); - Assert.Empty(stateManager.SavedSections); + var state = ReadSavedState(Assert.Single(stateManager.SavedSections)); + var deployment = Assert.Single(state.Deployments); + Assert.Null(deployment.DeploymentUrl); } [Fact] @@ -2102,6 +2629,7 @@ public async Task DeployAsyncThrowsWhenVercelInspectReportsNonReadyState() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); builder.AddVercelEnvironment("vercel"); @@ -2117,7 +2645,9 @@ public async Task DeployAsyncThrowsWhenVercelInspectReportsNonReadyState() VercelDeploymentStep.DeployAsync(context, environment)); Assert.Contains("finished with state 'ERROR' instead of 'READY'", exception.Message); - Assert.Empty(stateManager.SavedSections); + var state = ReadSavedState(Assert.Single(stateManager.SavedSections)); + var deployment = Assert.Single(state.Deployments); + Assert.Null(deployment.DeploymentUrl); } [Fact] @@ -2129,6 +2659,7 @@ public async Task ValidateCliPrerequisitesRunsVersionAndWhoami() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.AddVercelEnvironment("vercel"); using var app = builder.Build(); @@ -2154,6 +2685,7 @@ public async Task ValidateCliPrerequisitesValidatesConfiguredScope() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.AddVercelEnvironment("vercel") .WithVercelScope("team"); @@ -2168,7 +2700,7 @@ public async Task ValidateCliPrerequisitesValidatesConfiguredScope() runner.Invocations, invocation => Assert.Equal(["--version"], invocation.Arguments), invocation => Assert.Equal(["whoami"], invocation.Arguments), - invocation => Assert.Equal(["--scope", "team", "project", "ls", "--format=json"], invocation.Arguments)); + invocation => Assert.Equal(["project", "ls", "--scope", "team", "--format=json"], invocation.Arguments)); } [Fact] @@ -2181,6 +2713,7 @@ public async Task ValidateCliPrerequisitesThrowsWhenScopeValidationFails() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.AddVercelEnvironment("vercel") .WithVercelScope("missing-team"); @@ -2204,6 +2737,7 @@ public async Task ValidateCliPrerequisitesThrowsWhenWhoamiFails() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.AddVercelEnvironment("vercel"); using var app = builder.Build(); @@ -2224,6 +2758,7 @@ public async Task ValidateCliPrerequisitesThrowsWhenVersionFailsWithStandardOutp var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.AddVercelEnvironment("vercel"); using var app = builder.Build(); @@ -2244,6 +2779,7 @@ public async Task ValidateCliPrerequisitesThrowsWhenVersionIsTooOld() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.AddVercelEnvironment("vercel"); using var app = builder.Build(); @@ -2265,6 +2801,7 @@ public async Task ValidateCliPrerequisitesThrowsWhenVersionCannotBeParsed() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.AddVercelEnvironment("vercel"); using var app = builder.Build(); @@ -2288,6 +2825,7 @@ public async Task ValidatePrerequisitesThrowsWhenNoResourcesArePublished() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); var vercel = builder.AddVercelEnvironment("vercel"); using var app = builder.Build(); @@ -2298,7 +2836,7 @@ public async Task ValidatePrerequisitesThrowsWhenNoResourcesArePublished() var exception = await Assert.ThrowsAsync(() => VercelDeploymentStep.ValidatePrerequisitesAsync(context, environment)); - Assert.Contains("No Dockerfile-backed compute resources target Vercel", exception.Message); + Assert.Contains("No image-build compute resources target Vercel", exception.Message); } [Fact] @@ -2307,8 +2845,10 @@ public async Task DestroyAsyncDeletesProjectsFromSavedDeploymentState() var runner = new FakeVercelCliRunner( new(0, "54.18.6", ""), new(0, "davidfowl-6717", ""), - new(0, "project-list", ""), + new(0, ProjectListJson("a-project", "z-project"), ""), + new(0, ProjectListJson("a-project"), ""), new(0, "", ""), + new(0, ProjectListJson("z-project"), ""), new(0, "", "")); var stateManager = new FakeDeploymentStateManager(); stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( @@ -2325,6 +2865,7 @@ public async Task DestroyAsyncDeletesProjectsFromSavedDeploymentState() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); var vercel = builder.AddVercelEnvironment("vercel") .WithVercelScope("team"); @@ -2340,15 +2881,17 @@ public async Task DestroyAsyncDeletesProjectsFromSavedDeploymentState() runner.Invocations, invocation => Assert.Equal(["--version"], invocation.Arguments), invocation => Assert.Equal(["whoami"], invocation.Arguments), - invocation => Assert.Equal(["--scope", "team", "project", "ls", "--format=json"], invocation.Arguments), + invocation => Assert.Equal(["project", "ls", "--scope", "team", "--format=json"], invocation.Arguments), + invocation => Assert.Equal(["project", "ls", "--scope", "team", "--filter", "a-project", "--format=json"], invocation.Arguments), invocation => { - Assert.Equal(["--scope", "team", "project", "remove", "a-project"], invocation.Arguments); + Assert.Equal(["project", "remove", "a-project", "--scope", "team"], invocation.Arguments); Assert.Equal("y\n", invocation.StandardInput); }, + invocation => Assert.Equal(["project", "ls", "--scope", "team", "--filter", "z-project", "--format=json"], invocation.Arguments), invocation => { - Assert.Equal(["--scope", "team", "project", "remove", "z-project"], invocation.Arguments); + Assert.Equal(["project", "remove", "z-project", "--scope", "team"], invocation.Arguments); Assert.Equal("y\n", invocation.StandardInput); }); Assert.Single(stateManager.DeletedSections); @@ -2364,6 +2907,7 @@ public async Task DestroyAsyncDoesNotFallBackToConfiguredDeploymentsWhenStateIsM var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") @@ -2390,6 +2934,7 @@ public async Task DestroyAsyncAddsSummaryWhenNoStateExists() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(new FakeDeploymentStateManager()); var vercel = builder.AddVercelEnvironment("vercel"); @@ -2423,6 +2968,7 @@ public async Task DestroyAsyncRejectsDeploymentStateFromDifferentScope() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.AddVercelEnvironment("vercel") .WithVercelScope("team-b"); @@ -2446,6 +2992,7 @@ public async Task DestroyAsyncSkipsUnmanagedProjectsAndClearsState() var runner = new FakeVercelCliRunner( new(0, "54.18.6", ""), new(0, "davidfowl-6717", ""), + new(0, ProjectListJson("managed-project"), ""), new(0, "", "")); var stateManager = new FakeDeploymentStateManager(); stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( @@ -2461,6 +3008,7 @@ public async Task DestroyAsyncSkipsUnmanagedProjectsAndClearsState() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.AddVercelEnvironment("vercel"); @@ -2474,6 +3022,7 @@ public async Task DestroyAsyncSkipsUnmanagedProjectsAndClearsState() runner.Invocations, invocation => Assert.Equal(["--version"], invocation.Arguments), invocation => Assert.Equal(["whoami"], invocation.Arguments), + invocation => Assert.Equal(["project", "ls", "--filter", "managed-project", "--format=json"], invocation.Arguments), invocation => Assert.Equal(["project", "remove", "managed-project"], invocation.Arguments)); Assert.Single(stateManager.DeletedSections); } @@ -2495,6 +3044,7 @@ public async Task DestroyAsyncSkipsCliWhenStateHasOnlyUnmanagedProjects() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.AddVercelEnvironment("vercel"); @@ -2508,13 +3058,52 @@ public async Task DestroyAsyncSkipsCliWhenStateHasOnlyUnmanagedProjects() Assert.Single(stateManager.DeletedSections); } + [Fact] + public async Task DestroyAsyncRemovesTrackedEnvironmentVariablesFromUnmanagedProjects() + { + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + var runner = new FakeVercelCliRunner(); + var stateManager = new FakeDeploymentStateManager(); + stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( + 1, + "vercel", + null, + null, + false, + [ + new("docs", "preexisting-project", "prj_existing", "dpl_2", "https://preexisting-project.vercel.app", "/src/docs", false) + { + ProjectEnvironmentVariables = ["API_KEY"] + } + ]))); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DestroyAsync(context, environment); + + string expectedProjectLinkDirectory = Path.Combine(tempRoot.Path, "vercel", ".vercel-projects", "preexisting-project"); + Assert.Contains(runner.Invocations, invocation => invocation.Arguments.SequenceEqual(["link", "--cwd", expectedProjectLinkDirectory, "--yes", "--project", "prj_existing"])); + Assert.Contains(runner.Invocations, invocation => invocation.Arguments.SequenceEqual(["env", "rm", "API_KEY", "preview", "--cwd", expectedProjectLinkDirectory, "--yes"])); + Assert.Single(stateManager.DeletedSections); + } + [Fact] public async Task DestroyAsyncTreatsMissingManagedProjectAsConverged() { var runner = new FakeVercelCliRunner( new(0, "54.18.6", ""), new(0, "davidfowl-6717", ""), - new(1, "", "Project managed-project not found")); + new(0, ProjectListJson(), "")); var stateManager = new FakeDeploymentStateManager(); stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( 1, @@ -2528,6 +3117,7 @@ public async Task DestroyAsyncTreatsMissingManagedProjectAsConverged() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.AddVercelEnvironment("vercel"); @@ -2541,7 +3131,7 @@ public async Task DestroyAsyncTreatsMissingManagedProjectAsConverged() runner.Invocations, invocation => Assert.Equal(["--version"], invocation.Arguments), invocation => Assert.Equal(["whoami"], invocation.Arguments), - invocation => Assert.Equal(["project", "remove", "managed-project"], invocation.Arguments)); + invocation => Assert.Equal(["project", "ls", "--filter", "managed-project", "--format=json"], invocation.Arguments)); var summary = Assert.Single(context.Summary.Items, item => item.Key == "Vercel project already absent"); Assert.Equal("managed-project", summary.Value); Assert.Single(stateManager.SavedSections); @@ -2554,8 +3144,11 @@ public async Task DestroyAsyncPreservesStateWhenProjectRemovalFails() var runner = new FakeVercelCliRunner( new(0, "54.18.6", ""), new(0, "davidfowl-6717", ""), + new(0, ProjectListJson("a-project"), ""), new(0, "", ""), - new(1, "", "remove failed")); + new(0, ProjectListJson("b-project"), ""), + new(1, "", "remove failed"), + new(0, ProjectListJson("b-project"), "")); var stateManager = new FakeDeploymentStateManager(); stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( 1, @@ -2570,6 +3163,7 @@ public async Task DestroyAsyncPreservesStateWhenProjectRemovalFails() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); builder.Services.AddSingleton(stateManager); builder.AddVercelEnvironment("vercel"); @@ -2581,7 +3175,7 @@ public async Task DestroyAsyncPreservesStateWhenProjectRemovalFails() VercelDeploymentStep.DestroyAsync(context, environment)); Assert.Contains("destroy Vercel project 'b-project'", exception.Message); - Assert.Equal(4, runner.Invocations.Count); + Assert.Equal(7, runner.Invocations.Count); Assert.Empty(stateManager.DeletedSections); var savedSection = Assert.Single(stateManager.SavedSections); string savedState = savedSection.Data.First().Value!.GetValue(); @@ -2701,6 +3295,24 @@ public void GetDeploymentResultReturnsJsonDeploymentId() Assert.Equal("https://example-json.vercel.app", result.DeploymentUrl); } + [Fact] + public void GetDeploymentInspectionParsesObservedJsonShapes() + { + Assert.Equal("READY", VercelDeploymentStep.GetDeploymentInspection("""{ "readyState": "READY" }""").ReadyState); + Assert.Equal("READY", VercelDeploymentStep.GetDeploymentInspection("""{ "state": "READY" }""").ReadyState); + Assert.Equal("READY", VercelDeploymentStep.GetDeploymentInspection("""{ "deployment": { "readyState": "READY" } }""").ReadyState); + Assert.Equal("READY", VercelDeploymentStep.GetDeploymentInspection("""{ "deployment": { "state": "READY" } }""").ReadyState); + } + + [Fact] + public void GetDeploymentInspectionThrowsForInvalidJson() + { + var exception = Assert.Throws(() => + VercelDeploymentStep.GetDeploymentInspection("""{ "deployment": """)); + + Assert.Contains("vercel inspect", exception.Message); + } + [Fact] public void GetVercelProjectNameReadsVercelProjectLink() { @@ -2759,6 +3371,52 @@ private static PipelineStepContext CreatePipelineStepContext( }; } + private static async Task> CreateConfiguredPipelineStepsAsync( + IDistributedApplicationBuilder builder, + DistributedApplication app) + { + var model = app.Services.GetRequiredService(); + var stepContext = CreatePipelineStepContext(builder, app); + List steps = []; + + foreach (var resource in model.Resources) + { + foreach (var annotation in resource.Annotations.OfType()) + { + var createdSteps = await annotation.CreateStepsAsync(new() + { + PipelineContext = stepContext.PipelineContext, + Resource = resource + }); + steps.AddRange(createdSteps); + } + } + + if (!steps.Any(static step => step.Name == "push-prereq")) + { + steps.Add(new PipelineStep + { + Name = "push-prereq", + Action = _ => Task.CompletedTask + }); + } + + foreach (var resource in model.Resources) + { + foreach (var annotation in resource.Annotations.OfType()) + { + await annotation.Callback(new() + { + Model = model, + Services = app.Services, + Steps = steps + }); + } + } + + return steps; + } + private static VercelDeploymentEntry CreateDeploymentEntry(string sourceRoot) { string dockerfilePath = Path.Combine(sourceRoot, "Dockerfile"); @@ -2769,6 +3427,38 @@ private static VercelDeploymentEntry CreateDeploymentEntry(string sourceRoot) new DockerfileBuildAnnotation(sourceRoot, dockerfilePath, stage: null)); } + private static VercelPreparedDeploymentAnnotation CreatePreparedDeployment( + IResource resource, + string oidcToken, + string projectName) + { + var entry = new VercelDeploymentEntry(resource, Path.GetTempPath()); + var project = new VercelPulledProject( + projectName, + $"prj_{projectName}", + $"team_{projectName}", + "{}", + oidcToken); + var claims = new VercelOidcClaims( + $"team_{projectName}", + "test-team", + projectName, + $"prj_{projectName}"); + var projectContext = new VercelPulledProjectContext( + VercelEnvironmentConfiguration.Empty, + project, + claims); + + return new( + entry, + new(projectName, project.ProjectId), + projectContext, + ManagedByAspire: true, + RemoteImageName: "app", + RemoteImageTag: "aspire-test", + TaggedImageReference: $"vcr.vercel.com/test-team/{projectName}/app:aspire-test"); + } + private static void WriteVercelProjectLink(string sourceRoot, string projectName, string projectId) { string vercelDirectory = Path.Combine(sourceRoot, ".vercel"); @@ -2784,6 +3474,26 @@ private static void WriteVercelProjectLink(string sourceRoot, string projectName private static VercelCliResult ReadyInspectResult() => new(0, """{"readyState":"READY"}""", ""); + private static string ProjectListJson(params string[] projectNames) + => JsonSerializer.Serialize(new + { + projects = projectNames.Select(static projectName => new + { + name = projectName, + id = $"prj_{projectName.Replace('-', '_')}" + }) + }); + + private static string EnvListJson(params string[] names) + => JsonSerializer.Serialize(new + { + envs = names.Select(static name => new + { + key = name, + target = new[] { "preview" } + }) + }); + private static async Task AssertWriteDeploymentPlanThrowsAsync( Action> configureApi, string? directoryName = null) @@ -2821,17 +3531,157 @@ private static VercelDeploymentState ReadSavedState(DeploymentStateSection secti section.Data.First().Value!.GetValue(), new JsonSerializerOptions(JsonSerializerDefaults.Web))!; - private static void AssertGeneratedVercelJson(string stagingRoot) + private static string CreateTestJwt(string payloadJson) + => $"{Base64Url("{}")}.{Base64Url(payloadJson)}.signature"; + + private static string Base64Url(string value) + => Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(value)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + [Fact] + public async Task PreparePulledProjectContextDeletesScratchLinkDirectory() { - using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(stagingRoot, "vercel.json"))); - var root = document.RootElement; - var service = root.GetProperty("services").GetProperty("app"); - Assert.Equal("container", service.GetProperty("runtime").GetString()); - Assert.Equal(".", service.GetProperty("root").GetString()); - Assert.Equal("Dockerfile.vercel", service.GetProperty("entrypoint").GetString()); - var rewrite = root.GetProperty("rewrites").EnumerateArray().Last(); - Assert.Equal("/(.*)", rewrite.GetProperty("source").GetString()); - Assert.Equal("app", rewrite.GetProperty("destination").GetProperty("service").GetString()); + using var sourceRoot = TemporaryDirectory.Create("pulled-project-context"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel") + .WithVercelProductionDeployments(); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + var entriesByResourceName = VercelDeploymentStep.GetDeploymentEntries(model, environment) + .ToDictionary(static deployment => deployment.Resource.Name, StringComparer.Ordinal); + + var projectContext = await VercelDeploymentStep.PreparePulledProjectContextAsync( + context, + runner, + environment.GetVercelOptions(), + entry, + entriesByResourceName); + + string expectedProjectLinkDirectory = Path.Combine(tempRoot.Path, "api", ".vercel-project"); + string expectedProjectName = VercelDeploymentStep.GetVercelProjectName(entry); + Assert.False(Directory.Exists(expectedProjectLinkDirectory)); + Assert.Equal(expectedProjectName, projectContext.PulledProject.ProjectName); + Assert.Equal("prj_test", projectContext.PulledProject.ProjectId); + Assert.Equal("team_test", projectContext.OidcClaims.OwnerId); + Assert.Equal("test-team", projectContext.OidcClaims.Owner); + Assert.Equal("test-project", projectContext.OidcClaims.Project); + Assert.Contains("\"projectId\": \"prj_test\"", projectContext.PulledProject.ProjectJsonContent, StringComparison.Ordinal); + Assert.Empty(projectContext.EnvironmentConfiguration.DeploymentEnvironmentVariables); + Assert.Empty(projectContext.EnvironmentConfiguration.ProjectEnvironmentVariables); + + Assert.Collection( + runner.Invocations, + invocation => Assert.Equal(["link", "--cwd", expectedProjectLinkDirectory, "--yes", "--project", expectedProjectName], invocation.Arguments), + invocation => Assert.Equal(["pull", "--cwd", expectedProjectLinkDirectory, "--yes", "--environment", "production"], invocation.Arguments)); + } + + [Fact] + public async Task WriteBuildOutputAsyncWritesProviderArtifactShape() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var deployRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)) with + { + DeployDirectory = deployRoot.Path + }; + var project = new VercelPulledProject( + "api", + "prj_test", + "team_test", + """ + { + "projectId": "prj_test", + "orgId": "team_test", + "projectName": "api" + } + """, + OidcToken: "unused"); + + await VercelDeploymentStep.WriteBuildOutputAsync( + entry, + project, + $"vcr.vercel.com/test-team/test-project/app@{FakeVercelCliRunner.FakeImageDigest}", + TestContext.Current.CancellationToken); + + AssertGeneratedBuildOutput(deployRoot.Path, expectedProjectName: "api"); + } + + private static void AssertGeneratedBuildOutput(string deployDirectory, string? expectedProjectName = null) + { + string outputDirectory = Path.Combine(deployDirectory, ".vercel", "output"); + string projectJsonPath = Path.Combine(deployDirectory, ".vercel", "project.json"); + string configJsonPath = Path.Combine(outputDirectory, "config.json"); + string functionConfigPath = Path.Combine(outputDirectory, "functions", "index.func", ".vc-config.json"); + var generatedFiles = Directory.EnumerateFiles(deployDirectory, "*", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(deployDirectory, path).Replace(Path.DirectorySeparatorChar, '/')) + .Order(StringComparer.Ordinal) + .ToArray(); + + Assert.Equal( + [ + ".vercel/output/config.json", + ".vercel/output/functions/index.func/.vc-config.json", + ".vercel/project.json" + ], + generatedFiles); + + using var projectDocument = JsonDocument.Parse(File.ReadAllText(projectJsonPath)); + var project = projectDocument.RootElement; + Assert.Equal("prj_test", project.GetProperty("projectId").GetString()); + Assert.Equal("team_test", project.GetProperty("orgId").GetString()); + string? projectName = project.GetProperty("projectName").GetString(); + if (expectedProjectName is null) + { + Assert.False(string.IsNullOrWhiteSpace(projectName)); + } + else + { + Assert.Equal(expectedProjectName, projectName); + } + + using var configDocument = JsonDocument.Parse(File.ReadAllText(configJsonPath)); + var root = configDocument.RootElement; + Assert.Equal(["version", "routes"], root.EnumerateObject().Select(static property => property.Name).ToArray()); + Assert.Equal(3, root.GetProperty("version").GetInt32()); + var routes = root.GetProperty("routes").EnumerateArray().ToArray(); + Assert.Equal(2, routes.Length); + Assert.Equal(["handle"], routes[0].EnumerateObject().Select(static property => property.Name).ToArray()); + Assert.Equal("filesystem", routes[0].GetProperty("handle").GetString()); + Assert.Equal(["src", "dest"], routes[1].EnumerateObject().Select(static property => property.Name).ToArray()); + Assert.Equal("/(.*)", routes[1].GetProperty("src").GetString()); + Assert.Equal("/index", routes[1].GetProperty("dest").GetString()); + + using var functionDocument = JsonDocument.Parse(File.ReadAllText(functionConfigPath)); + var functionConfig = functionDocument.RootElement; + Assert.Equal(["handler", "runtime", "environment"], functionConfig.EnumerateObject().Select(static property => property.Name).ToArray()); + Assert.Equal("container", functionConfig.GetProperty("runtime").GetString()); + Assert.Equal($"vcr.vercel.com/test-team/test-project/app@{FakeVercelCliRunner.FakeImageDigest}", functionConfig.GetProperty("handler").GetString()); + Assert.Empty(functionConfig.GetProperty("environment").EnumerateObject()); } private sealed class FakeProjectMetadata(string projectPath) : IProjectMetadata @@ -2877,6 +3727,41 @@ public void Dispose() private sealed class FakeVercelCliRunner(params VercelCliResult[] results) : IVercelCliRunner { private readonly Queue _results = new(results); + private readonly HashSet _projects = new(StringComparer.Ordinal); + private readonly Dictionary _linkedProjects = new(StringComparer.Ordinal); + private readonly HashSet _projectEnvironmentVariables = new(StringComparer.Ordinal) + { + "API_KEY", + "AUTH_HEADER", + "OLD_KEY" + }; + public const string FakeImageDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + public const string FakeImageIndexDigest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + public const string FakeImageManifestJson = """ + { + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "platform": { + "architecture": "amd64", + "os": "linux" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "platform": { + "architecture": "unknown", + "os": "unknown" + } + } + ] + } + """; public List Invocations { get; } = []; @@ -2889,9 +3774,9 @@ public Task RunAsync( { Invocations.Add(new(fileName, [.. arguments], workingDirectory, standardInput)); - if (IsProjectAdd(arguments)) + if (TryGetAutoResult(fileName, arguments, workingDirectory, out var autoResult)) { - return Task.FromResult(new VercelCliResult(0, "", "")); + return Task.FromResult(autoResult); } var result = _results.Count > 0 @@ -2901,19 +3786,261 @@ public Task RunAsync( return Task.FromResult(result); } - private static bool IsProjectAdd(IReadOnlyList arguments) + private bool TryGetAutoResult( + string fileName, + IReadOnlyList arguments, + string? workingDirectory, + out VercelCliResult result) { + result = new VercelCliResult(0, "", ""); + + if (string.Equals(fileName, "docker", StringComparison.Ordinal)) + { + if (arguments.Count >= 2 + && string.Equals(arguments[0], "buildx", StringComparison.Ordinal) + && string.Equals(arguments[1], "version", StringComparison.Ordinal)) + { + return true; + } + + if (arguments.Count >= 2 + && string.Equals(arguments[0], "buildx", StringComparison.Ordinal) + && string.Equals(arguments[1], "imagetools", StringComparison.Ordinal)) + { + result = new VercelCliResult(0, FakeImageManifestJson, ""); + return true; + } + + if (arguments.Count >= 1 + && string.Equals(arguments[0], "login", StringComparison.Ordinal)) + { + return true; + } + } + + if (string.Equals(fileName, "vercel", StringComparison.Ordinal)) + { + if (arguments is ["--version"] && ShouldAutoVercelVersion()) + { + result = new VercelCliResult(0, "54.18.6", ""); + return true; + } + + if (arguments is ["whoami"] && ShouldAutoVercelWhoami()) + { + result = new VercelCliResult(0, "test-user", ""); + return true; + } + + if (arguments.Count >= 2 + && string.Equals(arguments[0], "project", StringComparison.Ordinal) + && string.Equals(arguments[1], "ls", StringComparison.Ordinal) + && ShouldAutoVercelProjectList()) + { + string? filter = GetOptionValue(arguments, "--filter"); + string[] projects = _projects + .Where(project => string.IsNullOrWhiteSpace(filter) || project.Contains(filter, StringComparison.Ordinal)) + .Order(StringComparer.Ordinal) + .ToArray(); + result = new VercelCliResult(0, ProjectListJson(projects), ""); + return true; + } + } + for (int i = 0; i < arguments.Count - 1; i++) { if (string.Equals(arguments[i], "project", StringComparison.Ordinal) && string.Equals(arguments[i + 1], "add", StringComparison.Ordinal)) { + if (i + 2 < arguments.Count) + { + _projects.Add(arguments[i + 2]); + } + return true; } } + if (arguments.Count >= 3 + && string.Equals(arguments[0], "project", StringComparison.Ordinal) + && string.Equals(arguments[1], "remove", StringComparison.Ordinal) + && ShouldAutoVercelProjectRemove()) + { + _projects.Remove(arguments[2]); + return true; + } + + if (arguments.Count > 0 + && string.Equals(arguments[0], "link", StringComparison.Ordinal)) + { + string? cwd = GetOptionValue(arguments, "--cwd") ?? workingDirectory; + string? project = GetOptionValue(arguments, "--project"); + if (cwd is not null && project is not null) + { + _linkedProjects[cwd] = project; + } + + return true; + } + + if (arguments.Count >= 2 + && string.Equals(arguments[0], "env", StringComparison.Ordinal) + && string.Equals(arguments[1], "add", StringComparison.Ordinal)) + { + if (arguments.Count >= 3) + { + _projectEnvironmentVariables.Add(arguments[2]); + } + + return true; + } + + if (arguments.Count >= 2 + && string.Equals(arguments[0], "env", StringComparison.Ordinal) + && string.Equals(arguments[1], "ls", StringComparison.Ordinal)) + { + result = new VercelCliResult(0, EnvListJson([.. _projectEnvironmentVariables.Order(StringComparer.Ordinal)]), ""); + return true; + } + + if (arguments.Count >= 2 + && string.Equals(arguments[0], "env", StringComparison.Ordinal) + && string.Equals(arguments[1], "rm", StringComparison.Ordinal)) + { + if (arguments.Count >= 3) + { + _projectEnvironmentVariables.Remove(arguments[2]); + } + + return true; + } + + if (arguments.Count > 0 + && string.Equals(arguments[0], "pull", StringComparison.Ordinal)) + { + string? cwd = GetOptionValue(arguments, "--cwd") ?? workingDirectory; + string targetEnvironment = GetOptionValue(arguments, "--environment") ?? "preview"; + if (cwd is not null) + { + WritePulledProjectFiles(cwd, targetEnvironment); + } + + return true; + } + return false; } + + private bool ShouldAutoVercelVersion() + { + if (!_results.TryPeek(out var next)) + { + return true; + } + + string output = $"{next.StandardOutput}{Environment.NewLine}{next.StandardError}"; + if (LooksLikeDeployResult(output)) + { + return true; + } + + return next.Succeeded + && !VercelDeploymentStep.TryGetVercelCliVersion(output, out _) + && !output.TrimStart().StartsWith("vercel ", StringComparison.OrdinalIgnoreCase); + } + + private bool ShouldAutoVercelWhoami() + => !_results.TryPeek(out var next) + || (next.Succeeded && (IsEmptyResult(next) || LooksLikeDeployResult(next.StandardOutput))); + + private bool ShouldAutoVercelProjectList() + => !_results.TryPeek(out var next) + || (next.Succeeded && (IsEmptyResult(next) || (LooksLikeDeployResult(next.StandardOutput) && !LooksLikeProjectListResult(next.StandardOutput)))); + + private bool ShouldAutoVercelProjectRemove() + => !_results.TryPeek(out _); + + private static bool IsEmptyResult(VercelCliResult result) + => string.IsNullOrWhiteSpace(result.StandardOutput) + && string.IsNullOrWhiteSpace(result.StandardError); + + private static bool LooksLikeDeployResult(string output) + => output.Contains("https://", StringComparison.OrdinalIgnoreCase) + || output.TrimStart().StartsWith("{", StringComparison.Ordinal); + + private static bool LooksLikeProjectListResult(string output) + { + string trimmed = output.TrimStart(); + return trimmed.StartsWith("[", StringComparison.Ordinal) + || trimmed.StartsWith("{\"projects\"", StringComparison.Ordinal) + || trimmed.StartsWith("{ \"projects\"", StringComparison.Ordinal); + } + + private void WritePulledProjectFiles(string directory, string targetEnvironment) + { + string project = _linkedProjects.GetValueOrDefault(directory, "api"); + string vercelDirectory = Path.Combine(directory, ".vercel"); + Directory.CreateDirectory(vercelDirectory); + File.WriteAllText(Path.Combine(vercelDirectory, "project.json"), $$""" + { + "projectId": "prj_test", + "orgId": "team_test", + "projectName": "{{project}}", + "settings": { + "createdAt": 0, + "framework": null, + "devCommand": null, + "installCommand": null, + "buildCommand": null, + "outputDirectory": null, + "rootDirectory": null, + "directoryListing": false, + "nodeVersion": "22.x" + } + } + """); + File.WriteAllText(Path.Combine(vercelDirectory, $".env.{targetEnvironment}.local"), $"{VercelOidcTokenEnvironmentVariableForTests()}=\"{CreateFakeOidcToken()}\"{Environment.NewLine}"); + File.WriteAllText(Path.Combine(directory, ".env.local"), $"{VercelOidcTokenEnvironmentVariableForTests()}=\"{CreateFakeOidcToken()}\"{Environment.NewLine}"); + } + + private static string? GetOptionValue(IReadOnlyList arguments, string option) + { + for (int i = 0; i < arguments.Count - 1; i++) + { + if (string.Equals(arguments[i], option, StringComparison.Ordinal)) + { + return arguments[i + 1]; + } + } + + return null; + } + + private static string CreateFakeOidcToken() + { + string header = Base64UrlEncode("""{"alg":"none"}"""); + string payload = Base64UrlEncode("""{"owner_id":"team_test","owner":"test-team","project":"test-project","project_id":"prj_test"}"""); + return $"{header}.{payload}."; + } + + private static string Base64UrlEncode(string value) + => Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(value)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + private static string VercelOidcTokenEnvironmentVariableForTests() => "VERCEL_OIDC_TOKEN"; + } + + private sealed class FakeVercelContainerRegistryClient : IVercelContainerRegistryClient + { + public List Repositories { get; } = []; + + public Task EnsureRepositoryAsync(string token, VercelOidcClaims claims, string repository, CancellationToken cancellationToken) + { + Repositories.Add(repository); + return Task.CompletedTask; + } } private sealed class RecordingLogger : ILogger @@ -2966,6 +4093,37 @@ public Task PushImageAsync(IResource resource, CancellationToken cancellationTok } } + private sealed class VerifyingResourceContainerImageManager(FakeVercelCliRunner runner) : IResourceContainerImageManager + { + private int _activePushes; + + public List PushedResources { get; } = []; + + public Task BuildImageAsync(IResource resource, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task BuildImagesAsync(IEnumerable resources, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public async Task PushImageAsync(IResource resource, CancellationToken cancellationToken) + { + Assert.Equal(1, Interlocked.Increment(ref _activePushes)); + try + { + await Task.Delay(25, cancellationToken); + + var preparedDeployment = Assert.Single(resource.Annotations.OfType()); + var loginInvocation = runner.Invocations.Last(invocation => invocation.FileName == "docker" && invocation.Arguments.Contains("login")); + Assert.Equal(preparedDeployment.ProjectContext.PulledProject.OidcToken, loginInvocation.StandardInput); + PushedResources.Add(resource.Name); + } + finally + { + Interlocked.Decrement(ref _activePushes); + } + } + } + private sealed class FakeDeploymentStateManager : IDeploymentStateManager { private readonly Dictionary _sections = new(StringComparer.Ordinal); From 01b232132ece6d0e2b5a71ea0b3a23886db0202d Mon Sep 17 00:00:00 2001 From: David Fowler Date: Thu, 2 Jul 2026 21:07:23 -0700 Subject: [PATCH 19/33] Test Vercel generated Dockerfile pipeline Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../VercelEnvironmentTests.cs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs index 63de9cf39..5cbeec74f 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs @@ -209,6 +209,38 @@ public async Task VercelDeployStepDependsOnBuiltInImagePushSteps() Assert.Contains(api.Annotations, annotation => annotation is ContainerImagePushOptionsCallbackAnnotation); } + [Fact] + public async Task GeneratedDockerfileResourceUsesBuiltInImageBuildAndPushSteps() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "index.html"), "hello"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.AddVercelEnvironment("vercel"); + builder.AddDockerfileFactory("api", sourceRoot.Path, _ => Task.FromResult(""" + FROM nginx:alpine + COPY index.html /usr/share/nginx/html/index.html + """)); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var api = Assert.Single(model.Resources.OfType()); + Assert.NotNull(Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)).Dockerfile!.DockerfileFactory); + + var steps = await CreateConfiguredPipelineStepsAsync(builder, app); + var buildStep = Assert.Single(steps, step => step.Name == "build-api"); + var pushStep = Assert.Single(steps, step => step.Name == "push-api"); + var deployStep = Assert.Single(steps, step => step.Name == "vercel-deploy-vercel"); + + Assert.Contains("vercel-deploy-prereq-vercel", buildStep.DependsOnSteps); + Assert.Contains(WellKnownPipelineSteps.Deploy, buildStep.RequiredBySteps); + Assert.Contains("vercel-deploy-prereq-vercel", pushStep.DependsOnSteps); + Assert.Contains(WellKnownPipelineSteps.Deploy, pushStep.RequiredBySteps); + Assert.Contains("push-api", deployStep.DependsOnSteps); + Assert.Contains(api.Annotations, annotation => annotation is ContainerImagePushOptionsCallbackAnnotation); + } + [Fact] public async Task VercelPipelineStepActionsCanBeUnitTested() { @@ -697,6 +729,33 @@ public async Task WriteDeploymentPlanThrowsWhenDockerfileIsMissing() Assert.Contains("Dockerfile", exception.Message); } + [Fact] + public async Task WriteDeploymentPlanAllowsGeneratedDockerfileFactoryBeforeBuild() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + Assert.False(File.Exists(Path.Combine(sourceRoot.Path, "Dockerfile"))); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + builder.AddVercelEnvironment("vercel"); + builder.AddDockerfileFactory("api", sourceRoot.Path, _ => Task.FromResult(""" + FROM nginx:alpine + COPY index.html /usr/share/nginx/html/index.html + """)); + File.WriteAllText(Path.Combine(sourceRoot.Path, "index.html"), "hello"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + string planPath = await VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken); + + Assert.True(File.Exists(planPath)); + using var document = JsonDocument.Parse(File.ReadAllText(planPath)); + var deployment = Assert.Single(document.RootElement.GetProperty("deployments").EnumerateArray()); + Assert.Equal("", deployment.GetProperty("dockerfilePath").GetString()); + } + [Fact] public async Task WriteDeploymentPlanThrowsWhenSourceRootIsMissing() { From a68eee3189658bbccc8368cce157b9b6312cdad0 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Thu, 2 Jul 2026 21:44:22 -0700 Subject: [PATCH 20/33] Fix OpenTelemetryCollector TypeScript smoke Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../apphost.mts | 10 +++++++--- .../TypeScriptAppHostTests.cs | 1 + 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost.TypeScript/apphost.mts b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost.TypeScript/apphost.mts index b9e0f569e..c68db3da6 100644 --- a/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost.TypeScript/apphost.mts +++ b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost.TypeScript/apphost.mts @@ -2,8 +2,13 @@ import { createBuilder } from "./.aspire/modules/aspire.mjs"; const builder = await createBuilder(); -// addOpenTelemetryCollector — default overload -const collector = await builder.addOpenTelemetryCollector("collector"); +// addOpenTelemetryCollector — settings overload +const collector = await builder.addOpenTelemetryCollector("collector", { + configureSettings: async settings => { + // This TypeScript smoke validates settings export/startup; C# tests cover the default health endpoint. + await settings.disableHealthcheck.set(true); + }, +}); await collector.withConfig("./otel-config.yaml"); await collector.withAppForwarding(); @@ -14,4 +19,3 @@ const _grpcEndpointName = await _grpcEndpoint.endpointName(); const _httpEndpointName = await _httpEndpoint.endpointName(); await builder.build().run(); - diff --git a/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/TypeScriptAppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/TypeScriptAppHostTests.cs index 78da54e48..6415596b5 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/TypeScriptAppHostTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/TypeScriptAppHostTests.cs @@ -13,6 +13,7 @@ await TypeScriptAppHostTest.Run( packageName: "CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector", exampleName: "opentelemetry-collector", waitForResources: ["collector"], + waitStatus: "up", cancellationToken: TestContext.Current.CancellationToken); } } From 773a5e07a1aed7046e0795fd95b163d51b81f343 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Thu, 2 Jul 2026 21:51:35 -0700 Subject: [PATCH 21/33] Clarify Vercel deploy pipeline steps Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../VercelDeploymentStep.cs | 45 +++++++++++++++++-- ...celEnvironmentResourceBuilderExtensions.cs | 6 +-- .../VercelPipelineFinalizerAnnotation.cs | 5 +++ .../VercelResourceBuilderExtensions.cs | 4 +- .../VercelResourceExtensions.cs | 16 +++++++ .../VercelEnvironmentTests.cs | 41 +++++++++++------ 6 files changed, 95 insertions(+), 22 deletions(-) create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelPipelineFinalizerAnnotation.cs diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs index 13ceb6c5e..ba1050228 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs @@ -34,10 +34,10 @@ internal static class VercelDeploymentStep // destroy => use saved state, not the current model, and delete only Aspire-managed projects. // Keep provider/protocol parsing in small internal helpers so tests can assert exact behavior // without live Vercel credentials. - public const string PublishStepNamePrefix = "vercel-publish-"; - public const string DeployPrereqStepNamePrefix = "vercel-deploy-prereq-"; - public const string DeployStepNamePrefix = "vercel-deploy-"; - public const string DestroyPrereqStepNamePrefix = "vercel-destroy-prereq-"; + public const string PublishStepNamePrefix = "vercel-generate-plan-"; + public const string DeployPrereqStepNamePrefix = "vercel-prepare-projects-"; + public const string DeployStepNamePrefix = "vercel-deploy-prebuilt-"; + public const string DestroyPrereqStepNamePrefix = "vercel-prepare-destroy-"; public const string DestroyStepNamePrefix = "vercel-destroy-"; public const string DeploymentPlanFileName = "vercel-deployments.json"; @@ -347,6 +347,7 @@ public static void ConfigurePipeline(PipelineConfigurationContext context, Verce return; } + string planStepName = $"{PublishStepNamePrefix}{environment.Name}"; string prereqStepName = $"{DeployPrereqStepNamePrefix}{environment.Name}"; var deploySteps = context.GetSteps(environment, "vercel-deploy").ToArray(); var pushPrereqSteps = context.Steps @@ -362,6 +363,8 @@ public static void ConfigurePipeline(PipelineConfigurationContext context, Verce { AddUnique(buildStep.DependsOnSteps, prereqStepName); AddUnique(buildStep.RequiredBySteps, WellKnownPipelineSteps.Deploy); + RemoveDuplicates(buildStep.DependsOnSteps); + RemoveDuplicates(buildStep.RequiredBySteps); } var pushSteps = context.GetSteps(entry.Resource, WellKnownPipelineTags.PushContainerImage).ToArray(); @@ -369,6 +372,8 @@ public static void ConfigurePipeline(PipelineConfigurationContext context, Verce { AddUnique(pushStep.DependsOnSteps, prereqStepName); AddUnique(pushStep.RequiredBySteps, WellKnownPipelineSteps.Deploy); + RemoveDuplicates(pushStep.DependsOnSteps); + RemoveDuplicates(pushStep.RequiredBySteps); } // Aspire's global push prerequisite validates that every pushed image has a @@ -377,16 +382,33 @@ public static void ConfigurePipeline(PipelineConfigurationContext context, Verce foreach (var pushPrereqStep in pushPrereqSteps) { AddUnique(pushPrereqStep.DependsOnSteps, prereqStepName); + RemoveDuplicates(pushPrereqStep.DependsOnSteps); + RemoveDuplicates(pushPrereqStep.RequiredBySteps); } foreach (var deployStep in deploySteps) { + AddUnique(deployStep.DependsOnSteps, planStepName); foreach (var pushStep in pushSteps) { AddUnique(deployStep.DependsOnSteps, pushStep.Name); } + + RemoveDuplicates(deployStep.DependsOnSteps); + RemoveDuplicates(deployStep.RequiredBySteps); } } + + NormalizePipelineDependencies(context); + } + + public static void NormalizePipelineDependencies(PipelineConfigurationContext context) + { + foreach (var step in context.Steps) + { + RemoveDuplicates(step.DependsOnSteps); + RemoveDuplicates(step.RequiredBySteps); + } } private static async Task PrepareResourceForBuiltInImagePushAsync( @@ -672,6 +694,21 @@ private static void AddUnique(ICollection values, string value) } } + private static void RemoveDuplicates(ICollection values) + { + string[] distinctValues = values.Distinct(StringComparer.Ordinal).ToArray(); + if (distinctValues.Length == values.Count) + { + return; + } + + values.Clear(); + foreach (string value in distinctValues) + { + values.Add(value); + } + } + private static void RemoveAnnotations(IResource resource, Func? predicate = null) where TAnnotation : IResourceAnnotation { diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs index 686aca4d0..bac5761c9 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs @@ -52,7 +52,7 @@ public static IResourceBuilder AddVercelEnvironment( new PipelineStep { Name = $"{VercelDeploymentStep.PublishStepNamePrefix}{resource.Name}", - Description = $"Generate Vercel deployment plan for '{resource.Name}'.", + Description = $"Generate the Vercel Build Output API plan for '{resource.Name}'.", Resource = resource, DependsOnSteps = [WellKnownPipelineSteps.ValidateComputeEnvironments], RequiredBySteps = [WellKnownPipelineSteps.Publish, WellKnownPipelineSteps.Deploy], @@ -61,7 +61,7 @@ public static IResourceBuilder AddVercelEnvironment( new PipelineStep { Name = $"{VercelDeploymentStep.DeployPrereqStepNamePrefix}{resource.Name}", - Description = $"Prepare Vercel projects and VCR image push settings for '{resource.Name}'.", + Description = $"Create/link Vercel projects and configure VCR image pushes for '{resource.Name}'.", Resource = resource, DependsOnSteps = [WellKnownPipelineSteps.ValidateComputeEnvironments], RequiredBySteps = [WellKnownPipelineSteps.Deploy], @@ -70,7 +70,7 @@ public static IResourceBuilder AddVercelEnvironment( new PipelineStep { Name = $"{VercelDeploymentStep.DeployStepNamePrefix}{resource.Name}", - Description = $"Deploy resources to Vercel environment '{resource.Name}'.", + Description = $"Deploy the digest-pinned VCR images to Vercel with --prebuilt for '{resource.Name}'.", Resource = resource, Tags = ["vercel-deploy"], DependsOnSteps = [$"{VercelDeploymentStep.DeployPrereqStepNamePrefix}{resource.Name}"], diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelPipelineFinalizerAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelPipelineFinalizerAnnotation.cs new file mode 100644 index 000000000..e31292b6b --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelPipelineFinalizerAnnotation.cs @@ -0,0 +1,5 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal sealed class VercelPipelineFinalizerAnnotation : IResourceAnnotation; diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceBuilderExtensions.cs index 529609502..b2682042f 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceBuilderExtensions.cs @@ -37,6 +37,8 @@ public static IResourceBuilder WithVercelProjectName( nameof(projectName)); } - return builder.WithAnnotation(new VercelProjectOptionsAnnotation(projectName), ResourceAnnotationMutationBehavior.Replace); + return builder + .WithAnnotation(new VercelProjectOptionsAnnotation(projectName), ResourceAnnotationMutationBehavior.Replace) + .WithVercelPipelineFinalizer(); } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceExtensions.cs index 28da9087a..dcd95dbee 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceExtensions.cs @@ -1,7 +1,9 @@ +#pragma warning disable ASPIREPIPELINES001 #pragma warning disable CTASPIREVERCEL001 using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; namespace CommunityToolkit.Aspire.Hosting.Vercel; @@ -17,4 +19,18 @@ public static VercelEnvironmentOptionsAnnotation GetVercelOptions(this VercelEnv return new VercelEnvironmentOptionsAnnotation(); } + + [AspireExportIgnore(Reason = "Internal Vercel pipeline cleanup is not part of the generated AppHost API.")] + public static IResourceBuilder WithVercelPipelineFinalizer(this IResourceBuilder builder) + where TResource : IResource + { + if (builder.Resource.Annotations.OfType().Any()) + { + return builder; + } + + builder.Resource.Annotations.Add(new VercelPipelineFinalizerAnnotation()); + builder.Resource.Annotations.Add(new PipelineConfigurationAnnotation(VercelDeploymentStep.NormalizePipelineDependencies)); + return builder; + } } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs index 5cbeec74f..98d4962f6 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs @@ -150,23 +150,23 @@ public async Task AddVercelEnvironmentRegistersExpectedPipelineSteps() steps, step => { - Assert.Equal("vercel-publish-vercel", step.Name); + Assert.Equal("vercel-generate-plan-vercel", step.Name); Assert.Same(vercel.Resource, step.Resource); Assert.Equal([WellKnownPipelineSteps.ValidateComputeEnvironments], step.DependsOnSteps); Assert.Equal([WellKnownPipelineSteps.Publish, WellKnownPipelineSteps.Deploy], step.RequiredBySteps); }, step => { - Assert.Equal("vercel-deploy-prereq-vercel", step.Name); + Assert.Equal("vercel-prepare-projects-vercel", step.Name); Assert.Same(vercel.Resource, step.Resource); Assert.Equal([WellKnownPipelineSteps.ValidateComputeEnvironments], step.DependsOnSteps); Assert.Equal([WellKnownPipelineSteps.Deploy], step.RequiredBySteps); }, step => { - Assert.Equal("vercel-deploy-vercel", step.Name); + Assert.Equal("vercel-deploy-prebuilt-vercel", step.Name); Assert.Same(vercel.Resource, step.Resource); - Assert.Equal(["vercel-deploy-prereq-vercel"], step.DependsOnSteps); + Assert.Equal(["vercel-prepare-projects-vercel"], step.DependsOnSteps); Assert.Equal([WellKnownPipelineSteps.Deploy], step.RequiredBySteps); }, step => @@ -198,13 +198,15 @@ public async Task VercelDeployStepDependsOnBuiltInImagePushSteps() var buildStep = Assert.Single(steps, step => step.Name == "build-api"); var pushStep = Assert.Single(steps, step => step.Name == "push-api"); var pushPrereqStep = Assert.Single(steps, step => step.Name == "push-prereq"); - var deployStep = Assert.Single(steps, step => step.Name == "vercel-deploy-vercel"); + var deployStep = Assert.Single(steps, step => step.Name == "vercel-deploy-prebuilt-vercel"); - Assert.Contains("vercel-deploy-prereq-vercel", buildStep.DependsOnSteps); + AssertPipelineDependenciesAreDistinct(steps); + Assert.Contains("vercel-prepare-projects-vercel", buildStep.DependsOnSteps); Assert.Contains(WellKnownPipelineSteps.Deploy, buildStep.RequiredBySteps); - Assert.Contains("vercel-deploy-prereq-vercel", pushPrereqStep.DependsOnSteps); - Assert.Contains("vercel-deploy-prereq-vercel", pushStep.DependsOnSteps); + Assert.Contains("vercel-prepare-projects-vercel", pushPrereqStep.DependsOnSteps); + Assert.Contains("vercel-prepare-projects-vercel", pushStep.DependsOnSteps); Assert.Contains(WellKnownPipelineSteps.Deploy, pushStep.RequiredBySteps); + Assert.Contains("vercel-generate-plan-vercel", deployStep.DependsOnSteps); Assert.Contains("push-api", deployStep.DependsOnSteps); Assert.Contains(api.Annotations, annotation => annotation is ContainerImagePushOptionsCallbackAnnotation); } @@ -231,12 +233,14 @@ COPY index.html /usr/share/nginx/html/index.html var steps = await CreateConfiguredPipelineStepsAsync(builder, app); var buildStep = Assert.Single(steps, step => step.Name == "build-api"); var pushStep = Assert.Single(steps, step => step.Name == "push-api"); - var deployStep = Assert.Single(steps, step => step.Name == "vercel-deploy-vercel"); + var deployStep = Assert.Single(steps, step => step.Name == "vercel-deploy-prebuilt-vercel"); - Assert.Contains("vercel-deploy-prereq-vercel", buildStep.DependsOnSteps); + AssertPipelineDependenciesAreDistinct(steps); + Assert.Contains("vercel-prepare-projects-vercel", buildStep.DependsOnSteps); Assert.Contains(WellKnownPipelineSteps.Deploy, buildStep.RequiredBySteps); - Assert.Contains("vercel-deploy-prereq-vercel", pushStep.DependsOnSteps); + Assert.Contains("vercel-prepare-projects-vercel", pushStep.DependsOnSteps); Assert.Contains(WellKnownPipelineSteps.Deploy, pushStep.RequiredBySteps); + Assert.Contains("vercel-generate-plan-vercel", deployStep.DependsOnSteps); Assert.Contains("push-api", deployStep.DependsOnSteps); Assert.Contains(api.Annotations, annotation => annotation is ContainerImagePushOptionsCallbackAnnotation); } @@ -265,9 +269,9 @@ public async Task VercelPipelineStepActionsCanBeUnitTested() using var app = builder.Build(); var steps = await CreateConfiguredPipelineStepsAsync(builder, app); var context = CreatePipelineStepContext(builder, app); - var publishStep = Assert.Single(steps, step => step.Name == "vercel-publish-vercel"); - var prereqStep = Assert.Single(steps, step => step.Name == "vercel-deploy-prereq-vercel"); - var deployStep = Assert.Single(steps, step => step.Name == "vercel-deploy-vercel"); + var publishStep = Assert.Single(steps, step => step.Name == "vercel-generate-plan-vercel"); + var prereqStep = Assert.Single(steps, step => step.Name == "vercel-prepare-projects-vercel"); + var deployStep = Assert.Single(steps, step => step.Name == "vercel-deploy-prebuilt-vercel"); var destroyStep = Assert.Single(steps, step => step.Name == "vercel-destroy-vercel"); await publishStep.Action(context); @@ -3476,6 +3480,15 @@ await annotation.Callback(new() return steps; } + private static void AssertPipelineDependenciesAreDistinct(IEnumerable steps) + { + foreach (var step in steps) + { + Assert.Equal(step.DependsOnSteps.Distinct(StringComparer.Ordinal), step.DependsOnSteps); + Assert.Equal(step.RequiredBySteps.Distinct(StringComparer.Ordinal), step.RequiredBySteps); + } + } + private static VercelDeploymentEntry CreateDeploymentEntry(string sourceRoot) { string dockerfilePath = Path.Combine(sourceRoot, "Dockerfile"); From 684cbb07b6820c456ea9881a4a809dd69159bc7b Mon Sep 17 00:00:00 2001 From: David Fowler Date: Thu, 2 Jul 2026 22:38:11 -0700 Subject: [PATCH 22/33] Reference Vercel docs in integration docs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../README.md | 20 +++++++++++----- .../VercelDeploymentStep.cs | 23 ++++++++++++++++--- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md index 2676932e3..e0373c73f 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md @@ -79,11 +79,11 @@ For low-level container resources, Vercel requires Aspire image-build metadata. 1. `aspire publish` validates the targeted resources and writes `vercel-deployments.json`, a deterministic review plan with command shape, Dockerfile/source information, and environment variable names. It does not call Vercel, resolve secrets, or push images. 2. The deploy prerequisite step validates Vercel CLI, Docker buildx, Vercel auth, configured scope, existing state compatibility, Vercel project names, endpoints, and unsupported Aspire concepts before provider mutation. -3. For each resource, the integration creates or links a Vercel project in an Aspire-owned scratch directory, configures sensitive project environment variables with `vercel env add --sensitive` over standard input, and runs `vercel pull` in that scratch directory to obtain `.vercel/project.json`, `.vercel/.env..local`, and the short-lived `VERCEL_OIDC_TOKEN`. -4. The integration decodes only routing claims from the Vercel OIDC JWT payload, configures Vercel Container Registry (`vcr.vercel.com//`) as the resource's Aspire deployment target registry, and lets Aspire's built-in build/push steps build the workload image and push it to VCR. -5. After the push, deploy logs in to VCR again, runs `docker buildx imagetools inspect --format '{{json .Manifest}}'`, and selects the linux/amd64 manifest digest. Live Vercel validation rejected OCI index digests, so the generated artifact references the concrete platform manifest. -6. Deploy writes Vercel Build Output API v3 files under Aspire temp/output storage: `.vercel/project.json`, `.vercel/output/config.json`, and `.vercel/output/functions/index.func/.vc-config.json` with `runtime: "container"` and a digest-pinned VCR `handler`. -7. Deploy runs `vercel deploy --prebuilt`, parses the deployment URL from JSON or plain CLI output, verifies readiness with `vercel inspect --wait --format=json`, records deployment URLs/image digests/state, and adds deployment URLs to the pipeline summary. +3. For each resource, the integration creates or links a Vercel project in an Aspire-owned scratch directory, configures sensitive project environment variables with `vercel env add --sensitive` over standard input, and runs [`vercel pull`](https://vercel.com/docs/cli/pull) in that scratch directory to obtain `.vercel/project.json`, `.vercel/.env..local`, and the short-lived `VERCEL_OIDC_TOKEN`. +4. The integration decodes only routing claims from the Vercel OIDC JWT payload, configures [Vercel Container Registry](https://vercel.com/docs/container-registry) (`vcr.vercel.com//`) as the resource's Aspire deployment target registry, and lets Aspire's built-in build/push steps build the workload image and push it to VCR. +5. After the push, deploy logs in to VCR again, runs `docker buildx imagetools inspect --format '{{json .Manifest}}'`, and selects the linux/amd64 manifest digest. Live Vercel validation rejected OCI index digests, so the generated artifact references the concrete platform manifest accepted by [Vercel Container Images](https://vercel.com/docs/functions/container-images). +6. Deploy writes [Vercel Build Output API](https://vercel.com/docs/build-output-api) v3 files under Aspire temp/output storage: `.vercel/project.json`, `.vercel/output/config.json`, and `.vercel/output/functions/index.func/.vc-config.json` with `runtime: "container"` and a digest-pinned VCR `handler`. +7. Deploy runs [`vercel deploy --prebuilt`](https://vercel.com/docs/cli/deploy), parses the deployment URL from JSON or plain CLI output, verifies readiness with [`vercel inspect --wait`](https://vercel.com/docs/cli/inspect), records deployment URLs/image digests/state, and adds deployment URLs to the pipeline summary. 8. `aspire destroy` reads saved deployment state first, removes tracked project environment variables from linked projects, deletes only Aspire-managed Vercel projects, treats already-missing projects as converged, and saves partial state after each successful delete so retry remains safe. Non-secret Aspire environment variables configured on the resource are processed during publish/deploy and passed to Vercel as deployment-scoped CLI environment variables: @@ -98,7 +98,7 @@ builder.AddContainer("api", "api") vercel deploy --cwd --project --prebuilt --yes --env GREETING=hello ``` -Secret parameters, connection strings, and composite values that contain secrets are configured as sensitive Vercel project environment variables before deploy. Because `vercel env add` is scoped through Vercel link metadata and does not accept `--project`, the integration links a temporary scratch directory outside the source tree and sends the value through standard input instead of putting it on the command line: +Secret parameters, connection strings, and composite values that contain secrets are configured as [sensitive Vercel project environment variables](https://vercel.com/docs/environment-variables/sensitive-environment-variables) before deploy. Because [`vercel env add`](https://vercel.com/docs/cli/env) is scoped through [`vercel link`](https://vercel.com/docs/cli/link) metadata and does not accept `--project`, the integration links a temporary scratch directory outside the source tree and sends the value through standard input instead of putting it on the command line: ```csharp var apiKey = builder.AddParameter("api-key", secret: true); @@ -181,7 +181,15 @@ If the source root already contains a `vercel.json`, the integration reads it on - [Run any Dockerfile on Vercel](https://vercel.com/blog/dockerfile-on-vercel) - [Vercel Container Images](https://vercel.com/docs/functions/container-images) +- [Vercel Container Registry](https://vercel.com/docs/container-registry) +- [Vercel Build Output API](https://vercel.com/docs/build-output-api) +- [Build Output API configuration](https://vercel.com/docs/build-output-api/configuration) - [Vercel CLI deploy](https://vercel.com/docs/cli/deploy) +- [Vercel CLI link](https://vercel.com/docs/cli/link) +- [Vercel CLI pull](https://vercel.com/docs/cli/pull) +- [Vercel CLI env](https://vercel.com/docs/cli/env) +- [Vercel CLI inspect](https://vercel.com/docs/cli/inspect) +- [Sensitive environment variables](https://vercel.com/docs/environment-variables/sensitive-environment-variables) ## Feedback & contributing diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs index ba1050228..cf800b2d3 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs @@ -34,6 +34,12 @@ internal static class VercelDeploymentStep // destroy => use saved state, not the current model, and delete only Aspire-managed projects. // Keep provider/protocol parsing in small internal helpers so tests can assert exact behavior // without live Vercel credentials. + // + // Vercel contracts referenced by the non-obvious deploy behavior below: + // Container Images: https://vercel.com/docs/functions/container-images + // Container Registry: https://vercel.com/docs/container-registry + // Build Output API: https://vercel.com/docs/build-output-api + // Prebuilt deploy: https://vercel.com/docs/cli/deploy public const string PublishStepNamePrefix = "vercel-generate-plan-"; public const string DeployPrereqStepNamePrefix = "vercel-prepare-projects-"; public const string DeployStepNamePrefix = "vercel-deploy-prebuilt-"; @@ -1521,6 +1527,7 @@ private static async Task PrepareProjectEnvironmentDirectoryAsync( Directory.CreateDirectory(projectLinkDirectory); // `vercel env add` is project-scoped but intentionally does not accept --project. + // See https://vercel.com/docs/cli/env and https://vercel.com/docs/cli/link. // Link a scratch directory instead of the source root so secret configuration can use // the CLI's native project lookup without writing .vercel metadata into user code. string[] linkArguments = BuildLinkProjectArguments(options, projectLinkDirectory, GetVercelProjectOption(entry)); @@ -1540,6 +1547,9 @@ private static async Task PullProjectSettingsAsync( VercelDeploymentEntry entry, string projectLinkDirectory) { + // `vercel pull` is the documented way to materialize project settings and environment + // files under `.vercel/`; VCR authentication depends on the pulled VERCEL_OIDC_TOKEN. + // See https://vercel.com/docs/cli/pull and https://vercel.com/docs/container-registry. string targetEnvironment = GetVercelProjectEnvironmentName(options); string[] arguments = BuildPullProjectSettingsArguments(options, projectLinkDirectory, targetEnvironment); var result = await runner.RunAsync(VercelCliFileName, arguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); @@ -1599,6 +1609,9 @@ internal static async Task LoginToVcrAsync( throw new DistributedApplicationException("The Vercel OIDC token did not include the owner_id claim required to authenticate to VCR."); } + // VCR supports Docker-compatible tooling at vcr.vercel.com. This login uses the + // Vercel-issued OIDC token pulled for the linked project. + // See https://vercel.com/docs/container-registry. string[] arguments = BuildDockerLoginArguments(claims.OwnerId); var result = await runner.RunAsync(DockerCliFileName, arguments, workingDirectory: null, cancellationToken, standardInput: oidcToken).ConfigureAwait(false); if (!result.Succeeded) @@ -1613,7 +1626,9 @@ internal static async Task WriteBuildOutputAsync( string imageReference, CancellationToken cancellationToken) { - // Vercel Build Output API v3 expects: + // Vercel Build Output API v3 expects the file-system contract documented at + // https://vercel.com/docs/build-output-api and + // https://vercel.com/docs/build-output-api/configuration: // .vercel/project.json copied project identity from `vercel pull` // .vercel/output/config.json routes and API version // .vercel/output/functions/index.func/.vc-config.json @@ -1709,8 +1724,10 @@ internal static string GetDockerImageDigest(string output) try { // Current path uses `--format '{{json .Manifest}}'`. Docker may return an OCI - // image index with a `manifests[]` array, or a single manifest object. Vercel - // rejected index digests in live smoke tests, so prefer the linux/amd64 child. + // image index with a `manifests[]` array, or a single manifest object. Vercel's + // Container Images docs describe VCR-backed OCI images; live smoke tests rejected + // index digests, so prefer the linux/amd64 child. + // See https://vercel.com/docs/functions/container-images. using var document = JsonDocument.Parse(trimmed); var root = document.RootElement; From 32463c3dbe7b8165bde57eb2f3dbe403a3b2391b Mon Sep 17 00:00:00 2001 From: David Fowler Date: Thu, 2 Jul 2026 23:25:08 -0700 Subject: [PATCH 23/33] Split Vercel deployment step Break the Vercel deployment implementation into focused partial files for orchestration, plan generation, CLI handling, state, Build Output/VCR setup, environment mapping, model validation, and shared types. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../VercelDeploymentStep.BuildOutput.cs | 437 +++ .../VercelDeploymentStep.Cli.cs | 494 ++++ .../VercelDeploymentStep.Environment.cs | 416 +++ .../VercelDeploymentStep.Model.cs | 517 ++++ .../VercelDeploymentStep.Plan.cs | 191 ++ .../VercelDeploymentStep.State.cs | 441 +++ .../VercelDeploymentStep.Types.cs | 96 + .../VercelDeploymentStep.cs | 2419 +---------------- 8 files changed, 2593 insertions(+), 2418 deletions(-) create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.BuildOutput.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Cli.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Environment.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Model.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Plan.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.State.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Types.cs diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.BuildOutput.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.BuildOutput.cs new file mode 100644 index 000000000..1de772a6f --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.BuildOutput.cs @@ -0,0 +1,437 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES002 +#pragma warning disable ASPIREPIPELINES003 +#pragma warning disable ASPIREPIPELINES004 +#pragma warning disable ASPIRECOMPUTE003 +#pragma warning disable ASPIREPROBES001 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.ApplicationModel.Docker; +using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Publishing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal static partial class VercelDeploymentStep +{ + private static async Task PrepareProjectEnvironmentDirectoryAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry) + { + var outputService = context.Services.GetRequiredService(); + string projectLinkDirectory = Path.Combine(outputService.GetTempDirectory(entry.Resource), ".vercel-project"); + if (Directory.Exists(projectLinkDirectory)) + { + Directory.Delete(projectLinkDirectory, recursive: true); + } + + Directory.CreateDirectory(projectLinkDirectory); + + // `vercel env add` is project-scoped but intentionally does not accept --project. + // See https://vercel.com/docs/cli/env and https://vercel.com/docs/cli/link. + // Link a scratch directory instead of the source root so secret configuration can use + // the CLI's native project lookup without writing .vercel metadata into user code. + string[] linkArguments = BuildLinkProjectArguments(options, projectLinkDirectory, GetVercelProjectOption(entry)); + var result = await runner.RunAsync(VercelCliFileName, linkArguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException($"prepare temporary Vercel project link for resource '{entry.Resource.Name}'", VercelCliFileName, result); + } + + return projectLinkDirectory; + } + + private static async Task PullProjectSettingsAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry, + string projectLinkDirectory) + { + // `vercel pull` is the documented way to materialize project settings and environment + // files under `.vercel/`; VCR authentication depends on the pulled VERCEL_OIDC_TOKEN. + // See https://vercel.com/docs/cli/pull and https://vercel.com/docs/container-registry. + string targetEnvironment = GetVercelProjectEnvironmentName(options); + string[] arguments = BuildPullProjectSettingsArguments(options, projectLinkDirectory, targetEnvironment); + var result = await runner.RunAsync(VercelCliFileName, arguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException($"pull Vercel project settings for resource '{entry.Resource.Name}'", VercelCliFileName, result); + } + + string vercelDirectory = Path.Combine(projectLinkDirectory, VercelDirectoryName); + string projectJsonPath = Path.Combine(vercelDirectory, VercelProjectFileName); + string environmentPath = Path.Combine(vercelDirectory, $".env.{targetEnvironment}.local"); + + if (!File.Exists(projectJsonPath)) + { + throw new DistributedApplicationException($"Vercel pull did not write expected project settings file '{projectJsonPath}' for resource '{entry.Resource.Name}'."); + } + + if (!File.Exists(environmentPath)) + { + throw new DistributedApplicationException($"Vercel pull did not write expected environment file '{environmentPath}' for resource '{entry.Resource.Name}'."); + } + + var environmentVariables = ParseDotEnvFile(await File.ReadAllLinesAsync(environmentPath, context.CancellationToken).ConfigureAwait(false)); + if (!environmentVariables.TryGetValue(VercelOidcTokenEnvironmentVariable, out string? oidcToken) + || string.IsNullOrWhiteSpace(oidcToken)) + { + throw new DistributedApplicationException($"Vercel pull did not provide {VercelOidcTokenEnvironmentVariable}, which is required to authenticate local Docker builds to VCR."); + } + + string projectJsonContent = await File.ReadAllTextAsync(projectJsonPath, context.CancellationToken).ConfigureAwait(false); + var project = ReadVercelProjectSettings(projectJsonPath, projectJsonContent); + + // `vercel pull` materializes project secrets next to the scratch link so local + // builders can read them. This integration only needs the short-lived OIDC token + // and project metadata; delete the env files before creating deploy artifacts. + DeleteIfExists(environmentPath); + DeleteIfExists(Path.Combine(projectLinkDirectory, ".env.local")); + + return new(project.ProjectName, project.ProjectId, project.OrgId, projectJsonContent, oidcToken); + } + + private static async Task LoginToVcrAsync( + PipelineStepContext context, + IVercelCliRunner runner, + string oidcToken, + VercelOidcClaims claims) + => await LoginToVcrAsync(runner, oidcToken, claims, context.CancellationToken).ConfigureAwait(false); + + internal static async Task LoginToVcrAsync( + IVercelCliRunner runner, + string oidcToken, + VercelOidcClaims claims, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(claims.OwnerId)) + { + throw new DistributedApplicationException("The Vercel OIDC token did not include the owner_id claim required to authenticate to VCR."); + } + + // VCR supports Docker-compatible tooling at vcr.vercel.com. This login uses the + // Vercel-issued OIDC token pulled for the linked project. + // See https://vercel.com/docs/container-registry. + string[] arguments = BuildDockerLoginArguments(claims.OwnerId); + var result = await runner.RunAsync(DockerCliFileName, arguments, workingDirectory: null, cancellationToken, standardInput: oidcToken).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException("authenticate Docker to VCR", DockerCliFileName, result); + } + } + + internal static async Task WriteBuildOutputAsync( + VercelDeploymentEntry entry, + VercelPulledProject project, + string imageReference, + CancellationToken cancellationToken) + { + // Vercel Build Output API v3 expects the file-system contract documented at + // https://vercel.com/docs/build-output-api and + // https://vercel.com/docs/build-output-api/configuration: + // .vercel/project.json copied project identity from `vercel pull` + // .vercel/output/config.json routes and API version + // .vercel/output/functions/index.func/.vc-config.json + // { "runtime": "container", "handler": "@sha256:..." } + // There is intentionally no user source copy here; Aspire's build/push pipeline has + // already built the image, and Vercel deploy uploads only metadata that points at it. + string vercelDirectory = Path.Combine(entry.DeployDirectory, VercelDirectoryName); + string outputDirectory = Path.Combine(vercelDirectory, VercelOutputDirectoryName); + string functionDirectory = Path.Combine(outputDirectory, "functions", "index.func"); + Directory.CreateDirectory(functionDirectory); + + await File.WriteAllTextAsync(Path.Combine(vercelDirectory, VercelProjectFileName), project.ProjectJsonContent, cancellationToken).ConfigureAwait(false); + + var outputConfig = new JsonObject + { + ["version"] = VercelBuildOutputApiVersion, + ["routes"] = new JsonArray + { + new JsonObject + { + ["handle"] = "filesystem" + }, + new JsonObject + { + ["src"] = "/(.*)", + ["dest"] = "/index" + } + } + }; + + var functionConfig = new JsonObject + { + ["handler"] = imageReference, + ["runtime"] = "container", + ["environment"] = new JsonObject() + }; + + await File.WriteAllTextAsync(Path.Combine(outputDirectory, "config.json"), outputConfig.ToJsonString(JsonOptions), cancellationToken).ConfigureAwait(false); + await File.WriteAllTextAsync(Path.Combine(functionDirectory, ".vc-config.json"), functionConfig.ToJsonString(JsonOptions), cancellationToken).ConfigureAwait(false); + } + + private static VercelPulledProjectSettings ReadVercelProjectSettings(string projectJsonPath, string projectJsonContent) + { + try + { + // `vercel pull` writes `.vercel/project.json`. Only project identity fields are + // needed: they select the linked provider project and are safe to persist in state. + using var document = JsonDocument.Parse(projectJsonContent); + var root = document.RootElement; + + string projectName = root.TryGetProperty("projectName", out var projectNameElement) && projectNameElement.ValueKind == JsonValueKind.String + ? projectNameElement.GetString() ?? string.Empty + : string.Empty; + string? projectId = root.TryGetProperty("projectId", out var projectIdElement) && projectIdElement.ValueKind == JsonValueKind.String + ? projectIdElement.GetString() + : null; + string? orgId = root.TryGetProperty("orgId", out var orgIdElement) && orgIdElement.ValueKind == JsonValueKind.String + ? orgIdElement.GetString() + : null; + + return new(projectName, projectId, orgId); + } + catch (JsonException ex) + { + throw new DistributedApplicationException($"Vercel project settings file '{projectJsonPath}' is invalid JSON.", ex); + } + } + + internal static string GetDockerImageDigest(string output) + { + string trimmed = output.Trim(); + if (trimmed.StartsWith("\"", StringComparison.Ordinal)) + { + try + { + // `docker buildx imagetools inspect --format '{{json .Digest}}'` style output + // is a JSON string. Older experiments used this shape before Vercel required + // selecting the concrete linux/amd64 manifest from the OCI index. + string? digest = JsonSerializer.Deserialize(trimmed); + if (IsSha256Digest(digest)) + { + return digest!; + } + } + catch (JsonException ex) + { + throw new DistributedApplicationException("Docker returned invalid JSON while resolving the pushed VCR image digest.", ex); + } + } + + if (trimmed.StartsWith("{", StringComparison.Ordinal)) + { + try + { + // Current path uses `--format '{{json .Manifest}}'`. Docker may return an OCI + // image index with a `manifests[]` array, or a single manifest object. Vercel's + // Container Images docs describe VCR-backed OCI images; live smoke tests rejected + // index digests, so prefer the linux/amd64 child. + // See https://vercel.com/docs/functions/container-images. + using var document = JsonDocument.Parse(trimmed); + var root = document.RootElement; + + if (root.TryGetProperty("manifests", out var manifests) && manifests.ValueKind == JsonValueKind.Array) + { + foreach (var manifest in manifests.EnumerateArray()) + { + if (manifest.TryGetProperty("platform", out var platform) + && platform.TryGetProperty("os", out var osElement) + && platform.TryGetProperty("architecture", out var architectureElement) + && string.Equals(osElement.GetString(), "linux", StringComparison.OrdinalIgnoreCase) + && string.Equals(architectureElement.GetString(), "amd64", StringComparison.OrdinalIgnoreCase) + && TryGetJsonString(manifest, "digest", out var platformDigest) + && IsSha256Digest(platformDigest)) + { + return platformDigest!; + } + } + + throw new DistributedApplicationException("Docker did not return a linux/amd64 manifest digest for the pushed VCR image. Vercel requires linux/amd64 container images."); + } + + if (TryGetJsonString(root, "digest", out var digest) && IsSha256Digest(digest)) + { + return digest!; + } + } + catch (JsonException ex) + { + throw new DistributedApplicationException("Docker returned invalid JSON while resolving the pushed VCR image digest.", ex); + } + } + + var match = Regex.Match(trimmed, @"sha256:[a-fA-F0-9]{64}", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); + if (match.Success) + { + return match.Value; + } + + throw new DistributedApplicationException($"Docker did not return a valid sha256 image digest. Output: {GetTrimmedOutput(output)}"); + } + + private static bool TryGetJsonString(JsonElement element, string propertyName, [NotNullWhen(true)] out string? value) + { + value = element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + + return !string.IsNullOrWhiteSpace(value); + } + + internal static VercelOidcClaims DecodeUnvalidatedOidcClaims(string token) + { + string[] parts = token.Split('.'); + if (parts.Length != 3) + { + throw new DistributedApplicationException("The Vercel OIDC token is not a valid compact JWT."); + } + + try + { + // This is an unvalidated decode of the Vercel-issued token from `vercel pull`. + // Docker/Vercel validate the token when it is used; here we only need routing + // metadata such as owner_id/project to construct the VCR login and repository. + byte[] payloadBytes = Convert.FromBase64String(PadBase64Url(parts[1])); + using var document = JsonDocument.Parse(payloadBytes); + var root = document.RootElement; + + return new( + GetStringClaim(root, "owner_id"), + GetStringClaim(root, "owner"), + GetStringClaim(root, "project"), + GetStringClaim(root, "project_id")); + } + catch (Exception ex) when (ex is FormatException or JsonException) + { + throw new DistributedApplicationException("The Vercel OIDC token payload could not be decoded.", ex); + } + } + + private static string? GetStringClaim(JsonElement root, string name) + => root.TryGetProperty(name, out var element) && element.ValueKind == JsonValueKind.String + ? element.GetString() + : null; + + private static string PadBase64Url(string value) + { + string padded = value.Replace('-', '+').Replace('_', '/'); + return padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '='); + } + + internal static Dictionary ParseDotEnvFile(IEnumerable lines) + { + // Vercel writes dotenv files such as `.vercel/.env.production.local` during pull. + // We only need the VERCEL_OIDC_TOKEN line. This intentionally supports the subset the + // CLI emits: comments/blank lines, KEY=value, single/double quoted values, and common + // backslash escapes. It is not a general dotenv evaluator with interpolation. + Dictionary values = new(StringComparer.Ordinal); + + foreach (string rawLine in lines) + { + string line = rawLine.Trim(); + if (line.Length == 0 || line[0] == '#') + { + continue; + } + + int separator = line.IndexOf('='); + if (separator <= 0) + { + continue; + } + + string key = line[..separator].Trim(); + string value = line[(separator + 1)..].Trim(); + values[key] = UnquoteDotEnvValue(value); + } + + return values; + } + + private static string UnquoteDotEnvValue(string value) + { + if (value.Length >= 2 + && ((value[0] == '"' && value[^1] == '"') + || (value[0] == '\'' && value[^1] == '\''))) + { + value = value[1..^1]; + } + + return value.Replace("\\n", "\n", StringComparison.Ordinal) + .Replace("\\r", "\r", StringComparison.Ordinal) + .Replace("\\\"", "\"", StringComparison.Ordinal) + .Replace("\\\\", "\\", StringComparison.Ordinal); + } + + private static bool IsSha256Digest([NotNullWhen(true)] string? value) + => value is not null && Regex.IsMatch(value, "^sha256:[a-f0-9]{64}$", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); + + private static void DeleteIfExists(string path) + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + + private static void DeleteDirectoryIfExists(string path) + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + + private static async Task EnsureManagedProjectAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry) + { + // `vercel project add` is idempotent for the current login/scope in the CLI versions + // this integration supports: it creates the project or validates that it already + // exists and is accessible. Failure here means deploy should not proceed to image push. + string projectName = GetVercelProjectName(entry); + string[] arguments = BuildAddProjectArguments(options, projectName); + var result = await runner.RunAsync(VercelCliFileName, arguments, workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException($"create or validate Vercel project '{projectName}' for resource '{entry.Resource.Name}'", VercelCliFileName, result); + } + } + + private static string GetVercelProjectEnvironmentName(VercelEnvironmentOptionsAnnotation options) + { + if (options.Production) + { + return "production"; + } + + return string.IsNullOrWhiteSpace(options.Target) ? "preview" : options.Target; + } + + private static string GetVercelProjectEnvironmentName(VercelDeploymentState state) + { + if (state.Production) + { + return "production"; + } + + return string.IsNullOrWhiteSpace(state.Target) ? "preview" : state.Target; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Cli.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Cli.cs new file mode 100644 index 000000000..042885a5a --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Cli.cs @@ -0,0 +1,494 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES002 +#pragma warning disable ASPIREPIPELINES003 +#pragma warning disable ASPIREPIPELINES004 +#pragma warning disable ASPIRECOMPUTE003 +#pragma warning disable ASPIREPROBES001 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.ApplicationModel.Docker; +using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Publishing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal static partial class VercelDeploymentStep +{ + public static async Task ValidateCliPrerequisitesAsync(PipelineStepContext context, VercelEnvironmentResource environment) + { + var options = environment.GetVercelOptions(); + var runner = context.Services.GetRequiredService(); + + var versionResult = await runner.RunAsync(VercelCliFileName, ["--version"], workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + if (!versionResult.Succeeded) + { + throw CreateCliException("validate Vercel CLI installation", VercelCliFileName, versionResult); + } + + var versionOutput = $"{versionResult.StandardOutput}{Environment.NewLine}{versionResult.StandardError}"; + if (!TryGetVercelCliVersion(versionOutput, out var version)) + { + throw new DistributedApplicationException( + $"Failed to determine Vercel CLI version from '{GetTrimmedOutput(versionOutput)}'. Install Vercel CLI {MinimumVercelCliVersion} or later from https://vercel.com/docs/cli."); + } + + // The preview relies on newer CLI behavior: project-scoped link/pull, + // prebuilt deploys, deployment-scoped --env, JSON inspect output with + // --wait/--timeout, and project removal. + if (version < MinimumVercelCliVersion) + { + throw new DistributedApplicationException( + $"Vercel CLI version '{version}' is not supported. Install Vercel CLI {MinimumVercelCliVersion} or later from https://vercel.com/docs/cli."); + } + + var whoamiResult = await runner.RunAsync(VercelCliFileName, ["whoami"], workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + if (!whoamiResult.Succeeded) + { + throw CreateCliException("validate Vercel authentication", VercelCliFileName, whoamiResult); + } + + if (!string.IsNullOrWhiteSpace(options.Scope)) + { + var scopeResult = await runner.RunAsync(VercelCliFileName, BuildValidateScopeArguments(options), workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + if (!scopeResult.Succeeded) + { + throw CreateCliException($"validate Vercel scope '{options.Scope}'", VercelCliFileName, scopeResult); + } + } + } + + internal static string[] BuildDeployArguments(VercelEnvironmentOptionsAnnotation options, VercelDeploymentEntry entry) + => BuildDeployArguments(options, GetDeployDirectory(entry), GetVercelProjectOption(entry), environmentVariables: []); + + // Keep CLI argument construction as pure array-returning helpers. Tests assert exact + // argument boundaries so Vercel quirks such as `env add` requiring --cwd, not --project, + // cannot regress into shell-quoted or source-mutating command strings. + internal static string[] BuildDockerInspectDigestArguments(string imageReference) + => ["buildx", "imagetools", "inspect", "--format", "{{json .Manifest}}", imageReference]; + + internal static string[] BuildDestroyProjectArguments(VercelEnvironmentOptionsAnnotation options, string projectName) + { + List arguments = []; + + arguments.Add("project"); + arguments.Add("remove"); + arguments.Add(projectName); + AddOptionalScopeArgument(arguments, options); + + return [.. arguments]; + } + + internal static string[] BuildListProjectEnvironmentVariablesArguments( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string targetEnvironment) + { + List arguments = []; + + arguments.Add("env"); + arguments.Add("ls"); + arguments.Add(targetEnvironment); + AddOptionalScopeArgument(arguments, options); + arguments.Add("--cwd"); + arguments.Add(projectLinkDirectory); + arguments.Add("--format=json"); + + return [.. arguments]; + } + + internal static string[] BuildAddProjectArguments(VercelEnvironmentOptionsAnnotation options, string projectName) + { + List arguments = []; + + arguments.Add("project"); + arguments.Add("add"); + arguments.Add(projectName); + AddOptionalScopeArgument(arguments, options); + + return [.. arguments]; + } + + internal static string[] BuildAddProjectEnvironmentVariableArguments( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string name, + string targetEnvironment) + { + List arguments = []; + + arguments.Add("env"); + arguments.Add("add"); + arguments.Add(name); + arguments.Add(targetEnvironment); + AddOptionalScopeArgument(arguments, options); + // Vercel env commands must run inside a linked project directory. Use the + // Aspire-owned scratch link instead of the source root so provider metadata + // and pulled env files never mutate the user's checkout. + arguments.Add("--cwd"); + arguments.Add(projectLinkDirectory); + arguments.Add("--yes"); + arguments.Add("--force"); + arguments.Add("--sensitive"); + + return [.. arguments]; + } + + internal static string[] BuildRemoveProjectEnvironmentVariableArguments( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string name, + string targetEnvironment) + { + List arguments = []; + + arguments.Add("env"); + arguments.Add("rm"); + arguments.Add(name); + arguments.Add(targetEnvironment); + AddOptionalScopeArgument(arguments, options); + arguments.Add("--cwd"); + arguments.Add(projectLinkDirectory); + arguments.Add("--yes"); + + return [.. arguments]; + } + + internal static string[] BuildLinkProjectArguments( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string projectNameOrId) + { + List arguments = []; + + arguments.Add("link"); + AddOptionalScopeArgument(arguments, options); + arguments.Add("--cwd"); + arguments.Add(projectLinkDirectory); + arguments.Add("--yes"); + arguments.Add("--project"); + arguments.Add(projectNameOrId); + + return [.. arguments]; + } + + internal static string[] BuildPullProjectSettingsArguments( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string targetEnvironment) + { + List arguments = []; + + arguments.Add("pull"); + AddOptionalScopeArgument(arguments, options); + arguments.Add("--cwd"); + arguments.Add(projectLinkDirectory); + arguments.Add("--yes"); + arguments.Add("--environment"); + arguments.Add(targetEnvironment); + + return [.. arguments]; + } + + internal static string[] BuildDockerLoginArguments(string username) + => ["login", VcrRegistry, "--username", username, "--password-stdin"]; + + private static void AddOptionalProjectArgument(List arguments, string? projectNameOrId) + { + if (!string.IsNullOrWhiteSpace(projectNameOrId)) + { + arguments.Add("--project"); + arguments.Add(projectNameOrId); + } + } + + private static void AddOptionalScopeArgument(List arguments, VercelEnvironmentOptionsAnnotation options) + { + if (!string.IsNullOrWhiteSpace(options.Scope)) + { + arguments.Add("--scope"); + arguments.Add(options.Scope); + } + } + + internal static string[] BuildInspectDeploymentArguments(VercelEnvironmentOptionsAnnotation options, string deploymentUrl) + { + List arguments = []; + + arguments.Add("inspect"); + arguments.Add(deploymentUrl); + AddOptionalScopeArgument(arguments, options); + arguments.Add("--wait"); + arguments.Add("--timeout"); + arguments.Add("120s"); + arguments.Add("--format=json"); + + return [.. arguments]; + } + + internal static string[] BuildValidateScopeArguments(VercelEnvironmentOptionsAnnotation options) + => BuildListProjectsArguments(options); + + internal static string[] BuildListProjectsArguments(VercelEnvironmentOptionsAnnotation options, string? filter = null) + { + List arguments = []; + + arguments.Add("project"); + arguments.Add("ls"); + AddOptionalScopeArgument(arguments, options); + if (!string.IsNullOrWhiteSpace(filter)) + { + arguments.Add("--filter"); + arguments.Add(filter); + } + + arguments.Add("--format=json"); + + return [.. arguments]; + } + + private static async Task VerifyDeploymentAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + string resourceName, + VercelDeploymentResult deploymentResult) + { + // A successful `vercel deploy` only means the CLI accepted the submission. + // Query the provider before recording state so Aspire does not persist failed + // or still-building deployments as successfully applied resources. + string[] arguments = BuildInspectDeploymentArguments(options, deploymentResult.DeploymentUrl); + var result = await runner.RunAsync(VercelCliFileName, arguments, workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + + if (!result.Succeeded) + { + throw CreateCliException($"verify Vercel deployment for resource '{resourceName}'", VercelCliFileName, result); + } + + var inspection = GetDeploymentInspection(result.StandardOutput); + if (inspection.ReadyState is null) + { + throw new DistributedApplicationException($"Vercel inspect output for resource '{resourceName}' did not include a deployment ready state. Output: {GetTrimmedOutput(result.StandardOutput)}"); + } + + if (!string.Equals(inspection.ReadyState, "READY", StringComparison.OrdinalIgnoreCase)) + { + throw new DistributedApplicationException($"Vercel deployment for resource '{resourceName}' finished with state '{inspection.ReadyState}' instead of 'READY'."); + } + } + + + private static string[] BuildDeployArguments( + VercelEnvironmentOptionsAnnotation options, + string deployDirectory, + string? projectNameOrId, + IReadOnlyList> environmentVariables) + { + List arguments = []; + + arguments.Add("deploy"); + AddOptionalScopeArgument(arguments, options); + arguments.Add("--cwd"); + arguments.Add(deployDirectory); + AddOptionalProjectArgument(arguments, projectNameOrId); + arguments.Add("--prebuilt"); + arguments.Add("--yes"); + + if (options.Production) + { + arguments.Add("--prod"); + } + + if (!string.IsNullOrWhiteSpace(options.Target)) + { + arguments.Add("--target"); + arguments.Add(options.Target); + } + + foreach (var environmentVariable in environmentVariables.OrderBy(static variable => variable.Key, StringComparer.Ordinal)) + { + arguments.Add("--env"); + arguments.Add($"{environmentVariable.Key}={environmentVariable.Value}"); + } + + return [.. arguments]; + } + + private static string BuildDisplayDeployCommand( + VercelEnvironmentOptionsAnnotation options, + string resourceName, + IReadOnlyList> environmentVariables) + { + // The plan should explain the command shape without leaking concrete source roots, + // project names, or environment values that may be machine- or account-specific. + var displayEnvironmentVariables = environmentVariables + .Select(static environmentVariable => new KeyValuePair(environmentVariable.Key, "")) + .ToArray(); + + string displayImage = $"vcr.vercel.com///{VercelContainerServiceName}:"; + string displayDeployDirectory = $"<{resourceName}-build-output>"; + string displayProject = $"<{resourceName}-vercel-project>"; + return $"vercel pull --cwd <{resourceName}-vercel-project-link> --yes --environment {GetVercelProjectEnvironmentName(options)} && aspire build/push {resourceName} -> {displayImage} && docker {string.Join(" ", BuildDockerInspectDigestArguments(displayImage))} && vercel {string.Join(" ", BuildDeployArguments(options, displayDeployDirectory, displayProject, displayEnvironmentVariables))}"; + } + + + internal static string GetDeploymentUrl(string standardOutput) + => GetDeploymentResult(standardOutput).DeploymentUrl; + + internal static VercelDeploymentResult GetDeploymentResult(string standardOutput) + { + // `vercel deploy` output has changed between CLI versions and flags. Prefer structured + // JSON when present, then fall back to the last plain HTTP(S) URL printed by the CLI. + if (TryGetJsonDeploymentResult(standardOutput) is { } jsonDeploymentResult) + { + return jsonDeploymentResult; + } + + // Older CLI versions printed the deployment URL as plain text. Keep the fallback so + // we fail only when no usable URL exists, not because formatting changed slightly. + string[] lines = standardOutput + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + string? deploymentUrl = lines.LastOrDefault(IsHttpUrl); + if (deploymentUrl is null) + { + throw new DistributedApplicationException($"Vercel deploy output did not contain an HTTP or HTTPS deployment URL. Output: {GetTrimmedOutput(standardOutput)}"); + } + + return new(DeploymentId: null, deploymentUrl); + } + + internal static VercelDeploymentInspection GetDeploymentInspection(string standardOutput) + { + try + { + // Parse the Vercel inspect JSON shapes observed across CLI versions: + // { "readyState": "READY" } + // { "state": "READY" } + // { "deployment": { "readyState": "READY" } } + // { "deployment": { "state": "READY" } } + using var document = JsonDocument.Parse(standardOutput); + var root = document.RootElement; + string? readyState = GetJsonStringProperty(root, "readyState") + ?? GetJsonStringProperty(root, "state") + ?? (root.TryGetProperty("deployment", out var deployment) ? GetJsonStringProperty(deployment, "readyState") : null) + ?? (root.TryGetProperty("deployment", out deployment) ? GetJsonStringProperty(deployment, "state") : null); + + return new(readyState); + } + catch (JsonException ex) + { + throw new DistributedApplicationException("Failed to parse JSON output from 'vercel inspect'.", ex); + } + } + + private static VercelDeploymentResult? TryGetJsonDeploymentResult(string standardOutput) + { + if (!standardOutput.AsSpan().TrimStart().StartsWith("{", StringComparison.Ordinal)) + { + return null; + } + + try + { + using var document = JsonDocument.Parse(standardOutput); + var root = document.RootElement; + + if (TryGetDeploymentResult(root, out var deploymentResult)) + { + return deploymentResult; + } + } + catch (JsonException) + { + // Older Vercel CLI output is plain text; fall back to line-based URL extraction. + } + + return null; + } + + private static bool TryGetDeploymentResult(JsonElement root, [NotNullWhen(true)] out VercelDeploymentResult? deploymentResult) + { + // Parse the Vercel deploy JSON shapes observed from different CLI versions: + // { "deployment": { "url": "https://...", "id": "..." } } + // { "url": "https://...", "id": "..." } + // Callers fall back to line-based extraction when deploy output is plain text. + if (root.TryGetProperty("deployment", out var deployment) + && deployment.TryGetProperty("url", out var nestedUrl) + && TryGetHttpUrl(nestedUrl, out var nestedDeploymentUrl)) + { + string? deploymentId = deployment.TryGetProperty("id", out var nestedId) && nestedId.ValueKind == JsonValueKind.String + ? nestedId.GetString() + : null; + + deploymentResult = new(deploymentId, nestedDeploymentUrl); + return true; + } + + if (root.TryGetProperty("url", out var url) + && TryGetHttpUrl(url, out var rootDeploymentUrl)) + { + string? deploymentId = root.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String + ? id.GetString() + : null; + + deploymentResult = new(deploymentId, rootDeploymentUrl); + return true; + } + + deploymentResult = null; + return false; + } + + private static bool TryGetHttpUrl(JsonElement urlElement, [NotNullWhen(true)] out string? url) + { + url = urlElement.ValueKind == JsonValueKind.String + ? urlElement.GetString() + : null; + + return IsHttpUrl(url); + } + + private static bool IsHttpUrl([NotNullWhen(true)] string? url) + => Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http"; + + internal static bool TryGetVercelCliVersion(string output, [NotNullWhen(true)] out Version? version) + { + // The CLI can print banners/warnings around the version. Extract the first semantic + // x.y.z token instead of requiring a line to be exactly the version string. + var match = Regex.Match(output, @"(?\d+)\.(?\d+)\.(?\d+)(?!\d)", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); + if (!match.Success) + { + version = null; + return false; + } + + version = new( + int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture), + int.Parse(match.Groups["minor"].Value, CultureInfo.InvariantCulture), + int.Parse(match.Groups["patch"].Value, CultureInfo.InvariantCulture)); + return true; + } + + private static string GetTrimmedOutput(string output) + => string.IsNullOrWhiteSpace(output) ? "" : output.Trim(); + + private static DistributedApplicationException CreateCliException(string operation, string cliPath, VercelCliResult result) + { + string output = string.IsNullOrWhiteSpace(result.StandardError) + ? result.StandardOutput.Trim() + : result.StandardError.Trim(); + + return new DistributedApplicationException($"Failed to {operation} using '{cliPath}' (exit code {result.ExitCode}). {output}"); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Environment.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Environment.cs new file mode 100644 index 000000000..3d8d981c5 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Environment.cs @@ -0,0 +1,416 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES002 +#pragma warning disable ASPIREPIPELINES003 +#pragma warning disable ASPIREPIPELINES004 +#pragma warning disable ASPIRECOMPUTE003 +#pragma warning disable ASPIREPROBES001 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.ApplicationModel.Docker; +using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Publishing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal static partial class VercelDeploymentStep +{ + private static async Task GetVercelEnvironmentConfigurationAsync( + IResource resource, + VercelEnvironmentOptionsAnnotation options, + IExecutionConfigurationResult executionConfiguration, + IReadOnlyDictionary entriesByResourceName, + bool resolveProjectEnvironmentVariableValues, + CancellationToken cancellationToken) + { + List> deploymentEnvironmentVariables = []; + List> projectEnvironmentVariables = []; + HashSet names = new(StringComparer.Ordinal); + + // This is the env-var edge-case boundary. Vercel has deployment env args and + // project env secrets, but no Aspire-style connection binding/service-discovery + // object in this preview. Only deterministic production endpoint URLs are projected; + // other resource references fail here instead of becoming misleading strings. + // Keep a single pass over Aspire's unprocessed environment dictionary. The processed + // value can contain publish-mode manifest expressions, but deployment needs the + // original graph value so it can choose Vercel's concrete URL/secret mechanism. + foreach (var environmentVariable in executionConfiguration.EnvironmentVariablesWithUnprocessed) + { + string name = environmentVariable.Key; + object unprocessedValue = environmentVariable.Value.Item1; + string value = environmentVariable.Value.Item2; + + ValidateEnvironmentVariableName(resource, name); + if (!names.Add(name)) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures environment variable '{name}' more than once. Vercel project environment variable names must be unique."); + } + + if (ContainsUnsupportedResourceReference(resource, unprocessedValue)) + { + throw new DistributedApplicationException( + $"Environment variable '{name}' for resource '{resource.Name}' references another Aspire resource or service in a way that cannot be represented as a Vercel deployment URL. Use endpoint references to Vercel production workloads, or configure the value in Vercel project environment variables."); + } + + // Non-secrets can ride on `vercel deploy --env`; secret-bearing values must use + // Vercel project environment variables so values never appear in CLI arguments. + bool containsSecret = ContainsSecretReference(unprocessedValue); + if (containsSecret) + { + value = resolveProjectEnvironmentVariableValues + ? await GetVercelProjectEnvironmentVariableValueAsync( + resource, + options, + entriesByResourceName, + unprocessedValue, + value, + cancellationToken).ConfigureAwait(false) + : ""; + } + else if (TryGetVercelEnvironmentVariableValue(resource, options, entriesByResourceName, unprocessedValue, out string? vercelValue)) + { + value = vercelValue; + } + + if (containsSecret) + { + projectEnvironmentVariables.Add(new(name, value)); + } + else + { + deploymentEnvironmentVariables.Add(new(name, value)); + } + } + + return new(deploymentEnvironmentVariables, projectEnvironmentVariables); + } + + private static async ValueTask GetVercelProjectEnvironmentVariableValueAsync( + IResource resource, + VercelEnvironmentOptionsAnnotation options, + IReadOnlyDictionary entriesByResourceName, + object? value, + string processedValue, + CancellationToken cancellationToken) + { + // This path resolves values for `vercel env add`, not `vercel deploy --env`. + // Values can be secret-bearing because they are sent on stdin to Vercel's secret + // store; they must not be copied into publish plans, command lines, or state. + switch (value) + { + case null: + return processedValue; + case string stringValue: + return stringValue; + case ParameterResource parameter: + return await GetParameterValueAsync(parameter, cancellationToken).ConfigureAwait(false); + case IResourceBuilder parameterBuilder: + return await GetParameterValueAsync(parameterBuilder.Resource, cancellationToken).ConfigureAwait(false); + case IResourceWithConnectionString connectionStringResource: + return await GetValueProviderValueAsync(connectionStringResource.ConnectionStringExpression, $"connection string for resource '{connectionStringResource.Name}'", cancellationToken).ConfigureAwait(false); + case IResourceBuilder connectionStringBuilder: + return await GetValueProviderValueAsync(connectionStringBuilder.Resource.ConnectionStringExpression, $"connection string for resource '{connectionStringBuilder.Resource.Name}'", cancellationToken).ConfigureAwait(false); + case ReferenceExpression referenceExpression: + return await GetVercelProjectReferenceExpressionValueAsync(resource, options, entriesByResourceName, referenceExpression, cancellationToken).ConfigureAwait(false); + case IValueProvider valueProvider: + return await GetValueProviderValueAsync(valueProvider, "environment variable value", cancellationToken).ConfigureAwait(false); + default: + return processedValue; + } + } + + private static async ValueTask GetVercelProjectReferenceExpressionValueAsync( + IResource resource, + VercelEnvironmentOptionsAnnotation options, + IReadOnlyDictionary entriesByResourceName, + ReferenceExpression referenceExpression, + CancellationToken cancellationToken) + { + if (referenceExpression.IsConditional) + { + throw new DistributedApplicationException("Vercel project environment variables do not support conditional reference expressions. Configure a concrete Vercel project environment variable instead."); + } + + var arguments = new object?[referenceExpression.ValueProviders.Count]; + for (int i = 0; i < referenceExpression.ValueProviders.Count; i++) + { + IValueProvider valueProvider = referenceExpression.ValueProviders[i]; + arguments[i] = valueProvider switch + { + // Secret-bearing project env vars may combine endpoint URLs with secret + // parameters because the final value is sent through Vercel's secret path. + EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference) => GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReference.Property(EndpointProperty.Url)), + EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint) => GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReferenceExpression), + ParameterResource parameter => await GetParameterValueAsync(parameter, cancellationToken).ConfigureAwait(false), + IResourceWithConnectionString connectionStringResource => await GetValueProviderValueAsync(connectionStringResource.ConnectionStringExpression, $"connection string for resource '{connectionStringResource.Name}'", cancellationToken).ConfigureAwait(false), + _ => await GetValueProviderValueAsync(valueProvider, "reference expression value", cancellationToken).ConfigureAwait(false) + }; + + if (referenceExpression.StringFormats[i] is "uri" && arguments[i] is string stringValue) + { + arguments[i] = Uri.EscapeDataString(stringValue); + } + } + + return string.Format(CultureInfo.InvariantCulture, referenceExpression.Format, arguments); + } + + private static async ValueTask GetParameterValueAsync(ParameterResource parameter, CancellationToken cancellationToken) + { + try + { + string? value = await parameter.GetValueAsync(cancellationToken).ConfigureAwait(false); + return value ?? throw new DistributedApplicationException($"Secret parameter '{parameter.Name}' did not produce a value for Vercel project environment configuration."); + } + catch (MissingParameterValueException ex) + { + throw new DistributedApplicationException($"Secret parameter '{parameter.Name}' does not have a value. Provide a value before deploying to Vercel.", ex); + } + } + + private static async ValueTask GetValueProviderValueAsync(IValueProvider valueProvider, string description, CancellationToken cancellationToken) + { + try + { + string? value = await valueProvider.GetValueAsync(cancellationToken).ConfigureAwait(false); + return value ?? throw new DistributedApplicationException($"The {description} did not produce a value for Vercel project environment configuration."); + } + catch (MissingParameterValueException ex) + { + throw new DistributedApplicationException($"The {description} does not have a value. Provide a value before deploying to Vercel.", ex); + } + } + + private static bool TryGetVercelEnvironmentVariableValue( + IResource resource, + VercelEnvironmentOptionsAnnotation options, + IReadOnlyDictionary entriesByResourceName, + object? value, + [NotNullWhen(true)] out string? vercelValue) + { + // Service-discovery env vars generated by WithReference also arrive as structured + // endpoint values, so translate by value shape instead of by environment variable name. + switch (value) + { + case EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference): + vercelValue = GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReference.Property(EndpointProperty.Url)); + return true; + case EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint): + vercelValue = GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReferenceExpression); + return true; + case ReferenceExpression referenceExpression when ContainsCrossResourceEndpointReference(resource, referenceExpression): + vercelValue = GetVercelReferenceExpressionValue(resource, options, entriesByResourceName, referenceExpression); + return true; + default: + vercelValue = null; + return false; + } + } + + private static string GetVercelReferenceExpressionValue( + IResource resource, + VercelEnvironmentOptionsAnnotation options, + IReadOnlyDictionary entriesByResourceName, + ReferenceExpression referenceExpression) + { + if (referenceExpression.IsConditional) + { + throw new DistributedApplicationException("Vercel endpoint references do not support conditional reference expressions. Configure a concrete Vercel project environment variable instead."); + } + + var arguments = new object?[referenceExpression.ValueProviders.Count]; + for (int i = 0; i < referenceExpression.ValueProviders.Count; i++) + { + IValueProvider valueProvider = referenceExpression.ValueProviders[i]; + arguments[i] = valueProvider switch + { + EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference) => GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReference.Property(EndpointProperty.Url)), + EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint) => GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReferenceExpression), + // Mixed expressions can hide provider-specific ordering or secret semantics. + // Keep this path to deterministic endpoint-only production URLs. + _ => throw new DistributedApplicationException("Vercel endpoint reference expressions cannot be combined with parameters, secrets, or other value providers. Configure a concrete Vercel project environment variable instead.") + }; + + if (referenceExpression.StringFormats[i] is "uri" && arguments[i] is string stringValue) + { + arguments[i] = Uri.EscapeDataString(stringValue); + } + } + + return string.Format(CultureInfo.InvariantCulture, referenceExpression.Format, arguments); + } + + private static string GetVercelEndpointPropertyValue( + IResource resource, + VercelEnvironmentOptionsAnnotation options, + IReadOnlyDictionary entriesByResourceName, + EndpointReferenceExpression endpointReferenceExpression) + { + // This is the endpoint-reference edge-case boundary: preview/custom URLs are + // post-deploy outputs, internal endpoints have no public Vercel edge address, and + // cross-environment references lack a stable same-deploy alias. Fail before values + // are written to Vercel env vars. + if (!options.Production) + { + throw new DistributedApplicationException( + "Vercel endpoint references require production deployments because preview and custom target URLs are assigned by Vercel after deployment. Call WithVercelProductionDeployments on the Vercel environment, or remove the reference."); + } + + var endpointReference = endpointReferenceExpression.Endpoint; + var endpoint = endpointReference.EndpointAnnotation; + if (!endpoint.IsExternal) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' references endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}', but Vercel endpoint references can only target external HTTP or HTTPS endpoints. Configure an external endpoint or remove the reference."); + } + + if (!IsHttpEndpoint(endpoint)) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' references endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}' with scheme '{endpoint.UriScheme}', but Vercel endpoint references support only HTTP or HTTPS endpoints."); + } + + if (!entriesByResourceName.TryGetValue(endpointReference.Resource.Name, out var referencedEntry)) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' references endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}', but the referenced resource does not target this Vercel environment. Vercel endpoint references can only target workloads deployed to the same Vercel environment."); + } + + string host = $"{GetVercelProjectName(referencedEntry)}.vercel.app"; + const int port = 443; + + return endpointReferenceExpression.Property switch + { + // Production aliases are deterministic before deploy; preview/custom URLs are not. + // Keep endpoint references on the stable caller-visible Vercel HTTPS surface. + EndpointProperty.Url => $"https://{host}", + EndpointProperty.Host or EndpointProperty.IPV4Host => host, + EndpointProperty.Port => port.ToString(CultureInfo.InvariantCulture), + EndpointProperty.TargetPort => endpoint.TargetPort is int targetPort + ? targetPort.ToString(CultureInfo.InvariantCulture) + : throw new DistributedApplicationException( + // Azure publishers can carry ContainerPortReference placeholders in + // Bicep/Helm. Vercel deploy receives concrete CLI env values, so an + // unresolved TargetPort would become a bogus string rather than a + // target-native reference. + $"Resource '{resource.Name}' references endpoint property '{EndpointProperty.TargetPort}' for endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}', but the endpoint does not define an explicit target port. Configure a target port or avoid passing TargetPort to Vercel."), + EndpointProperty.Scheme => "https", + EndpointProperty.HostAndPort => $"{host}:{port.ToString(CultureInfo.InvariantCulture)}", + EndpointProperty.TlsEnabled => bool.TrueString, + _ => throw new DistributedApplicationException($"The endpoint property '{endpointReferenceExpression.Property}' is not supported for Vercel endpoint references.") + }; + } + + private static void ValidateEnvironmentVariableName(IResource resource, string name) + { + // Do not remap names to satisfy Vercel's env var shape. A lossy rename would break + // the consuming workload's contract, so invalid names fail with the original key. + if (string.IsNullOrWhiteSpace(name) + || (!char.IsAsciiLetter(name[0]) && name[0] != '_') + || name.Any(static character => !char.IsAsciiLetterOrDigit(character) && character != '_')) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures invalid Vercel environment variable name '{name}'. Use letters, digits, and underscores, and start with a letter or underscore."); + } + } + + private static void ValidateUnsupportedRuntimeConfiguration( + IResource resource, + IExecutionConfigurationResult executionConfiguration) + { + // These Aspire concepts have no faithful Vercel Dockerfile-deploy equivalent in this + // preview. Rejecting them is safer than silently dropping entrypoint, args, or build + // values that would change the workload's deployed behavior. Aspire's built-in + // image build/push pipeline owns build-time Docker options; this validation only + // rejects runtime concepts the Vercel Build Output API path cannot preserve. + if (resource is ContainerResource { Entrypoint: not null }) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures a container entrypoint, but Vercel Dockerfile deployments use the CMD/ENTRYPOINT from Aspire's publish output. Configure the workload's publish behavior or Vercel project settings instead."); + } + + if (executionConfiguration.ArgumentsWithUnprocessed.Any()) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures Aspire command-line arguments, but Vercel Dockerfile deployments cannot override Docker CMD/ENTRYPOINT. Configure the workload's publish behavior or express the values as environment variables."); + } + } + + private static bool ContainsSecretReference(object? value) + { + // Connection strings are treated as secret-bearing even when the underlying provider + // does not mark each segment secret; Vercel should receive them through project env. + return value switch + { + null => false, + string => false, + ParameterResource parameter => parameter.Secret, + IResourceBuilder parameterBuilder => parameterBuilder.Resource.Secret, + IResourceWithConnectionString => true, + IResourceBuilder => true, + IValueWithReferences valueWithReferences => valueWithReferences.References.Any(ContainsSecretReference), + _ => false + }; + } + + private static bool ContainsCrossResourceEndpointReference(IResource resource, object? value) + { + return value switch + { + null => false, + EndpointReference endpointReference => IsCrossResourceEndpointReference(resource, endpointReference), + EndpointReferenceExpression endpointReferenceExpression => IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint), + IValueWithReferences valueWithReferences => valueWithReferences.References.Any(reference => ContainsCrossResourceEndpointReference(resource, reference)), + _ => false + }; + } + + private static bool IsCrossResourceEndpointReference(IResource resource, EndpointReference endpointReference) + => !IsSameResource(resource, endpointReference.Resource); + + private static bool ContainsUnsupportedResourceReference(IResource resource, object? value) + { + // Vercel only knows how to turn endpoint references into deterministic production + // aliases. Other resource references can represent connection strings, parameters, + // or custom values that need a target-native mechanism this preview does not have. + return value switch + { + null => false, + string => false, + ParameterResource => false, + IResourceBuilder => false, + EndpointReference => false, + EndpointReferenceExpression => false, + IResource referencedResource => !IsSameResource(resource, referencedResource), + IValueWithReferences valueWithReferences => valueWithReferences.References.Any(reference => ContainsUnsupportedResourceReference(resource, reference)), + IResourceBuilder resourceBuilder => !IsSameResource(resource, resourceBuilder.Resource), + _ => false + }; + } + + private static bool IsSameResource(IResource resource, IResource otherResource) + // Compare by Aspire resource name rather than object identity. Polyglot/ATS flows + // can recreate resource references across an RPC boundary, but resource name is the + // app-model identity used by deployment target maps. + => string.Equals(resource.Name, otherResource.Name, StringComparison.Ordinal); + + private static bool IsHttpEndpoint(EndpointAnnotation endpoint) + // URI scheme alone is not enough: Aspire can model a TCP transport with an HTTP + // URI scheme. Vercel's container ingress here is HTTP-family traffic over TCP. + => endpoint.Protocol == ProtocolType.Tcp + && (string.Equals(endpoint.UriScheme, "http", StringComparison.OrdinalIgnoreCase) + || string.Equals(endpoint.UriScheme, "https", StringComparison.OrdinalIgnoreCase)) + && (string.Equals(endpoint.Transport, "http", StringComparison.OrdinalIgnoreCase) + || string.Equals(endpoint.Transport, "http2", StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Model.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Model.cs new file mode 100644 index 000000000..c45a5c756 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Model.cs @@ -0,0 +1,517 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES002 +#pragma warning disable ASPIREPIPELINES003 +#pragma warning disable ASPIREPIPELINES004 +#pragma warning disable ASPIRECOMPUTE003 +#pragma warning disable ASPIREPROBES001 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.ApplicationModel.Docker; +using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Publishing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal static partial class VercelDeploymentStep +{ + private static IReadOnlyDictionary GetDeploymentEntriesByResourceName(IReadOnlyList entries) + => entries.ToDictionary(static entry => entry.Resource.Name, StringComparer.Ordinal); + + internal static IEnumerable GetDeploymentEntries(DistributedApplicationModel model, VercelEnvironmentResource environment) + { + var computeEnvironments = model.Resources.OfType().Take(2).ToArray(); + // Match Aspire's single-environment convention only when Vercel is the sole compute + // environment. With mixed targets, implicit selection would accidentally deploy + // untargeted resources to every environment. + bool allowImplicitTargeting = computeEnvironments.Length == 1 && ReferenceEquals(computeEnvironments[0], environment); + + foreach (var resource in model.Resources.OfType()) + { + if (!IsTargetedToEnvironment(resource, environment, allowImplicitTargeting)) + { + continue; + } + + if (resource.TryGetLastAnnotation(out var dockerfile)) + { + yield return new(resource, dockerfile.ContextPath, dockerfile.DockerfilePath, dockerfile); + continue; + } + + if (resource is ProjectResource project) + { + string projectPath = project.GetProjectMetadata().ProjectPath; + string sourceRoot = Path.GetDirectoryName(projectPath) + ?? throw new DistributedApplicationException($"Project resource '{resource.Name}' has project path '{projectPath}' without a containing directory."); + yield return new(resource, sourceRoot); + continue; + } + + throw new DistributedApplicationException($"Resource '{resource.Name}' targets Vercel but is not an Aspire image build resource. Use a .NET project, a workload integration that publishes Dockerfile metadata, call PublishAsDockerFile, or configure the resource with WithDockerfile, WithDockerfileFactory, or WithDockerfileBuilder."); + } + } + + private static bool IsTargetedToEnvironment(IResource resource, VercelEnvironmentResource environment, bool allowImplicitTargeting) + { + var computeEnvironment = resource.GetComputeEnvironment(); + // Match Aspire's single-environment convention: when Vercel is the only compute + // environment, image-build workloads implicitly target it. + return ReferenceEquals(computeEnvironment, environment) + || (computeEnvironment is null && allowImplicitTargeting); + } + + private static void ValidateEntries(IReadOnlyList entries) + { + // Keep unsupported Vercel-preview cases in validation so publish/prereq/deploy fail + // before mutating provider projects. Each failure names the Aspire concept that + // cannot be projected rather than letting a later Vercel CLI call fail opaquely. + if (entries.Count == 0) + { + throw new DistributedApplicationException("No image-build compute resources target Vercel. Add a .NET project, a workload with Aspire Dockerfile publish metadata, or use WithComputeEnvironment to target Vercel when multiple compute environments are present."); + } + + foreach (var entry in entries) + { + if (!Directory.Exists(entry.SourceRoot)) + { + throw new DistributedApplicationException($"The Vercel source root '{entry.SourceRoot}' for resource '{entry.Resource.Name}' does not exist."); + } + + if (entry.Dockerfile is { DockerfileFactory: null } && !File.Exists(entry.DockerfilePath!)) + { + throw new DistributedApplicationException($"The Vercel Dockerfile '{entry.DockerfilePath}' for resource '{entry.Resource.Name}' does not exist. Configure the resource with an existing Dockerfile or Aspire-generated Dockerfile metadata."); + } + + ValidateUnsupportedResourceModel(entry); + } + + ValidateUniqueVercelProjectNames(entries); + } + + private static void ValidateUniqueVercelProjectNames(IReadOnlyList entries) + { + // Production endpoint references use https://{projectName}.vercel.app. If two + // resources resolve to the same Vercel project, endpoint references and destroy + // ownership would both become ambiguous. + var projectNames = entries + .Select(entry => new + { + Entry = entry, + ProjectLink = GetVercelProjectLink(entry), + Linked = HasVercelProjectLinkFile(entry.SourceRoot) + }) + .GroupBy(item => item.ProjectLink.ProjectName, StringComparer.Ordinal) + .Where(group => group.Count() > 1) + .ToArray(); + + if (projectNames.Length == 0) + { + return; + } + + var collision = projectNames[0]; + string resources = string.Join(", ", collision.Select(static item => $"'{item.Entry.Resource.Name}'").Order(StringComparer.Ordinal)); + throw new DistributedApplicationException( + $"Multiple Vercel resources resolve to project name '{collision.Key}' ({resources}). Vercel project names must be unique per environment because each resource deploys to and references one project production URL. Use WithVercelProjectName, distinct source directory names, or link each resource to a distinct Vercel project with .vercel/project.json."); + } + + private static void ValidateUnsupportedResourceModel(VercelDeploymentEntry entry) + { + IResource resource = entry.Resource; + + // This method is intentionally conservative. Each rejected annotation has run-mode + // or another deployment-target semantics that Vercel's Dockerfile deploy cannot + // project without changing what the user modeled in the AppHost. If Vercel gains a + // native equivalent later, add the mapping and tests here instead of silently ignoring it. + if (resource.Annotations.OfType().Any()) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures Aspire generic container registry/image push metadata, but Vercel deployments use Vercel Container Registry through a provider-owned prebuilt artifact. Remove WithContainerRegistry before deploying with the Aspire Vercel integration."); + } + + if (resource.Annotations.OfType().Any()) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures Aspire container volumes or bind mounts, but Vercel Dockerfile deployments do not support Aspire-managed container mounts. Move persistent state to a Vercel-supported external service or remove the mount."); + } + + if (resource.Annotations.OfType().Any() + || resource.Annotations.OfType().Any() + || resource.Annotations.OfType().Any()) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures Aspire container file mounts, but Vercel Dockerfile deployments build the checked-in source tree directly. Include required files in the source tree or deploy this resource outside the Aspire Vercel integration."); + } + + if (resource.Annotations.OfType().Any() + || resource.Annotations.OfType().Any() + || resource.Annotations.OfType().Any()) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures Aspire health checks or container probes, but the Vercel preview integration does not map them to Vercel-native checks. Remove the Aspire probes or configure health behavior in Vercel."); + } + + if (resource.Annotations.OfType().Any()) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures Aspire replicas or scale, but the Vercel preview integration does not map replica counts to Vercel-native scaling. Configure scaling in Vercel instead."); + } + + if (resource.Annotations.OfType().Any()) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures Aspire wait/dependency ordering, but Vercel deploys each project independently and the preview integration does not preserve Aspire startup ordering. Remove the wait relationship or deploy dependent services separately."); + } + + ValidateEndpointModel(entry); + ValidateProjectName(entry); + } + + private static void ValidateEndpointModel(VercelDeploymentEntry entry) + { + var endpoints = entry.Resource.Annotations.OfType().ToArray(); + + if (endpoints.Length == 0) + { + return; + } + + // Reject the tempting Compose/ACA shapes up front: private listeners, multiple + // target ports, and non-HTTP protocols do not have an equivalent in this preview's + // single public Vercel container ingress. + // Vercel's Dockerfile preview exposes one public platform ingress; it has no + // Aspire-modeled private service network for internal endpoints. + var internalEndpoint = endpoints.FirstOrDefault(static endpoint => !endpoint.IsExternal); + if (internalEndpoint is not null) + { + throw new DistributedApplicationException( + $"Resource '{entry.Resource.Name}' configures endpoint '{internalEndpoint.Name}' as internal, but Vercel Dockerfile deployments expose public platform HTTPS ingress only. Mark the endpoint external or remove it before deploying to Vercel."); + } + + var unsupportedEndpoint = endpoints.FirstOrDefault(static endpoint => !IsHttpEndpoint(endpoint)); + if (unsupportedEndpoint is not null) + { + throw new DistributedApplicationException( + $"Resource '{entry.Resource.Name}' configures endpoint '{unsupportedEndpoint.Name}' with scheme '{unsupportedEndpoint.UriScheme}' and transport '{unsupportedEndpoint.Transport}', but Vercel Dockerfile deployments support only HTTP or HTTPS endpoints with HTTP transports."); + } + + var targetPorts = endpoints + .Select(static endpoint => endpoint.TargetPort) + .Where(static targetPort => targetPort.HasValue) + .Select(static targetPort => targetPort!.Value) + .Distinct() + .ToArray(); + + // Vercel provides one runtime listener through $PORT. Additional target ports would + // look like ACA extra ports, but Vercel has no equivalent modeled here. + if (targetPorts.Length > 1) + { + throw new DistributedApplicationException( + $"Resource '{entry.Resource.Name}' configures multiple Aspire endpoint target ports, but Vercel Dockerfile deployments support only one HTTP listening port exposed through the $PORT environment variable."); + } + } + + private static void ValidateProjectName(VercelDeploymentEntry entry) + { + if (HasVercelProjectLinkFile(entry.SourceRoot)) + { + return; + } + + _ = GetVercelProjectName(entry); + } + + private static async Task PrepareDeploymentEntryAsync(PipelineStepContext context, VercelDeploymentEntry entry) + { + await ValidateVercelJsonAsync(entry.Resource, entry.SourceRoot, context.CancellationToken).ConfigureAwait(false); + + var outputService = context.Services.GetRequiredService(); + string tempDirectory = outputService.GetTempDirectory(entry.Resource); + string deployDirectory = Path.Combine(tempDirectory, "vercel-build-output"); + Directory.CreateDirectory(tempDirectory); + if (Directory.Exists(deployDirectory)) + { + Directory.Delete(deployDirectory, recursive: true); + } + + // This is an output-only Build Output API root, not a staged copy of the source. + // Aspire's built-in build/push steps read the real source/project directly; Vercel + // deploy receives only generated metadata that points at the digest already pushed to VCR. + Directory.CreateDirectory(deployDirectory); + + return entry with + { + TempDirectory = tempDirectory, + DeployDirectory = deployDirectory + }; + } + + private static async Task ValidateVercelJsonAsync( + IResource resource, + string sourceRoot, + CancellationToken cancellationToken) + { + string vercelJsonPath = Path.Combine(sourceRoot, VercelJsonFileName); + if (!File.Exists(vercelJsonPath)) + { + return; + } + + JsonObject root; + try + { + root = JsonNode.Parse(await File.ReadAllTextAsync(vercelJsonPath, cancellationToken).ConfigureAwait(false)) as JsonObject + ?? throw new DistributedApplicationException($"Resource '{resource.Name}' source root contains '{VercelJsonFileName}', but it is not a JSON object."); + } + catch (JsonException ex) + { + throw new DistributedApplicationException($"Resource '{resource.Name}' source root contains invalid '{VercelJsonFileName}'.", ex); + } + + var unsupportedKey = VercelJsonBuildOutputUnsupportedKeys.FirstOrDefault(root.ContainsKey); + if (unsupportedKey is not null) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' source root contains '{VercelJsonFileName}' with top-level '{unsupportedKey}', but the Aspire Vercel integration owns the generated Build Output API container function and catch-all routing configuration. Move that setting into the Dockerfile, AppHost environment variables, or Vercel project settings before deploying with the Aspire Vercel integration."); + } + } + + private static string GetDisplayDockerfilePath(VercelDeploymentEntry entry) + => entry.Dockerfile is null + ? "" + : entry.Dockerfile.DockerfileFactory is null + ? Path.GetRelativePath(entry.SourceRoot, entry.DockerfilePath!) + : ""; + + internal static string GetVercelProjectName(VercelDeploymentEntry entry) + => GetVercelProjectLink(entry).ProjectName; + + internal static string GetVercelProjectName(IResource resource) + { + if (resource.TryGetLastAnnotation(out var dockerfile)) + { + return GetVercelProjectName(new VercelDeploymentEntry(resource, dockerfile.ContextPath, dockerfile.DockerfilePath, dockerfile)); + } + + if (resource is ProjectResource project) + { + string projectPath = project.GetProjectMetadata().ProjectPath; + string sourceRoot = Path.GetDirectoryName(projectPath) + ?? throw new DistributedApplicationException($"Project resource '{resource.Name}' has project path '{projectPath}' without a containing directory."); + return GetVercelProjectName(new VercelDeploymentEntry(resource, sourceRoot)); + } + + throw new DistributedApplicationException($"Resource '{resource.Name}' targets Vercel but is not an Aspire image build resource. Use a .NET project, a workload integration that publishes Dockerfile metadata, call PublishAsDockerFile, or configure the resource with WithDockerfile, WithDockerfileFactory, or WithDockerfileBuilder."); + } + + private static VercelProjectLink GetVercelProjectLink(VercelDeploymentEntry entry) + { + if (TryReadVercelProjectLink(entry.SourceRoot, out var projectLink)) + { + return projectLink; + } + + return new(GetManagedVercelProjectName(entry), ProjectId: null); + } + + private static string GetVercelProjectOption(VercelDeploymentEntry entry) + { + var projectLink = GetVercelProjectLink(entry); + return string.IsNullOrWhiteSpace(projectLink.ProjectId) + ? projectLink.ProjectName + : projectLink.ProjectId; + } + + private static string GetManagedVercelProjectName(VercelDeploymentEntry entry) + { + if (entry.Resource.TryGetLastAnnotation(out var options)) + { + return options.ProjectName; + } + + // The production endpoint contract is project-name based, so managed names must + // be stable and Vercel-valid before deploy starts. + string sourceRoot = Path.TrimEndingDirectorySeparator(entry.SourceRoot); + string sourceRootName = Path.GetFileName(sourceRoot); + + if (TryCreateVercelProjectName(sourceRootName, out string? projectName) + || TryCreateVercelProjectName(entry.Resource.Name, out projectName)) + { + return projectName; + } + + throw new DistributedApplicationException($"Could not infer a valid Vercel project name for resource '{entry.Resource.Name}' from source root '{entry.SourceRoot}'. Rename the source directory or link the source root to an existing Vercel project."); + } + + internal static bool IsValidVercelProjectName(string projectName) + { + if (string.IsNullOrWhiteSpace(projectName) + || projectName.Length > VercelProjectNameMaxLength + || !IsLowercaseAsciiLetterOrDigit(projectName[0]) + || !IsLowercaseAsciiLetterOrDigit(projectName[^1])) + { + return false; + } + + return projectName.All(static character => + IsLowercaseAsciiLetterOrDigit(character) + || character == '-'); + } + + private static bool TryCreateVercelProjectName(string? value, [NotNullWhen(true)] out string? projectName) + { + if (string.IsNullOrWhiteSpace(value)) + { + projectName = null; + return false; + } + + var builder = new StringBuilder(value.Length); + bool previousWasSeparator = false; + + foreach (char character in value) + { + if (IsAsciiLetterOrDigit(character)) + { + builder.Append(char.ToLowerInvariant(character)); + previousWasSeparator = false; + } + else if (!previousWasSeparator && builder.Length > 0) + { + builder.Append('-'); + previousWasSeparator = true; + } + } + + projectName = builder + .ToString() + .Trim('-'); + + if (projectName.Length > VercelProjectNameMaxLength) + { + projectName = projectName[..VercelProjectNameMaxLength].Trim('-'); + } + + if (projectName.Length == 0) + { + projectName = null; + return false; + } + + return true; + } + + private static bool IsAsciiLetterOrDigit(char character) + => character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9'; + + private static bool IsLowercaseAsciiLetterOrDigit(char character) + => character is >= 'a' and <= 'z' or >= '0' and <= '9'; + + private static bool HasVercelProjectLinkFile(string sourceRoot) + => File.Exists(GetVercelProjectJsonPath(sourceRoot)); + + private static bool TryReadVercelProjectLink(string sourceRoot, [NotNullWhen(true)] out VercelProjectLink? projectLink) + { + string projectJsonPath = GetVercelProjectJsonPath(sourceRoot); + + if (File.Exists(projectJsonPath)) + { + // Vercel CLI writes linked project identity as: + // .vercel/project.json: { "projectId": "...", "orgId": "...", "projectName": "..." } + // Treat it as user/provider ownership metadata rather than regenerating a managed + // name. Destroy preserves these linked projects and only removes tracked env vars. + using var document = JsonDocument.Parse(File.ReadAllText(projectJsonPath)); + string? projectName = GetJsonStringProperty(document.RootElement, "projectName"); + + if (!string.IsNullOrWhiteSpace(projectName)) + { + projectLink = new(projectName, GetJsonStringProperty(document.RootElement, "projectId")); + return true; + } + } + + projectLink = null; + return false; + } + + private static string? GetJsonStringProperty(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out var property) + && property.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(property.GetString()) + ? property.GetString() + : null; + + internal static bool ProjectListContainsProject(string standardOutput, string projectName) + { + try + { + using var document = JsonDocument.Parse(standardOutput); + foreach (var project in EnumerateJsonArrayOrNamedArray(document.RootElement, "projects")) + { + if (string.Equals(GetJsonStringProperty(project, "name"), projectName, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + catch (JsonException ex) + { + throw new DistributedApplicationException("Failed to parse JSON output from 'vercel project ls'.", ex); + } + } + + internal static bool EnvironmentVariableListContainsName(string standardOutput, string name) + { + try + { + using var document = JsonDocument.Parse(standardOutput); + foreach (var environmentVariable in EnumerateJsonArrayOrNamedArray(document.RootElement, "envs")) + { + if (string.Equals(GetJsonStringProperty(environmentVariable, "key"), name, StringComparison.Ordinal) + && string.IsNullOrWhiteSpace(GetJsonStringProperty(environmentVariable, "gitBranch"))) + { + return true; + } + } + + return false; + } + catch (JsonException ex) + { + throw new DistributedApplicationException("Failed to parse JSON output from 'vercel env ls'.", ex); + } + } + + private static JsonElement.ArrayEnumerator EnumerateJsonArrayOrNamedArray(JsonElement root, string propertyName) + { + if (root.ValueKind == JsonValueKind.Array) + { + return root.EnumerateArray(); + } + + if (root.ValueKind == JsonValueKind.Object + && root.TryGetProperty(propertyName, out var array) + && array.ValueKind == JsonValueKind.Array) + { + return array.EnumerateArray(); + } + + throw new JsonException($"Expected JSON array or object property '{propertyName}'."); + } + + private static string GetVercelProjectJsonPath(string sourceRoot) + => Path.Combine(sourceRoot, ".vercel", "project.json"); +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Plan.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Plan.cs new file mode 100644 index 000000000..987391329 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Plan.cs @@ -0,0 +1,191 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES002 +#pragma warning disable ASPIREPIPELINES003 +#pragma warning disable ASPIREPIPELINES004 +#pragma warning disable ASPIRECOMPUTE003 +#pragma warning disable ASPIREPROBES001 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.ApplicationModel.Docker; +using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Publishing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal static partial class VercelDeploymentStep +{ + public static async Task WriteDeploymentPlanAsync(PipelineStepContext context, VercelEnvironmentResource environment) + { + var outputService = context.Services.GetRequiredService(); + string outputDirectory = outputService.GetOutputDirectory(environment); + + string planPath = await WriteDeploymentPlanAsync( + context.ExecutionContext, + context.Logger, + context.Model, + environment, + outputDirectory, + context.CancellationToken).ConfigureAwait(false); + + context.Summary.Add("Vercel deployment plan", planPath); + } + + internal static async Task WriteDeploymentPlanAsync( + DistributedApplicationModel model, + VercelEnvironmentResource environment, + string outputDirectory, + CancellationToken cancellationToken) + => await WriteDeploymentPlanAsync( + executionContext: null, + logger: null, + model, + environment, + outputDirectory, + cancellationToken).ConfigureAwait(false); + + internal static async Task WriteDeploymentPlanAsync( + DistributedApplicationExecutionContext? executionContext, + ILogger? logger, + DistributedApplicationModel model, + VercelEnvironmentResource environment, + string outputDirectory, + CancellationToken cancellationToken) + { + var entries = GetDeploymentEntries(model, environment).ToList(); + ValidateEntries(entries); + + // Publish is a reviewable handoff, not a dry-run deploy. Keep it deterministic: + // show commands, Dockerfile paths, and env var names without resolving secrets or + // depending on mutable Vercel state. + Directory.CreateDirectory(outputDirectory); + var options = environment.GetVercelOptions(); + + var plan = new VercelDeploymentPlan( + environment.Name, + await CreateDeploymentPlanEntriesAsync( + executionContext, + logger, + options, + entries, + cancellationToken).ConfigureAwait(false)); + + string planPath = Path.Combine(outputDirectory, DeploymentPlanFileName); + await using FileStream stream = File.Create(planPath); + await JsonSerializer.SerializeAsync(stream, plan, JsonOptions, cancellationToken).ConfigureAwait(false); + + return planPath; + } + + internal static async Task BuildDeployArgumentsAsync( + DistributedApplicationExecutionContext executionContext, + ILogger logger, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry, + CancellationToken cancellationToken) + => await BuildDeployArgumentsAsync( + executionContext, + logger, + options, + entry, + [entry], + cancellationToken).ConfigureAwait(false); + + internal static async Task BuildDeployArgumentsAsync( + DistributedApplicationExecutionContext executionContext, + ILogger logger, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry, + IReadOnlyList entries, + CancellationToken cancellationToken) + { + // Publish plans must be useful without Vercel credentials or secret resolution. + // Secret-bearing values are reduced to names/placeholders here; deploy resolves them + // only when they are sent to Vercel's project env store over stdin. + var entriesByResourceName = GetDeploymentEntriesByResourceName(entries); + var environmentConfiguration = await GetVercelEnvironmentConfigurationAsync( + executionContext, + logger, + options, + entry, + entriesByResourceName, + resolveProjectEnvironmentVariableValues: false, + cancellationToken).ConfigureAwait(false); + + return BuildDeployArguments(options, GetDeployDirectory(entry), GetVercelProjectOption(entry), environmentConfiguration.DeploymentEnvironmentVariables); + } + + private static async Task CreateDeploymentPlanEntriesAsync( + DistributedApplicationExecutionContext? executionContext, + ILogger? logger, + VercelEnvironmentOptionsAnnotation options, + IReadOnlyList entries, + CancellationToken cancellationToken) + { + List planEntries = []; + var entriesByResourceName = GetDeploymentEntriesByResourceName(entries); + + foreach (var entry in entries) + { + // When execution context is available, include the same target-native env names + // deploy will use. Values stay redacted because publish output is committed or + // handed off more often than deploy logs. + var environmentConfiguration = executionContext is null || logger is null + ? VercelEnvironmentConfiguration.Empty + : await GetVercelEnvironmentConfigurationAsync(executionContext, logger, options, entry, entriesByResourceName, resolveProjectEnvironmentVariableValues: false, cancellationToken).ConfigureAwait(false); + + planEntries.Add(new( + entry.Resource.Name, + GetDisplayDockerfilePath(entry), + BuildDisplayDeployCommand(options, entry.Resource.Name, environmentConfiguration.DeploymentEnvironmentVariables), + [.. environmentConfiguration.AllEnvironmentVariableNames.Order(StringComparer.Ordinal)])); + } + + return [.. planEntries]; + } + + private static async Task GetVercelEnvironmentConfigurationAsync( + DistributedApplicationExecutionContext executionContext, + ILogger logger, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry, + IReadOnlyDictionary entriesByResourceName, + bool resolveProjectEnvironmentVariableValues, + CancellationToken cancellationToken) + { + var executionConfiguration = await ExecutionConfigurationBuilder + .Create(entry.Resource) + .WithEnvironmentVariablesConfig() + .WithArgumentsConfig() + .BuildAsync(executionContext, logger, cancellationToken) + .ConfigureAwait(false); + + if (executionConfiguration.Exception is not null) + { + throw new DistributedApplicationException($"Failed to process deployment configuration for resource '{entry.Resource.Name}'.", executionConfiguration.Exception); + } + + ValidateUnsupportedRuntimeConfiguration(entry.Resource, executionConfiguration); + + var environmentVariables = await GetVercelEnvironmentConfigurationAsync( + entry.Resource, + options, + executionConfiguration, + entriesByResourceName, + resolveProjectEnvironmentVariableValues, + cancellationToken).ConfigureAwait(false); + + return environmentVariables; + } + +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.State.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.State.cs new file mode 100644 index 000000000..a67e0df30 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.State.cs @@ -0,0 +1,441 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES002 +#pragma warning disable ASPIREPIPELINES003 +#pragma warning disable ASPIREPIPELINES004 +#pragma warning disable ASPIRECOMPUTE003 +#pragma warning disable ASPIREPROBES001 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.ApplicationModel.Docker; +using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Publishing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal static partial class VercelDeploymentStep +{ + public static async Task DestroyAsync(PipelineStepContext context, VercelEnvironmentResource environment) + { + var options = environment.GetVercelOptions(); + var stateManager = context.Services.GetRequiredService(); + var stateSection = await stateManager.AcquireSectionAsync(GetStateSectionName(environment), context.CancellationToken).ConfigureAwait(false); + var state = ReadDeploymentState(stateSection); + if (state is null) + { + // Keep no-op destroy cheap and offline. If Aspire has no deployment state, + // there is no recorded provider object that this integration owns. + context.Summary.Add("Vercel destroy", $"No Vercel deployment state was found for environment '{environment.Name}'. Nothing to destroy."); + return; + } + + ValidateDeploymentState(environment, options, state); + + // Destroy is state-first rather than model-first. The current AppHost may no + // longer contain the resources that created these Vercel projects, but persisted + // state records which provider objects Aspire is allowed to delete. + var projects = state.Deployments + .Where(static deployment => deployment.ManagedByAspire) + .Select(static deployment => deployment.ProjectName) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + var linkedDeploymentsWithEnvironmentVariables = state.Deployments + .Where(static deployment => !deployment.ManagedByAspire && deployment.ProjectEnvironmentVariables.Length > 0) + .OrderBy(static deployment => deployment.ProjectName, StringComparer.Ordinal) + .ThenBy(static deployment => deployment.ResourceName, StringComparer.Ordinal) + .ToArray(); + + if (projects.Length == 0 && linkedDeploymentsWithEnvironmentVariables.Length == 0) + { + // Linked .vercel projects are valid deploy targets but remain externally owned, + // so clearing state is safer than deleting provider projects the user brought. + context.Summary.Add("Vercel destroy", $"No Aspire-managed Vercel deployments were found for environment '{environment.Name}'."); + await stateManager.DeleteSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); + return; + } + + // Validate auth only after we know there is provider state to mutate. This keeps + // `aspire destroy` usable in clean workspaces or after state has already been removed. + await ValidateCliPrerequisitesAsync(context, environment).ConfigureAwait(false); + var runner = context.Services.GetRequiredService(); + + foreach (var deployment in linkedDeploymentsWithEnvironmentVariables) + { + await RemoveLinkedProjectEnvironmentVariablesAsync( + context, + runner, + options, + environment, + deployment, + GetVercelProjectEnvironmentName(state)).ConfigureAwait(false); + } + + if (projects.Length == 0) + { + context.Summary.Add("Vercel destroy", $"No Aspire-managed Vercel deployments were found for environment '{environment.Name}'."); + await stateManager.DeleteSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); + return; + } + + foreach (string projectName in projects) + { + if (!await ProjectExistsAsync(context, runner, options, projectName).ConfigureAwait(false)) + { + context.Summary.Add("Vercel project already absent", projectName); + } + else + { + string[] arguments = BuildDestroyProjectArguments(options, projectName); + var result = await runner.RunAsync(VercelCliFileName, arguments, workingDirectory: null, context.CancellationToken, standardInput: "y\n").ConfigureAwait(false); + + if (!result.Succeeded) + { + if (await ProjectExistsAsync(context, runner, options, projectName).ConfigureAwait(false)) + { + throw CreateCliException($"destroy Vercel project '{projectName}'", VercelCliFileName, result); + } + + context.Summary.Add("Vercel project already absent", projectName); + } + else + { + context.Summary.Add("Vercel project removed", projectName); + } + } + + state = RemoveManagedProjectFromDeploymentState(state, projectName); + // Save after each project removal so a later CLI failure leaves retryable state + // for projects that still exist instead of forgetting partially cleaned resources. + stateSection.SetValue(JsonSerializer.Serialize(state, JsonOptions)); + await stateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); + } + + await stateManager.DeleteSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); + } + + private static async Task ValidateExistingDeploymentStateAsync( + PipelineStepContext context, + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options) + { + var stateManager = context.Services.GetRequiredService(); + var stateSection = await stateManager.AcquireSectionAsync(GetStateSectionName(environment), context.CancellationToken).ConfigureAwait(false); + var existingState = ReadDeploymentState(stateSection); + if (existingState is not null) + { + ValidateDeploymentState(environment, options, existingState); + } + } + + private static async Task GetPreviousDeploymentStateEntryAsync( + PipelineStepContext context, + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + string resourceName, + string projectName) + { + var stateManager = context.Services.GetRequiredService(); + var stateSection = await stateManager.AcquireSectionAsync(GetStateSectionName(environment), context.CancellationToken).ConfigureAwait(false); + var existingState = ReadDeploymentState(stateSection); + if (existingState is null) + { + return null; + } + + ValidateDeploymentState(environment, options, existingState); + var entry = existingState.Deployments.FirstOrDefault(deployment => + string.Equals(deployment.ResourceName, resourceName, StringComparison.Ordinal) + && string.Equals(deployment.ProjectName, projectName, StringComparison.Ordinal)); + + return entry is null + ? null + : new(entry, GetVercelProjectEnvironmentName(existingState)); + } + + private static async Task SaveDeploymentStateEntryAsync( + PipelineStepContext context, + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentStateEntry deployment) + { + var stateManager = context.Services.GetRequiredService(); + var stateSection = await stateManager.AcquireSectionAsync(GetStateSectionName(environment), context.CancellationToken).ConfigureAwait(false); + var existingState = ReadDeploymentState(stateSection); + var state = existingState is null + ? CreateDeploymentState(environment, options, [deployment]) + : MergeDeploymentState(environment, options, existingState, deployment); + + stateSection.SetValue(JsonSerializer.Serialize(state, JsonOptions)); + + await stateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); + } + + private static VercelDeploymentState CreateDeploymentState( + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentStateEntry[] deployments) + => new( + DeploymentStateSchemaVersion, + environment.Name, + NormalizeScope(options.Scope), + NormalizeTarget(options.Target), + options.Production, + deployments); + + private static VercelDeploymentState MergeDeploymentState( + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentState existingState, + VercelDeploymentStateEntry deployment) + { + ValidateDeploymentState(environment, options, existingState); + + return CreateDeploymentState( + environment, + options, + [ + .. existingState.Deployments.Where(existing => + !string.Equals(existing.ResourceName, deployment.ResourceName, StringComparison.Ordinal) + || !string.Equals(existing.ProjectName, deployment.ProjectName, StringComparison.Ordinal)), + deployment + ]); + } + + private static VercelDeploymentState? ReadDeploymentState(DeploymentStateSection stateSection) + { + // DeploymentStateSection storage shape has changed across Aspire builds. Accept the + // known wrappers so destroy can still clean up projects created by an older CLI. + if (stateSection.Data.TryGetPropertyValue("value", out JsonNode? value) + && value is not null) + { + return DeserializeDeploymentState(value); + } + + value = stateSection.Data.FirstOrDefault().Value; + if (value is not null) + { + return DeserializeDeploymentState(value); + } + + if (stateSection.Data.ContainsKey("schemaVersion")) + { + return stateSection.Data.Deserialize(JsonOptions); + } + + return null; + } + + private static VercelDeploymentState? DeserializeDeploymentState(JsonNode value) + { + return value.GetValueKind() == JsonValueKind.String + ? JsonSerializer.Deserialize(value.GetValue(), JsonOptions) + : value.Deserialize(JsonOptions); + } + + private static void ValidateDeploymentState( + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentState state) + { + if (state.SchemaVersion != DeploymentStateSchemaVersion) + { + throw new DistributedApplicationException($"Vercel deployment state for environment '{environment.Name}' uses unsupported schema version '{state.SchemaVersion}'. Redeploy the environment before destroying it."); + } + + if (!string.Equals(state.Environment, environment.Name, StringComparison.Ordinal)) + { + throw new DistributedApplicationException($"Vercel deployment state for environment '{state.Environment}' cannot be used to destroy environment '{environment.Name}'."); + } + + string? configuredScope = NormalizeScope(options.Scope); + if (!string.Equals(state.Scope, configuredScope, StringComparison.Ordinal)) + { + string stateScope = string.IsNullOrWhiteSpace(state.Scope) ? "" : state.Scope; + string requestedScope = string.IsNullOrWhiteSpace(configuredScope) ? "" : configuredScope; + throw new DistributedApplicationException($"Vercel deployment state for environment '{environment.Name}' was created for scope '{stateScope}', but destroy is configured for scope '{requestedScope}'. Use the same Vercel scope that created the deployment state."); + } + } + + private static string? NormalizeScope(string? scope) + => string.IsNullOrWhiteSpace(scope) ? null : scope; + + private static string? NormalizeTarget(string? target) + => string.IsNullOrWhiteSpace(target) ? null : target; + + private static string? GetProductionUrl(VercelEnvironmentOptionsAnnotation options, string projectName) + => options.Production ? $"https://{projectName}.vercel.app" : null; + + private static string GetDeployDirectory(VercelDeploymentEntry entry) + => string.IsNullOrWhiteSpace(entry.DeployDirectory) ? entry.SourceRoot : entry.DeployDirectory; + + private static VercelDeploymentState RemoveManagedProjectFromDeploymentState(VercelDeploymentState state, string projectName) + => state with + { + Deployments = state.Deployments + .Where(deployment => !deployment.ManagedByAspire || !string.Equals(deployment.ProjectName, projectName, StringComparison.Ordinal)) + .ToArray() + }; + + private static string GetStateSectionName(VercelEnvironmentResource environment) => $"{StateSectionNamePrefix}{environment.Name}"; + + private static async Task ProjectExistsAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + string projectName) + { + // Avoid treating localized or reformatted CLI errors as provider state. The Vercel + // CLI exposes project lists as JSON, so destroy checks exact project names before + // deleting and again after a failed delete to distinguish races from real failures. + string[] arguments = BuildListProjectsArguments(options, projectName); + var result = await runner.RunAsync(VercelCliFileName, arguments, workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException($"list Vercel projects while checking for '{projectName}'", VercelCliFileName, result); + } + + return ProjectListContainsProject(result.StandardOutput, projectName); + } + + private static async Task ProjectEnvironmentVariableExistsAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string name, + string targetEnvironment) + { + // `vercel env rm` reports absence as human text, but `vercel env ls --format=json` + // returns the linked project's exact keys. Use that provider read for idempotent + // stale-secret cleanup instead of parsing failure prose. + string[] arguments = BuildListProjectEnvironmentVariablesArguments(options, projectLinkDirectory, targetEnvironment); + var result = await runner.RunAsync(VercelCliFileName, arguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException($"list Vercel project environment variables before removing '{name}'", VercelCliFileName, result); + } + + return EnvironmentVariableListContainsName(result.StandardOutput, name); + } + + private static async Task ConfigureProjectEnvironmentVariablesAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry, + string projectLinkDirectory, + IReadOnlyList> environmentVariables, + PreviousVercelDeployment? previousDeployment) + { + var currentNames = environmentVariables + .Select(static variable => variable.Key) + .ToHashSet(StringComparer.Ordinal); + + if (previousDeployment is not null) + { + string[] staleNames = previousDeployment.Entry.ProjectEnvironmentVariables + .Where(name => !currentNames.Contains(name)) + .Order(StringComparer.Ordinal) + .ToArray(); + + await RemoveProjectEnvironmentVariablesAsync( + context, + runner, + options, + projectLinkDirectory, + entry.Resource.Name, + staleNames, + previousDeployment.ProjectEnvironment).ConfigureAwait(false); + } + + if (environmentVariables.Count == 0) + { + return; + } + + string targetEnvironment = GetVercelProjectEnvironmentName(options); + foreach (var environmentVariable in environmentVariables.OrderBy(static variable => variable.Key, StringComparer.Ordinal)) + { + string[] arguments = BuildAddProjectEnvironmentVariableArguments(options, projectLinkDirectory, environmentVariable.Key, targetEnvironment); + var result = await runner.RunAsync(VercelCliFileName, arguments, projectLinkDirectory, context.CancellationToken, standardInput: environmentVariable.Value).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException($"configure Vercel project environment variable '{environmentVariable.Key}' for resource '{entry.Resource.Name}'", VercelCliFileName, result); + } + } + } + + private static async Task RemoveProjectEnvironmentVariablesAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string resourceName, + IReadOnlyList names, + string targetEnvironment) + { + foreach (string name in names) + { + if (!await ProjectEnvironmentVariableExistsAsync(context, runner, options, projectLinkDirectory, name, targetEnvironment).ConfigureAwait(false)) + { + continue; + } + + string[] arguments = BuildRemoveProjectEnvironmentVariableArguments(options, projectLinkDirectory, name, targetEnvironment); + var result = await runner.RunAsync(VercelCliFileName, arguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded + && await ProjectEnvironmentVariableExistsAsync(context, runner, options, projectLinkDirectory, name, targetEnvironment).ConfigureAwait(false)) + { + throw CreateCliException($"remove stale Vercel project environment variable '{name}' for resource '{resourceName}'", VercelCliFileName, result); + } + } + } + + private static async Task RemoveLinkedProjectEnvironmentVariablesAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + VercelEnvironmentResource environment, + VercelDeploymentStateEntry deployment, + string targetEnvironment) + { + var outputService = context.Services.GetRequiredService(); + string projectLinkDirectory = Path.Combine(outputService.GetTempDirectory(environment), ".vercel-projects", deployment.ProjectName); + DeleteDirectoryIfExists(projectLinkDirectory); + Directory.CreateDirectory(projectLinkDirectory); + + try + { + string[] linkArguments = BuildLinkProjectArguments(options, projectLinkDirectory, deployment.ProjectId ?? deployment.ProjectName); + var linkResult = await runner.RunAsync(VercelCliFileName, linkArguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); + if (!linkResult.Succeeded) + { + throw CreateCliException($"link Vercel project '{deployment.ProjectName}' for environment variable cleanup", VercelCliFileName, linkResult); + } + + await RemoveProjectEnvironmentVariablesAsync( + context, + runner, + options, + projectLinkDirectory, + deployment.ResourceName, + deployment.ProjectEnvironmentVariables, + targetEnvironment).ConfigureAwait(false); + } + finally + { + DeleteDirectoryIfExists(projectLinkDirectory); + } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Types.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Types.cs new file mode 100644 index 000000000..3eb4fa18a --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Types.cs @@ -0,0 +1,96 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIRECOMPUTE003 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.ApplicationModel.Docker; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal sealed record VercelDeploymentEntry( + IResource Resource, + string SourceRoot, + string? DockerfilePath = null, + DockerfileBuildAnnotation? Dockerfile = null, + string TempDirectory = "", + string DeployDirectory = ""); + +internal sealed record VercelDeploymentPlan(string Environment, VercelDeploymentPlanEntry[] Deployments); + +internal sealed record VercelDeploymentPlanEntry(string ResourceName, string DockerfilePath, string DeployCommand, string[] EnvironmentVariables); + +internal sealed record VercelEnvironmentConfiguration( + IReadOnlyList> DeploymentEnvironmentVariables, + IReadOnlyList> ProjectEnvironmentVariables) +{ + public static VercelEnvironmentConfiguration Empty { get; } = new([], []); + + public IEnumerable AllEnvironmentVariableNames => + DeploymentEnvironmentVariables.Select(static variable => variable.Key) + .Concat(ProjectEnvironmentVariables.Select(static variable => variable.Key)); +} + +internal sealed record VercelDeploymentResult(string? DeploymentId, string DeploymentUrl); + +internal sealed record VercelDeploymentInspection(string? ReadyState); + +internal sealed record PreviousVercelDeployment(VercelDeploymentStateEntry Entry, string ProjectEnvironment); + +internal sealed record VercelDeploymentState( + int SchemaVersion, + string Environment, + string? Scope, + string? Target, + bool Production, + VercelDeploymentStateEntry[] Deployments); + +internal sealed record VercelDeploymentStateEntry( + string ResourceName, + string ProjectName, + string? ProjectId, + string? DeploymentId, + string? DeploymentUrl, + string SourceRoot, + bool ManagedByAspire) +{ + public string? ProductionUrl { get; init; } + + public string? VcrImageDigest { get; init; } + + public int? BuildOutputApiVersion { get; init; } + + public string[] ProjectEnvironmentVariables { get; init; } = []; +} + +internal sealed record VercelImageReference(string Reference, string Digest); + +internal sealed record VercelPreparedDeploymentAnnotation( + VercelDeploymentEntry Entry, + VercelProjectLink ProjectLink, + VercelPulledProjectContext ProjectContext, + bool ManagedByAspire, + string RemoteImageName, + string RemoteImageTag, + string TaggedImageReference) : IResourceAnnotation; + +internal sealed class VercelImagePushOptionsCallbackAnnotation : IResourceAnnotation +{ +} + +internal sealed record VercelProjectLink(string ProjectName, string? ProjectId); + +internal sealed record VercelPulledProject( + string ProjectName, + string? ProjectId, + string? OrgId, + string ProjectJsonContent, + string OidcToken); + +internal sealed record VercelPulledProjectContext( + VercelEnvironmentConfiguration EnvironmentConfiguration, + VercelPulledProject PulledProject, + VercelOidcClaims OidcClaims); + +internal sealed record VercelPulledProjectSettings(string ProjectName, string? ProjectId, string? OrgId); + +internal sealed record VercelOidcClaims(string? OwnerId, string? Owner, string? Project, string? ProjectId); diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs index cf800b2d3..96efa701f 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs @@ -23,7 +23,7 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; -internal static class VercelDeploymentStep +internal static partial class VercelDeploymentStep { // E2E contract: // publish => deterministic, reviewable vercel-deployments.json with no provider mutation @@ -91,67 +91,6 @@ internal static class VercelDeploymentStep WriteIndented = true }; - public static async Task WriteDeploymentPlanAsync(PipelineStepContext context, VercelEnvironmentResource environment) - { - var outputService = context.Services.GetRequiredService(); - string outputDirectory = outputService.GetOutputDirectory(environment); - - string planPath = await WriteDeploymentPlanAsync( - context.ExecutionContext, - context.Logger, - context.Model, - environment, - outputDirectory, - context.CancellationToken).ConfigureAwait(false); - - context.Summary.Add("Vercel deployment plan", planPath); - } - - internal static async Task WriteDeploymentPlanAsync( - DistributedApplicationModel model, - VercelEnvironmentResource environment, - string outputDirectory, - CancellationToken cancellationToken) - => await WriteDeploymentPlanAsync( - executionContext: null, - logger: null, - model, - environment, - outputDirectory, - cancellationToken).ConfigureAwait(false); - - internal static async Task WriteDeploymentPlanAsync( - DistributedApplicationExecutionContext? executionContext, - ILogger? logger, - DistributedApplicationModel model, - VercelEnvironmentResource environment, - string outputDirectory, - CancellationToken cancellationToken) - { - var entries = GetDeploymentEntries(model, environment).ToList(); - ValidateEntries(entries); - - // Publish is a reviewable handoff, not a dry-run deploy. Keep it deterministic: - // show commands, Dockerfile paths, and env var names without resolving secrets or - // depending on mutable Vercel state. - Directory.CreateDirectory(outputDirectory); - var options = environment.GetVercelOptions(); - - var plan = new VercelDeploymentPlan( - environment.Name, - await CreateDeploymentPlanEntriesAsync( - executionContext, - logger, - options, - entries, - cancellationToken).ConfigureAwait(false)); - - string planPath = Path.Combine(outputDirectory, DeploymentPlanFileName); - await using FileStream stream = File.Create(planPath); - await JsonSerializer.SerializeAsync(stream, plan, JsonOptions, cancellationToken).ConfigureAwait(false); - - return planPath; - } public static async Task ValidatePrerequisitesAsync(PipelineStepContext context, VercelEnvironmentResource environment) { @@ -222,48 +161,6 @@ await DeployEntryAsync( } } - public static async Task ValidateCliPrerequisitesAsync(PipelineStepContext context, VercelEnvironmentResource environment) - { - var options = environment.GetVercelOptions(); - var runner = context.Services.GetRequiredService(); - - var versionResult = await runner.RunAsync(VercelCliFileName, ["--version"], workingDirectory: null, context.CancellationToken).ConfigureAwait(false); - if (!versionResult.Succeeded) - { - throw CreateCliException("validate Vercel CLI installation", VercelCliFileName, versionResult); - } - - var versionOutput = $"{versionResult.StandardOutput}{Environment.NewLine}{versionResult.StandardError}"; - if (!TryGetVercelCliVersion(versionOutput, out var version)) - { - throw new DistributedApplicationException( - $"Failed to determine Vercel CLI version from '{GetTrimmedOutput(versionOutput)}'. Install Vercel CLI {MinimumVercelCliVersion} or later from https://vercel.com/docs/cli."); - } - - // The preview relies on newer CLI behavior: project-scoped link/pull, - // prebuilt deploys, deployment-scoped --env, JSON inspect output with - // --wait/--timeout, and project removal. - if (version < MinimumVercelCliVersion) - { - throw new DistributedApplicationException( - $"Vercel CLI version '{version}' is not supported. Install Vercel CLI {MinimumVercelCliVersion} or later from https://vercel.com/docs/cli."); - } - - var whoamiResult = await runner.RunAsync(VercelCliFileName, ["whoami"], workingDirectory: null, context.CancellationToken).ConfigureAwait(false); - if (!whoamiResult.Succeeded) - { - throw CreateCliException("validate Vercel authentication", VercelCliFileName, whoamiResult); - } - - if (!string.IsNullOrWhiteSpace(options.Scope)) - { - var scopeResult = await runner.RunAsync(VercelCliFileName, BuildValidateScopeArguments(options), workingDirectory: null, context.CancellationToken).ConfigureAwait(false); - if (!scopeResult.Succeeded) - { - throw CreateCliException($"validate Vercel scope '{options.Scope}'", VercelCliFileName, scopeResult); - } - } - } private static async Task DeployEntryAsync( PipelineStepContext context, @@ -723,2318 +620,4 @@ private static void RemoveAnnotations(IResource resource, Func BuildDeployArguments(options, GetDeployDirectory(entry), GetVercelProjectOption(entry), environmentVariables: []); - - // Keep CLI argument construction as pure array-returning helpers. Tests assert exact - // argument boundaries so Vercel quirks such as `env add` requiring --cwd, not --project, - // cannot regress into shell-quoted or source-mutating command strings. - internal static string[] BuildDockerInspectDigestArguments(string imageReference) - => ["buildx", "imagetools", "inspect", "--format", "{{json .Manifest}}", imageReference]; - - internal static string[] BuildDestroyProjectArguments(VercelEnvironmentOptionsAnnotation options, string projectName) - { - List arguments = []; - - arguments.Add("project"); - arguments.Add("remove"); - arguments.Add(projectName); - AddOptionalScopeArgument(arguments, options); - - return [.. arguments]; - } - - internal static string[] BuildListProjectEnvironmentVariablesArguments( - VercelEnvironmentOptionsAnnotation options, - string projectLinkDirectory, - string targetEnvironment) - { - List arguments = []; - - arguments.Add("env"); - arguments.Add("ls"); - arguments.Add(targetEnvironment); - AddOptionalScopeArgument(arguments, options); - arguments.Add("--cwd"); - arguments.Add(projectLinkDirectory); - arguments.Add("--format=json"); - - return [.. arguments]; - } - - internal static string[] BuildAddProjectArguments(VercelEnvironmentOptionsAnnotation options, string projectName) - { - List arguments = []; - - arguments.Add("project"); - arguments.Add("add"); - arguments.Add(projectName); - AddOptionalScopeArgument(arguments, options); - - return [.. arguments]; - } - - internal static string[] BuildAddProjectEnvironmentVariableArguments( - VercelEnvironmentOptionsAnnotation options, - string projectLinkDirectory, - string name, - string targetEnvironment) - { - List arguments = []; - - arguments.Add("env"); - arguments.Add("add"); - arguments.Add(name); - arguments.Add(targetEnvironment); - AddOptionalScopeArgument(arguments, options); - // Vercel env commands must run inside a linked project directory. Use the - // Aspire-owned scratch link instead of the source root so provider metadata - // and pulled env files never mutate the user's checkout. - arguments.Add("--cwd"); - arguments.Add(projectLinkDirectory); - arguments.Add("--yes"); - arguments.Add("--force"); - arguments.Add("--sensitive"); - - return [.. arguments]; - } - - internal static string[] BuildRemoveProjectEnvironmentVariableArguments( - VercelEnvironmentOptionsAnnotation options, - string projectLinkDirectory, - string name, - string targetEnvironment) - { - List arguments = []; - - arguments.Add("env"); - arguments.Add("rm"); - arguments.Add(name); - arguments.Add(targetEnvironment); - AddOptionalScopeArgument(arguments, options); - arguments.Add("--cwd"); - arguments.Add(projectLinkDirectory); - arguments.Add("--yes"); - - return [.. arguments]; - } - - internal static string[] BuildLinkProjectArguments( - VercelEnvironmentOptionsAnnotation options, - string projectLinkDirectory, - string projectNameOrId) - { - List arguments = []; - - arguments.Add("link"); - AddOptionalScopeArgument(arguments, options); - arguments.Add("--cwd"); - arguments.Add(projectLinkDirectory); - arguments.Add("--yes"); - arguments.Add("--project"); - arguments.Add(projectNameOrId); - - return [.. arguments]; - } - - internal static string[] BuildPullProjectSettingsArguments( - VercelEnvironmentOptionsAnnotation options, - string projectLinkDirectory, - string targetEnvironment) - { - List arguments = []; - - arguments.Add("pull"); - AddOptionalScopeArgument(arguments, options); - arguments.Add("--cwd"); - arguments.Add(projectLinkDirectory); - arguments.Add("--yes"); - arguments.Add("--environment"); - arguments.Add(targetEnvironment); - - return [.. arguments]; - } - - internal static string[] BuildDockerLoginArguments(string username) - => ["login", VcrRegistry, "--username", username, "--password-stdin"]; - - private static void AddOptionalProjectArgument(List arguments, string? projectNameOrId) - { - if (!string.IsNullOrWhiteSpace(projectNameOrId)) - { - arguments.Add("--project"); - arguments.Add(projectNameOrId); - } - } - - private static void AddOptionalScopeArgument(List arguments, VercelEnvironmentOptionsAnnotation options) - { - if (!string.IsNullOrWhiteSpace(options.Scope)) - { - arguments.Add("--scope"); - arguments.Add(options.Scope); - } - } - - internal static string[] BuildInspectDeploymentArguments(VercelEnvironmentOptionsAnnotation options, string deploymentUrl) - { - List arguments = []; - - arguments.Add("inspect"); - arguments.Add(deploymentUrl); - AddOptionalScopeArgument(arguments, options); - arguments.Add("--wait"); - arguments.Add("--timeout"); - arguments.Add("120s"); - arguments.Add("--format=json"); - - return [.. arguments]; - } - - internal static string[] BuildValidateScopeArguments(VercelEnvironmentOptionsAnnotation options) - => BuildListProjectsArguments(options); - - internal static string[] BuildListProjectsArguments(VercelEnvironmentOptionsAnnotation options, string? filter = null) - { - List arguments = []; - - arguments.Add("project"); - arguments.Add("ls"); - AddOptionalScopeArgument(arguments, options); - if (!string.IsNullOrWhiteSpace(filter)) - { - arguments.Add("--filter"); - arguments.Add(filter); - } - - arguments.Add("--format=json"); - - return [.. arguments]; - } - - private static async Task VerifyDeploymentAsync( - PipelineStepContext context, - IVercelCliRunner runner, - VercelEnvironmentOptionsAnnotation options, - string resourceName, - VercelDeploymentResult deploymentResult) - { - // A successful `vercel deploy` only means the CLI accepted the submission. - // Query the provider before recording state so Aspire does not persist failed - // or still-building deployments as successfully applied resources. - string[] arguments = BuildInspectDeploymentArguments(options, deploymentResult.DeploymentUrl); - var result = await runner.RunAsync(VercelCliFileName, arguments, workingDirectory: null, context.CancellationToken).ConfigureAwait(false); - - if (!result.Succeeded) - { - throw CreateCliException($"verify Vercel deployment for resource '{resourceName}'", VercelCliFileName, result); - } - - var inspection = GetDeploymentInspection(result.StandardOutput); - if (inspection.ReadyState is null) - { - throw new DistributedApplicationException($"Vercel inspect output for resource '{resourceName}' did not include a deployment ready state. Output: {GetTrimmedOutput(result.StandardOutput)}"); - } - - if (!string.Equals(inspection.ReadyState, "READY", StringComparison.OrdinalIgnoreCase)) - { - throw new DistributedApplicationException($"Vercel deployment for resource '{resourceName}' finished with state '{inspection.ReadyState}' instead of 'READY'."); - } - } - - public static async Task DestroyAsync(PipelineStepContext context, VercelEnvironmentResource environment) - { - var options = environment.GetVercelOptions(); - var stateManager = context.Services.GetRequiredService(); - var stateSection = await stateManager.AcquireSectionAsync(GetStateSectionName(environment), context.CancellationToken).ConfigureAwait(false); - var state = ReadDeploymentState(stateSection); - if (state is null) - { - // Keep no-op destroy cheap and offline. If Aspire has no deployment state, - // there is no recorded provider object that this integration owns. - context.Summary.Add("Vercel destroy", $"No Vercel deployment state was found for environment '{environment.Name}'. Nothing to destroy."); - return; - } - - ValidateDeploymentState(environment, options, state); - - // Destroy is state-first rather than model-first. The current AppHost may no - // longer contain the resources that created these Vercel projects, but persisted - // state records which provider objects Aspire is allowed to delete. - var projects = state.Deployments - .Where(static deployment => deployment.ManagedByAspire) - .Select(static deployment => deployment.ProjectName) - .Distinct(StringComparer.Ordinal) - .Order(StringComparer.Ordinal) - .ToArray(); - var linkedDeploymentsWithEnvironmentVariables = state.Deployments - .Where(static deployment => !deployment.ManagedByAspire && deployment.ProjectEnvironmentVariables.Length > 0) - .OrderBy(static deployment => deployment.ProjectName, StringComparer.Ordinal) - .ThenBy(static deployment => deployment.ResourceName, StringComparer.Ordinal) - .ToArray(); - - if (projects.Length == 0 && linkedDeploymentsWithEnvironmentVariables.Length == 0) - { - // Linked .vercel projects are valid deploy targets but remain externally owned, - // so clearing state is safer than deleting provider projects the user brought. - context.Summary.Add("Vercel destroy", $"No Aspire-managed Vercel deployments were found for environment '{environment.Name}'."); - await stateManager.DeleteSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); - return; - } - - // Validate auth only after we know there is provider state to mutate. This keeps - // `aspire destroy` usable in clean workspaces or after state has already been removed. - await ValidateCliPrerequisitesAsync(context, environment).ConfigureAwait(false); - var runner = context.Services.GetRequiredService(); - - foreach (var deployment in linkedDeploymentsWithEnvironmentVariables) - { - await RemoveLinkedProjectEnvironmentVariablesAsync( - context, - runner, - options, - environment, - deployment, - GetVercelProjectEnvironmentName(state)).ConfigureAwait(false); - } - - if (projects.Length == 0) - { - context.Summary.Add("Vercel destroy", $"No Aspire-managed Vercel deployments were found for environment '{environment.Name}'."); - await stateManager.DeleteSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); - return; - } - - foreach (string projectName in projects) - { - if (!await ProjectExistsAsync(context, runner, options, projectName).ConfigureAwait(false)) - { - context.Summary.Add("Vercel project already absent", projectName); - } - else - { - string[] arguments = BuildDestroyProjectArguments(options, projectName); - var result = await runner.RunAsync(VercelCliFileName, arguments, workingDirectory: null, context.CancellationToken, standardInput: "y\n").ConfigureAwait(false); - - if (!result.Succeeded) - { - if (await ProjectExistsAsync(context, runner, options, projectName).ConfigureAwait(false)) - { - throw CreateCliException($"destroy Vercel project '{projectName}'", VercelCliFileName, result); - } - - context.Summary.Add("Vercel project already absent", projectName); - } - else - { - context.Summary.Add("Vercel project removed", projectName); - } - } - - state = RemoveManagedProjectFromDeploymentState(state, projectName); - // Save after each project removal so a later CLI failure leaves retryable state - // for projects that still exist instead of forgetting partially cleaned resources. - stateSection.SetValue(JsonSerializer.Serialize(state, JsonOptions)); - await stateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); - } - - await stateManager.DeleteSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); - } - - internal static async Task BuildDeployArgumentsAsync( - DistributedApplicationExecutionContext executionContext, - ILogger logger, - VercelEnvironmentOptionsAnnotation options, - VercelDeploymentEntry entry, - CancellationToken cancellationToken) - => await BuildDeployArgumentsAsync( - executionContext, - logger, - options, - entry, - [entry], - cancellationToken).ConfigureAwait(false); - - internal static async Task BuildDeployArgumentsAsync( - DistributedApplicationExecutionContext executionContext, - ILogger logger, - VercelEnvironmentOptionsAnnotation options, - VercelDeploymentEntry entry, - IReadOnlyList entries, - CancellationToken cancellationToken) - { - // Publish plans must be useful without Vercel credentials or secret resolution. - // Secret-bearing values are reduced to names/placeholders here; deploy resolves them - // only when they are sent to Vercel's project env store over stdin. - var entriesByResourceName = GetDeploymentEntriesByResourceName(entries); - var environmentConfiguration = await GetVercelEnvironmentConfigurationAsync( - executionContext, - logger, - options, - entry, - entriesByResourceName, - resolveProjectEnvironmentVariableValues: false, - cancellationToken).ConfigureAwait(false); - - return BuildDeployArguments(options, GetDeployDirectory(entry), GetVercelProjectOption(entry), environmentConfiguration.DeploymentEnvironmentVariables); - } - - private static async Task CreateDeploymentPlanEntriesAsync( - DistributedApplicationExecutionContext? executionContext, - ILogger? logger, - VercelEnvironmentOptionsAnnotation options, - IReadOnlyList entries, - CancellationToken cancellationToken) - { - List planEntries = []; - var entriesByResourceName = GetDeploymentEntriesByResourceName(entries); - - foreach (var entry in entries) - { - // When execution context is available, include the same target-native env names - // deploy will use. Values stay redacted because publish output is committed or - // handed off more often than deploy logs. - var environmentConfiguration = executionContext is null || logger is null - ? VercelEnvironmentConfiguration.Empty - : await GetVercelEnvironmentConfigurationAsync(executionContext, logger, options, entry, entriesByResourceName, resolveProjectEnvironmentVariableValues: false, cancellationToken).ConfigureAwait(false); - - planEntries.Add(new( - entry.Resource.Name, - GetDisplayDockerfilePath(entry), - BuildDisplayDeployCommand(options, entry.Resource.Name, environmentConfiguration.DeploymentEnvironmentVariables), - [.. environmentConfiguration.AllEnvironmentVariableNames.Order(StringComparer.Ordinal)])); - } - - return [.. planEntries]; - } - - private static async Task GetVercelEnvironmentConfigurationAsync( - DistributedApplicationExecutionContext executionContext, - ILogger logger, - VercelEnvironmentOptionsAnnotation options, - VercelDeploymentEntry entry, - IReadOnlyDictionary entriesByResourceName, - bool resolveProjectEnvironmentVariableValues, - CancellationToken cancellationToken) - { - var executionConfiguration = await ExecutionConfigurationBuilder - .Create(entry.Resource) - .WithEnvironmentVariablesConfig() - .WithArgumentsConfig() - .BuildAsync(executionContext, logger, cancellationToken) - .ConfigureAwait(false); - - if (executionConfiguration.Exception is not null) - { - throw new DistributedApplicationException($"Failed to process deployment configuration for resource '{entry.Resource.Name}'.", executionConfiguration.Exception); - } - - ValidateUnsupportedRuntimeConfiguration(entry.Resource, executionConfiguration); - - var environmentVariables = await GetVercelEnvironmentConfigurationAsync( - entry.Resource, - options, - executionConfiguration, - entriesByResourceName, - resolveProjectEnvironmentVariableValues, - cancellationToken).ConfigureAwait(false); - - return environmentVariables; - } - - private static string[] BuildDeployArguments( - VercelEnvironmentOptionsAnnotation options, - string deployDirectory, - string? projectNameOrId, - IReadOnlyList> environmentVariables) - { - List arguments = []; - - arguments.Add("deploy"); - AddOptionalScopeArgument(arguments, options); - arguments.Add("--cwd"); - arguments.Add(deployDirectory); - AddOptionalProjectArgument(arguments, projectNameOrId); - arguments.Add("--prebuilt"); - arguments.Add("--yes"); - - if (options.Production) - { - arguments.Add("--prod"); - } - - if (!string.IsNullOrWhiteSpace(options.Target)) - { - arguments.Add("--target"); - arguments.Add(options.Target); - } - - foreach (var environmentVariable in environmentVariables.OrderBy(static variable => variable.Key, StringComparer.Ordinal)) - { - arguments.Add("--env"); - arguments.Add($"{environmentVariable.Key}={environmentVariable.Value}"); - } - - return [.. arguments]; - } - - private static string BuildDisplayDeployCommand( - VercelEnvironmentOptionsAnnotation options, - string resourceName, - IReadOnlyList> environmentVariables) - { - // The plan should explain the command shape without leaking concrete source roots, - // project names, or environment values that may be machine- or account-specific. - var displayEnvironmentVariables = environmentVariables - .Select(static environmentVariable => new KeyValuePair(environmentVariable.Key, "")) - .ToArray(); - - string displayImage = $"vcr.vercel.com///{VercelContainerServiceName}:"; - string displayDeployDirectory = $"<{resourceName}-build-output>"; - string displayProject = $"<{resourceName}-vercel-project>"; - return $"vercel pull --cwd <{resourceName}-vercel-project-link> --yes --environment {GetVercelProjectEnvironmentName(options)} && aspire build/push {resourceName} -> {displayImage} && docker {string.Join(" ", BuildDockerInspectDigestArguments(displayImage))} && vercel {string.Join(" ", BuildDeployArguments(options, displayDeployDirectory, displayProject, displayEnvironmentVariables))}"; - } - - private static async Task ValidateExistingDeploymentStateAsync( - PipelineStepContext context, - VercelEnvironmentResource environment, - VercelEnvironmentOptionsAnnotation options) - { - var stateManager = context.Services.GetRequiredService(); - var stateSection = await stateManager.AcquireSectionAsync(GetStateSectionName(environment), context.CancellationToken).ConfigureAwait(false); - var existingState = ReadDeploymentState(stateSection); - if (existingState is not null) - { - ValidateDeploymentState(environment, options, existingState); - } - } - - private static async Task GetPreviousDeploymentStateEntryAsync( - PipelineStepContext context, - VercelEnvironmentResource environment, - VercelEnvironmentOptionsAnnotation options, - string resourceName, - string projectName) - { - var stateManager = context.Services.GetRequiredService(); - var stateSection = await stateManager.AcquireSectionAsync(GetStateSectionName(environment), context.CancellationToken).ConfigureAwait(false); - var existingState = ReadDeploymentState(stateSection); - if (existingState is null) - { - return null; - } - - ValidateDeploymentState(environment, options, existingState); - var entry = existingState.Deployments.FirstOrDefault(deployment => - string.Equals(deployment.ResourceName, resourceName, StringComparison.Ordinal) - && string.Equals(deployment.ProjectName, projectName, StringComparison.Ordinal)); - - return entry is null - ? null - : new(entry, GetVercelProjectEnvironmentName(existingState)); - } - - private static async Task SaveDeploymentStateEntryAsync( - PipelineStepContext context, - VercelEnvironmentResource environment, - VercelEnvironmentOptionsAnnotation options, - VercelDeploymentStateEntry deployment) - { - var stateManager = context.Services.GetRequiredService(); - var stateSection = await stateManager.AcquireSectionAsync(GetStateSectionName(environment), context.CancellationToken).ConfigureAwait(false); - var existingState = ReadDeploymentState(stateSection); - var state = existingState is null - ? CreateDeploymentState(environment, options, [deployment]) - : MergeDeploymentState(environment, options, existingState, deployment); - - stateSection.SetValue(JsonSerializer.Serialize(state, JsonOptions)); - - await stateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); - } - - private static VercelDeploymentState CreateDeploymentState( - VercelEnvironmentResource environment, - VercelEnvironmentOptionsAnnotation options, - VercelDeploymentStateEntry[] deployments) - => new( - DeploymentStateSchemaVersion, - environment.Name, - NormalizeScope(options.Scope), - NormalizeTarget(options.Target), - options.Production, - deployments); - - private static VercelDeploymentState MergeDeploymentState( - VercelEnvironmentResource environment, - VercelEnvironmentOptionsAnnotation options, - VercelDeploymentState existingState, - VercelDeploymentStateEntry deployment) - { - ValidateDeploymentState(environment, options, existingState); - - return CreateDeploymentState( - environment, - options, - [ - .. existingState.Deployments.Where(existing => - !string.Equals(existing.ResourceName, deployment.ResourceName, StringComparison.Ordinal) - || !string.Equals(existing.ProjectName, deployment.ProjectName, StringComparison.Ordinal)), - deployment - ]); - } - - private static VercelDeploymentState? ReadDeploymentState(DeploymentStateSection stateSection) - { - // DeploymentStateSection storage shape has changed across Aspire builds. Accept the - // known wrappers so destroy can still clean up projects created by an older CLI. - if (stateSection.Data.TryGetPropertyValue("value", out JsonNode? value) - && value is not null) - { - return DeserializeDeploymentState(value); - } - - value = stateSection.Data.FirstOrDefault().Value; - if (value is not null) - { - return DeserializeDeploymentState(value); - } - - if (stateSection.Data.ContainsKey("schemaVersion")) - { - return stateSection.Data.Deserialize(JsonOptions); - } - - return null; - } - - private static VercelDeploymentState? DeserializeDeploymentState(JsonNode value) - { - return value.GetValueKind() == JsonValueKind.String - ? JsonSerializer.Deserialize(value.GetValue(), JsonOptions) - : value.Deserialize(JsonOptions); - } - - private static void ValidateDeploymentState( - VercelEnvironmentResource environment, - VercelEnvironmentOptionsAnnotation options, - VercelDeploymentState state) - { - if (state.SchemaVersion != DeploymentStateSchemaVersion) - { - throw new DistributedApplicationException($"Vercel deployment state for environment '{environment.Name}' uses unsupported schema version '{state.SchemaVersion}'. Redeploy the environment before destroying it."); - } - - if (!string.Equals(state.Environment, environment.Name, StringComparison.Ordinal)) - { - throw new DistributedApplicationException($"Vercel deployment state for environment '{state.Environment}' cannot be used to destroy environment '{environment.Name}'."); - } - - string? configuredScope = NormalizeScope(options.Scope); - if (!string.Equals(state.Scope, configuredScope, StringComparison.Ordinal)) - { - string stateScope = string.IsNullOrWhiteSpace(state.Scope) ? "" : state.Scope; - string requestedScope = string.IsNullOrWhiteSpace(configuredScope) ? "" : configuredScope; - throw new DistributedApplicationException($"Vercel deployment state for environment '{environment.Name}' was created for scope '{stateScope}', but destroy is configured for scope '{requestedScope}'. Use the same Vercel scope that created the deployment state."); - } - } - - private static string? NormalizeScope(string? scope) - => string.IsNullOrWhiteSpace(scope) ? null : scope; - - private static string? NormalizeTarget(string? target) - => string.IsNullOrWhiteSpace(target) ? null : target; - - private static string? GetProductionUrl(VercelEnvironmentOptionsAnnotation options, string projectName) - => options.Production ? $"https://{projectName}.vercel.app" : null; - - private static string GetDeployDirectory(VercelDeploymentEntry entry) - => string.IsNullOrWhiteSpace(entry.DeployDirectory) ? entry.SourceRoot : entry.DeployDirectory; - - private static VercelDeploymentState RemoveManagedProjectFromDeploymentState(VercelDeploymentState state, string projectName) - => state with - { - Deployments = state.Deployments - .Where(deployment => !deployment.ManagedByAspire || !string.Equals(deployment.ProjectName, projectName, StringComparison.Ordinal)) - .ToArray() - }; - - private static string GetStateSectionName(VercelEnvironmentResource environment) => $"{StateSectionNamePrefix}{environment.Name}"; - - private static async Task ProjectExistsAsync( - PipelineStepContext context, - IVercelCliRunner runner, - VercelEnvironmentOptionsAnnotation options, - string projectName) - { - // Avoid treating localized or reformatted CLI errors as provider state. The Vercel - // CLI exposes project lists as JSON, so destroy checks exact project names before - // deleting and again after a failed delete to distinguish races from real failures. - string[] arguments = BuildListProjectsArguments(options, projectName); - var result = await runner.RunAsync(VercelCliFileName, arguments, workingDirectory: null, context.CancellationToken).ConfigureAwait(false); - if (!result.Succeeded) - { - throw CreateCliException($"list Vercel projects while checking for '{projectName}'", VercelCliFileName, result); - } - - return ProjectListContainsProject(result.StandardOutput, projectName); - } - - private static async Task ProjectEnvironmentVariableExistsAsync( - PipelineStepContext context, - IVercelCliRunner runner, - VercelEnvironmentOptionsAnnotation options, - string projectLinkDirectory, - string name, - string targetEnvironment) - { - // `vercel env rm` reports absence as human text, but `vercel env ls --format=json` - // returns the linked project's exact keys. Use that provider read for idempotent - // stale-secret cleanup instead of parsing failure prose. - string[] arguments = BuildListProjectEnvironmentVariablesArguments(options, projectLinkDirectory, targetEnvironment); - var result = await runner.RunAsync(VercelCliFileName, arguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); - if (!result.Succeeded) - { - throw CreateCliException($"list Vercel project environment variables before removing '{name}'", VercelCliFileName, result); - } - - return EnvironmentVariableListContainsName(result.StandardOutput, name); - } - - private static async Task ConfigureProjectEnvironmentVariablesAsync( - PipelineStepContext context, - IVercelCliRunner runner, - VercelEnvironmentOptionsAnnotation options, - VercelDeploymentEntry entry, - string projectLinkDirectory, - IReadOnlyList> environmentVariables, - PreviousVercelDeployment? previousDeployment) - { - var currentNames = environmentVariables - .Select(static variable => variable.Key) - .ToHashSet(StringComparer.Ordinal); - - if (previousDeployment is not null) - { - string[] staleNames = previousDeployment.Entry.ProjectEnvironmentVariables - .Where(name => !currentNames.Contains(name)) - .Order(StringComparer.Ordinal) - .ToArray(); - - await RemoveProjectEnvironmentVariablesAsync( - context, - runner, - options, - projectLinkDirectory, - entry.Resource.Name, - staleNames, - previousDeployment.ProjectEnvironment).ConfigureAwait(false); - } - - if (environmentVariables.Count == 0) - { - return; - } - - string targetEnvironment = GetVercelProjectEnvironmentName(options); - foreach (var environmentVariable in environmentVariables.OrderBy(static variable => variable.Key, StringComparer.Ordinal)) - { - string[] arguments = BuildAddProjectEnvironmentVariableArguments(options, projectLinkDirectory, environmentVariable.Key, targetEnvironment); - var result = await runner.RunAsync(VercelCliFileName, arguments, projectLinkDirectory, context.CancellationToken, standardInput: environmentVariable.Value).ConfigureAwait(false); - if (!result.Succeeded) - { - throw CreateCliException($"configure Vercel project environment variable '{environmentVariable.Key}' for resource '{entry.Resource.Name}'", VercelCliFileName, result); - } - } - } - - private static async Task RemoveProjectEnvironmentVariablesAsync( - PipelineStepContext context, - IVercelCliRunner runner, - VercelEnvironmentOptionsAnnotation options, - string projectLinkDirectory, - string resourceName, - IReadOnlyList names, - string targetEnvironment) - { - foreach (string name in names) - { - if (!await ProjectEnvironmentVariableExistsAsync(context, runner, options, projectLinkDirectory, name, targetEnvironment).ConfigureAwait(false)) - { - continue; - } - - string[] arguments = BuildRemoveProjectEnvironmentVariableArguments(options, projectLinkDirectory, name, targetEnvironment); - var result = await runner.RunAsync(VercelCliFileName, arguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); - if (!result.Succeeded - && await ProjectEnvironmentVariableExistsAsync(context, runner, options, projectLinkDirectory, name, targetEnvironment).ConfigureAwait(false)) - { - throw CreateCliException($"remove stale Vercel project environment variable '{name}' for resource '{resourceName}'", VercelCliFileName, result); - } - } - } - - private static async Task RemoveLinkedProjectEnvironmentVariablesAsync( - PipelineStepContext context, - IVercelCliRunner runner, - VercelEnvironmentOptionsAnnotation options, - VercelEnvironmentResource environment, - VercelDeploymentStateEntry deployment, - string targetEnvironment) - { - var outputService = context.Services.GetRequiredService(); - string projectLinkDirectory = Path.Combine(outputService.GetTempDirectory(environment), ".vercel-projects", deployment.ProjectName); - DeleteDirectoryIfExists(projectLinkDirectory); - Directory.CreateDirectory(projectLinkDirectory); - - try - { - string[] linkArguments = BuildLinkProjectArguments(options, projectLinkDirectory, deployment.ProjectId ?? deployment.ProjectName); - var linkResult = await runner.RunAsync(VercelCliFileName, linkArguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); - if (!linkResult.Succeeded) - { - throw CreateCliException($"link Vercel project '{deployment.ProjectName}' for environment variable cleanup", VercelCliFileName, linkResult); - } - - await RemoveProjectEnvironmentVariablesAsync( - context, - runner, - options, - projectLinkDirectory, - deployment.ResourceName, - deployment.ProjectEnvironmentVariables, - targetEnvironment).ConfigureAwait(false); - } - finally - { - DeleteDirectoryIfExists(projectLinkDirectory); - } - } - - private static async Task PrepareProjectEnvironmentDirectoryAsync( - PipelineStepContext context, - IVercelCliRunner runner, - VercelEnvironmentOptionsAnnotation options, - VercelDeploymentEntry entry) - { - var outputService = context.Services.GetRequiredService(); - string projectLinkDirectory = Path.Combine(outputService.GetTempDirectory(entry.Resource), ".vercel-project"); - if (Directory.Exists(projectLinkDirectory)) - { - Directory.Delete(projectLinkDirectory, recursive: true); - } - - Directory.CreateDirectory(projectLinkDirectory); - - // `vercel env add` is project-scoped but intentionally does not accept --project. - // See https://vercel.com/docs/cli/env and https://vercel.com/docs/cli/link. - // Link a scratch directory instead of the source root so secret configuration can use - // the CLI's native project lookup without writing .vercel metadata into user code. - string[] linkArguments = BuildLinkProjectArguments(options, projectLinkDirectory, GetVercelProjectOption(entry)); - var result = await runner.RunAsync(VercelCliFileName, linkArguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); - if (!result.Succeeded) - { - throw CreateCliException($"prepare temporary Vercel project link for resource '{entry.Resource.Name}'", VercelCliFileName, result); - } - - return projectLinkDirectory; - } - - private static async Task PullProjectSettingsAsync( - PipelineStepContext context, - IVercelCliRunner runner, - VercelEnvironmentOptionsAnnotation options, - VercelDeploymentEntry entry, - string projectLinkDirectory) - { - // `vercel pull` is the documented way to materialize project settings and environment - // files under `.vercel/`; VCR authentication depends on the pulled VERCEL_OIDC_TOKEN. - // See https://vercel.com/docs/cli/pull and https://vercel.com/docs/container-registry. - string targetEnvironment = GetVercelProjectEnvironmentName(options); - string[] arguments = BuildPullProjectSettingsArguments(options, projectLinkDirectory, targetEnvironment); - var result = await runner.RunAsync(VercelCliFileName, arguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); - if (!result.Succeeded) - { - throw CreateCliException($"pull Vercel project settings for resource '{entry.Resource.Name}'", VercelCliFileName, result); - } - - string vercelDirectory = Path.Combine(projectLinkDirectory, VercelDirectoryName); - string projectJsonPath = Path.Combine(vercelDirectory, VercelProjectFileName); - string environmentPath = Path.Combine(vercelDirectory, $".env.{targetEnvironment}.local"); - - if (!File.Exists(projectJsonPath)) - { - throw new DistributedApplicationException($"Vercel pull did not write expected project settings file '{projectJsonPath}' for resource '{entry.Resource.Name}'."); - } - - if (!File.Exists(environmentPath)) - { - throw new DistributedApplicationException($"Vercel pull did not write expected environment file '{environmentPath}' for resource '{entry.Resource.Name}'."); - } - - var environmentVariables = ParseDotEnvFile(await File.ReadAllLinesAsync(environmentPath, context.CancellationToken).ConfigureAwait(false)); - if (!environmentVariables.TryGetValue(VercelOidcTokenEnvironmentVariable, out string? oidcToken) - || string.IsNullOrWhiteSpace(oidcToken)) - { - throw new DistributedApplicationException($"Vercel pull did not provide {VercelOidcTokenEnvironmentVariable}, which is required to authenticate local Docker builds to VCR."); - } - - string projectJsonContent = await File.ReadAllTextAsync(projectJsonPath, context.CancellationToken).ConfigureAwait(false); - var project = ReadVercelProjectSettings(projectJsonPath, projectJsonContent); - - // `vercel pull` materializes project secrets next to the scratch link so local - // builders can read them. This integration only needs the short-lived OIDC token - // and project metadata; delete the env files before creating deploy artifacts. - DeleteIfExists(environmentPath); - DeleteIfExists(Path.Combine(projectLinkDirectory, ".env.local")); - - return new(project.ProjectName, project.ProjectId, project.OrgId, projectJsonContent, oidcToken); - } - - private static async Task LoginToVcrAsync( - PipelineStepContext context, - IVercelCliRunner runner, - string oidcToken, - VercelOidcClaims claims) - => await LoginToVcrAsync(runner, oidcToken, claims, context.CancellationToken).ConfigureAwait(false); - - internal static async Task LoginToVcrAsync( - IVercelCliRunner runner, - string oidcToken, - VercelOidcClaims claims, - CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(claims.OwnerId)) - { - throw new DistributedApplicationException("The Vercel OIDC token did not include the owner_id claim required to authenticate to VCR."); - } - - // VCR supports Docker-compatible tooling at vcr.vercel.com. This login uses the - // Vercel-issued OIDC token pulled for the linked project. - // See https://vercel.com/docs/container-registry. - string[] arguments = BuildDockerLoginArguments(claims.OwnerId); - var result = await runner.RunAsync(DockerCliFileName, arguments, workingDirectory: null, cancellationToken, standardInput: oidcToken).ConfigureAwait(false); - if (!result.Succeeded) - { - throw CreateCliException("authenticate Docker to VCR", DockerCliFileName, result); - } - } - - internal static async Task WriteBuildOutputAsync( - VercelDeploymentEntry entry, - VercelPulledProject project, - string imageReference, - CancellationToken cancellationToken) - { - // Vercel Build Output API v3 expects the file-system contract documented at - // https://vercel.com/docs/build-output-api and - // https://vercel.com/docs/build-output-api/configuration: - // .vercel/project.json copied project identity from `vercel pull` - // .vercel/output/config.json routes and API version - // .vercel/output/functions/index.func/.vc-config.json - // { "runtime": "container", "handler": "@sha256:..." } - // There is intentionally no user source copy here; Aspire's build/push pipeline has - // already built the image, and Vercel deploy uploads only metadata that points at it. - string vercelDirectory = Path.Combine(entry.DeployDirectory, VercelDirectoryName); - string outputDirectory = Path.Combine(vercelDirectory, VercelOutputDirectoryName); - string functionDirectory = Path.Combine(outputDirectory, "functions", "index.func"); - Directory.CreateDirectory(functionDirectory); - - await File.WriteAllTextAsync(Path.Combine(vercelDirectory, VercelProjectFileName), project.ProjectJsonContent, cancellationToken).ConfigureAwait(false); - - var outputConfig = new JsonObject - { - ["version"] = VercelBuildOutputApiVersion, - ["routes"] = new JsonArray - { - new JsonObject - { - ["handle"] = "filesystem" - }, - new JsonObject - { - ["src"] = "/(.*)", - ["dest"] = "/index" - } - } - }; - - var functionConfig = new JsonObject - { - ["handler"] = imageReference, - ["runtime"] = "container", - ["environment"] = new JsonObject() - }; - - await File.WriteAllTextAsync(Path.Combine(outputDirectory, "config.json"), outputConfig.ToJsonString(JsonOptions), cancellationToken).ConfigureAwait(false); - await File.WriteAllTextAsync(Path.Combine(functionDirectory, ".vc-config.json"), functionConfig.ToJsonString(JsonOptions), cancellationToken).ConfigureAwait(false); - } - - private static VercelPulledProjectSettings ReadVercelProjectSettings(string projectJsonPath, string projectJsonContent) - { - try - { - // `vercel pull` writes `.vercel/project.json`. Only project identity fields are - // needed: they select the linked provider project and are safe to persist in state. - using var document = JsonDocument.Parse(projectJsonContent); - var root = document.RootElement; - - string projectName = root.TryGetProperty("projectName", out var projectNameElement) && projectNameElement.ValueKind == JsonValueKind.String - ? projectNameElement.GetString() ?? string.Empty - : string.Empty; - string? projectId = root.TryGetProperty("projectId", out var projectIdElement) && projectIdElement.ValueKind == JsonValueKind.String - ? projectIdElement.GetString() - : null; - string? orgId = root.TryGetProperty("orgId", out var orgIdElement) && orgIdElement.ValueKind == JsonValueKind.String - ? orgIdElement.GetString() - : null; - - return new(projectName, projectId, orgId); - } - catch (JsonException ex) - { - throw new DistributedApplicationException($"Vercel project settings file '{projectJsonPath}' is invalid JSON.", ex); - } - } - - internal static string GetDockerImageDigest(string output) - { - string trimmed = output.Trim(); - if (trimmed.StartsWith("\"", StringComparison.Ordinal)) - { - try - { - // `docker buildx imagetools inspect --format '{{json .Digest}}'` style output - // is a JSON string. Older experiments used this shape before Vercel required - // selecting the concrete linux/amd64 manifest from the OCI index. - string? digest = JsonSerializer.Deserialize(trimmed); - if (IsSha256Digest(digest)) - { - return digest!; - } - } - catch (JsonException ex) - { - throw new DistributedApplicationException("Docker returned invalid JSON while resolving the pushed VCR image digest.", ex); - } - } - - if (trimmed.StartsWith("{", StringComparison.Ordinal)) - { - try - { - // Current path uses `--format '{{json .Manifest}}'`. Docker may return an OCI - // image index with a `manifests[]` array, or a single manifest object. Vercel's - // Container Images docs describe VCR-backed OCI images; live smoke tests rejected - // index digests, so prefer the linux/amd64 child. - // See https://vercel.com/docs/functions/container-images. - using var document = JsonDocument.Parse(trimmed); - var root = document.RootElement; - - if (root.TryGetProperty("manifests", out var manifests) && manifests.ValueKind == JsonValueKind.Array) - { - foreach (var manifest in manifests.EnumerateArray()) - { - if (manifest.TryGetProperty("platform", out var platform) - && platform.TryGetProperty("os", out var osElement) - && platform.TryGetProperty("architecture", out var architectureElement) - && string.Equals(osElement.GetString(), "linux", StringComparison.OrdinalIgnoreCase) - && string.Equals(architectureElement.GetString(), "amd64", StringComparison.OrdinalIgnoreCase) - && TryGetJsonString(manifest, "digest", out var platformDigest) - && IsSha256Digest(platformDigest)) - { - return platformDigest!; - } - } - - throw new DistributedApplicationException("Docker did not return a linux/amd64 manifest digest for the pushed VCR image. Vercel requires linux/amd64 container images."); - } - - if (TryGetJsonString(root, "digest", out var digest) && IsSha256Digest(digest)) - { - return digest!; - } - } - catch (JsonException ex) - { - throw new DistributedApplicationException("Docker returned invalid JSON while resolving the pushed VCR image digest.", ex); - } - } - - var match = Regex.Match(trimmed, @"sha256:[a-fA-F0-9]{64}", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); - if (match.Success) - { - return match.Value; - } - - throw new DistributedApplicationException($"Docker did not return a valid sha256 image digest. Output: {GetTrimmedOutput(output)}"); - } - - private static bool TryGetJsonString(JsonElement element, string propertyName, [NotNullWhen(true)] out string? value) - { - value = element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String - ? property.GetString() - : null; - - return !string.IsNullOrWhiteSpace(value); - } - - internal static VercelOidcClaims DecodeUnvalidatedOidcClaims(string token) - { - string[] parts = token.Split('.'); - if (parts.Length != 3) - { - throw new DistributedApplicationException("The Vercel OIDC token is not a valid compact JWT."); - } - - try - { - // This is an unvalidated decode of the Vercel-issued token from `vercel pull`. - // Docker/Vercel validate the token when it is used; here we only need routing - // metadata such as owner_id/project to construct the VCR login and repository. - byte[] payloadBytes = Convert.FromBase64String(PadBase64Url(parts[1])); - using var document = JsonDocument.Parse(payloadBytes); - var root = document.RootElement; - - return new( - GetStringClaim(root, "owner_id"), - GetStringClaim(root, "owner"), - GetStringClaim(root, "project"), - GetStringClaim(root, "project_id")); - } - catch (Exception ex) when (ex is FormatException or JsonException) - { - throw new DistributedApplicationException("The Vercel OIDC token payload could not be decoded.", ex); - } - } - - private static string? GetStringClaim(JsonElement root, string name) - => root.TryGetProperty(name, out var element) && element.ValueKind == JsonValueKind.String - ? element.GetString() - : null; - - private static string PadBase64Url(string value) - { - string padded = value.Replace('-', '+').Replace('_', '/'); - return padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '='); - } - - internal static Dictionary ParseDotEnvFile(IEnumerable lines) - { - // Vercel writes dotenv files such as `.vercel/.env.production.local` during pull. - // We only need the VERCEL_OIDC_TOKEN line. This intentionally supports the subset the - // CLI emits: comments/blank lines, KEY=value, single/double quoted values, and common - // backslash escapes. It is not a general dotenv evaluator with interpolation. - Dictionary values = new(StringComparer.Ordinal); - - foreach (string rawLine in lines) - { - string line = rawLine.Trim(); - if (line.Length == 0 || line[0] == '#') - { - continue; - } - - int separator = line.IndexOf('='); - if (separator <= 0) - { - continue; - } - - string key = line[..separator].Trim(); - string value = line[(separator + 1)..].Trim(); - values[key] = UnquoteDotEnvValue(value); - } - - return values; - } - - private static string UnquoteDotEnvValue(string value) - { - if (value.Length >= 2 - && ((value[0] == '"' && value[^1] == '"') - || (value[0] == '\'' && value[^1] == '\''))) - { - value = value[1..^1]; - } - - return value.Replace("\\n", "\n", StringComparison.Ordinal) - .Replace("\\r", "\r", StringComparison.Ordinal) - .Replace("\\\"", "\"", StringComparison.Ordinal) - .Replace("\\\\", "\\", StringComparison.Ordinal); - } - - private static bool IsSha256Digest([NotNullWhen(true)] string? value) - => value is not null && Regex.IsMatch(value, "^sha256:[a-f0-9]{64}$", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); - - private static void DeleteIfExists(string path) - { - if (File.Exists(path)) - { - File.Delete(path); - } - } - - private static void DeleteDirectoryIfExists(string path) - { - if (Directory.Exists(path)) - { - Directory.Delete(path, recursive: true); - } - } - - private static async Task EnsureManagedProjectAsync( - PipelineStepContext context, - IVercelCliRunner runner, - VercelEnvironmentOptionsAnnotation options, - VercelDeploymentEntry entry) - { - // `vercel project add` is idempotent for the current login/scope in the CLI versions - // this integration supports: it creates the project or validates that it already - // exists and is accessible. Failure here means deploy should not proceed to image push. - string projectName = GetVercelProjectName(entry); - string[] arguments = BuildAddProjectArguments(options, projectName); - var result = await runner.RunAsync(VercelCliFileName, arguments, workingDirectory: null, context.CancellationToken).ConfigureAwait(false); - if (!result.Succeeded) - { - throw CreateCliException($"create or validate Vercel project '{projectName}' for resource '{entry.Resource.Name}'", VercelCliFileName, result); - } - } - - private static string GetVercelProjectEnvironmentName(VercelEnvironmentOptionsAnnotation options) - { - if (options.Production) - { - return "production"; - } - - return string.IsNullOrWhiteSpace(options.Target) ? "preview" : options.Target; - } - - private static string GetVercelProjectEnvironmentName(VercelDeploymentState state) - { - if (state.Production) - { - return "production"; - } - - return string.IsNullOrWhiteSpace(state.Target) ? "preview" : state.Target; - } - - private static async Task GetVercelEnvironmentConfigurationAsync( - IResource resource, - VercelEnvironmentOptionsAnnotation options, - IExecutionConfigurationResult executionConfiguration, - IReadOnlyDictionary entriesByResourceName, - bool resolveProjectEnvironmentVariableValues, - CancellationToken cancellationToken) - { - List> deploymentEnvironmentVariables = []; - List> projectEnvironmentVariables = []; - HashSet names = new(StringComparer.Ordinal); - - // This is the env-var edge-case boundary. Vercel has deployment env args and - // project env secrets, but no Aspire-style connection binding/service-discovery - // object in this preview. Only deterministic production endpoint URLs are projected; - // other resource references fail here instead of becoming misleading strings. - // Keep a single pass over Aspire's unprocessed environment dictionary. The processed - // value can contain publish-mode manifest expressions, but deployment needs the - // original graph value so it can choose Vercel's concrete URL/secret mechanism. - foreach (var environmentVariable in executionConfiguration.EnvironmentVariablesWithUnprocessed) - { - string name = environmentVariable.Key; - object unprocessedValue = environmentVariable.Value.Item1; - string value = environmentVariable.Value.Item2; - - ValidateEnvironmentVariableName(resource, name); - if (!names.Add(name)) - { - throw new DistributedApplicationException( - $"Resource '{resource.Name}' configures environment variable '{name}' more than once. Vercel project environment variable names must be unique."); - } - - if (ContainsUnsupportedResourceReference(resource, unprocessedValue)) - { - throw new DistributedApplicationException( - $"Environment variable '{name}' for resource '{resource.Name}' references another Aspire resource or service in a way that cannot be represented as a Vercel deployment URL. Use endpoint references to Vercel production workloads, or configure the value in Vercel project environment variables."); - } - - // Non-secrets can ride on `vercel deploy --env`; secret-bearing values must use - // Vercel project environment variables so values never appear in CLI arguments. - bool containsSecret = ContainsSecretReference(unprocessedValue); - if (containsSecret) - { - value = resolveProjectEnvironmentVariableValues - ? await GetVercelProjectEnvironmentVariableValueAsync( - resource, - options, - entriesByResourceName, - unprocessedValue, - value, - cancellationToken).ConfigureAwait(false) - : ""; - } - else if (TryGetVercelEnvironmentVariableValue(resource, options, entriesByResourceName, unprocessedValue, out string? vercelValue)) - { - value = vercelValue; - } - - if (containsSecret) - { - projectEnvironmentVariables.Add(new(name, value)); - } - else - { - deploymentEnvironmentVariables.Add(new(name, value)); - } - } - - return new(deploymentEnvironmentVariables, projectEnvironmentVariables); - } - - private static async ValueTask GetVercelProjectEnvironmentVariableValueAsync( - IResource resource, - VercelEnvironmentOptionsAnnotation options, - IReadOnlyDictionary entriesByResourceName, - object? value, - string processedValue, - CancellationToken cancellationToken) - { - // This path resolves values for `vercel env add`, not `vercel deploy --env`. - // Values can be secret-bearing because they are sent on stdin to Vercel's secret - // store; they must not be copied into publish plans, command lines, or state. - switch (value) - { - case null: - return processedValue; - case string stringValue: - return stringValue; - case ParameterResource parameter: - return await GetParameterValueAsync(parameter, cancellationToken).ConfigureAwait(false); - case IResourceBuilder parameterBuilder: - return await GetParameterValueAsync(parameterBuilder.Resource, cancellationToken).ConfigureAwait(false); - case IResourceWithConnectionString connectionStringResource: - return await GetValueProviderValueAsync(connectionStringResource.ConnectionStringExpression, $"connection string for resource '{connectionStringResource.Name}'", cancellationToken).ConfigureAwait(false); - case IResourceBuilder connectionStringBuilder: - return await GetValueProviderValueAsync(connectionStringBuilder.Resource.ConnectionStringExpression, $"connection string for resource '{connectionStringBuilder.Resource.Name}'", cancellationToken).ConfigureAwait(false); - case ReferenceExpression referenceExpression: - return await GetVercelProjectReferenceExpressionValueAsync(resource, options, entriesByResourceName, referenceExpression, cancellationToken).ConfigureAwait(false); - case IValueProvider valueProvider: - return await GetValueProviderValueAsync(valueProvider, "environment variable value", cancellationToken).ConfigureAwait(false); - default: - return processedValue; - } - } - - private static async ValueTask GetVercelProjectReferenceExpressionValueAsync( - IResource resource, - VercelEnvironmentOptionsAnnotation options, - IReadOnlyDictionary entriesByResourceName, - ReferenceExpression referenceExpression, - CancellationToken cancellationToken) - { - if (referenceExpression.IsConditional) - { - throw new DistributedApplicationException("Vercel project environment variables do not support conditional reference expressions. Configure a concrete Vercel project environment variable instead."); - } - - var arguments = new object?[referenceExpression.ValueProviders.Count]; - for (int i = 0; i < referenceExpression.ValueProviders.Count; i++) - { - IValueProvider valueProvider = referenceExpression.ValueProviders[i]; - arguments[i] = valueProvider switch - { - // Secret-bearing project env vars may combine endpoint URLs with secret - // parameters because the final value is sent through Vercel's secret path. - EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference) => GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReference.Property(EndpointProperty.Url)), - EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint) => GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReferenceExpression), - ParameterResource parameter => await GetParameterValueAsync(parameter, cancellationToken).ConfigureAwait(false), - IResourceWithConnectionString connectionStringResource => await GetValueProviderValueAsync(connectionStringResource.ConnectionStringExpression, $"connection string for resource '{connectionStringResource.Name}'", cancellationToken).ConfigureAwait(false), - _ => await GetValueProviderValueAsync(valueProvider, "reference expression value", cancellationToken).ConfigureAwait(false) - }; - - if (referenceExpression.StringFormats[i] is "uri" && arguments[i] is string stringValue) - { - arguments[i] = Uri.EscapeDataString(stringValue); - } - } - - return string.Format(CultureInfo.InvariantCulture, referenceExpression.Format, arguments); - } - - private static async ValueTask GetParameterValueAsync(ParameterResource parameter, CancellationToken cancellationToken) - { - try - { - string? value = await parameter.GetValueAsync(cancellationToken).ConfigureAwait(false); - return value ?? throw new DistributedApplicationException($"Secret parameter '{parameter.Name}' did not produce a value for Vercel project environment configuration."); - } - catch (MissingParameterValueException ex) - { - throw new DistributedApplicationException($"Secret parameter '{parameter.Name}' does not have a value. Provide a value before deploying to Vercel.", ex); - } - } - - private static async ValueTask GetValueProviderValueAsync(IValueProvider valueProvider, string description, CancellationToken cancellationToken) - { - try - { - string? value = await valueProvider.GetValueAsync(cancellationToken).ConfigureAwait(false); - return value ?? throw new DistributedApplicationException($"The {description} did not produce a value for Vercel project environment configuration."); - } - catch (MissingParameterValueException ex) - { - throw new DistributedApplicationException($"The {description} does not have a value. Provide a value before deploying to Vercel.", ex); - } - } - - private static bool TryGetVercelEnvironmentVariableValue( - IResource resource, - VercelEnvironmentOptionsAnnotation options, - IReadOnlyDictionary entriesByResourceName, - object? value, - [NotNullWhen(true)] out string? vercelValue) - { - // Service-discovery env vars generated by WithReference also arrive as structured - // endpoint values, so translate by value shape instead of by environment variable name. - switch (value) - { - case EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference): - vercelValue = GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReference.Property(EndpointProperty.Url)); - return true; - case EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint): - vercelValue = GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReferenceExpression); - return true; - case ReferenceExpression referenceExpression when ContainsCrossResourceEndpointReference(resource, referenceExpression): - vercelValue = GetVercelReferenceExpressionValue(resource, options, entriesByResourceName, referenceExpression); - return true; - default: - vercelValue = null; - return false; - } - } - - private static string GetVercelReferenceExpressionValue( - IResource resource, - VercelEnvironmentOptionsAnnotation options, - IReadOnlyDictionary entriesByResourceName, - ReferenceExpression referenceExpression) - { - if (referenceExpression.IsConditional) - { - throw new DistributedApplicationException("Vercel endpoint references do not support conditional reference expressions. Configure a concrete Vercel project environment variable instead."); - } - - var arguments = new object?[referenceExpression.ValueProviders.Count]; - for (int i = 0; i < referenceExpression.ValueProviders.Count; i++) - { - IValueProvider valueProvider = referenceExpression.ValueProviders[i]; - arguments[i] = valueProvider switch - { - EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference) => GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReference.Property(EndpointProperty.Url)), - EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint) => GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReferenceExpression), - // Mixed expressions can hide provider-specific ordering or secret semantics. - // Keep this path to deterministic endpoint-only production URLs. - _ => throw new DistributedApplicationException("Vercel endpoint reference expressions cannot be combined with parameters, secrets, or other value providers. Configure a concrete Vercel project environment variable instead.") - }; - - if (referenceExpression.StringFormats[i] is "uri" && arguments[i] is string stringValue) - { - arguments[i] = Uri.EscapeDataString(stringValue); - } - } - - return string.Format(CultureInfo.InvariantCulture, referenceExpression.Format, arguments); - } - - private static string GetVercelEndpointPropertyValue( - IResource resource, - VercelEnvironmentOptionsAnnotation options, - IReadOnlyDictionary entriesByResourceName, - EndpointReferenceExpression endpointReferenceExpression) - { - // This is the endpoint-reference edge-case boundary: preview/custom URLs are - // post-deploy outputs, internal endpoints have no public Vercel edge address, and - // cross-environment references lack a stable same-deploy alias. Fail before values - // are written to Vercel env vars. - if (!options.Production) - { - throw new DistributedApplicationException( - "Vercel endpoint references require production deployments because preview and custom target URLs are assigned by Vercel after deployment. Call WithVercelProductionDeployments on the Vercel environment, or remove the reference."); - } - - var endpointReference = endpointReferenceExpression.Endpoint; - var endpoint = endpointReference.EndpointAnnotation; - if (!endpoint.IsExternal) - { - throw new DistributedApplicationException( - $"Resource '{resource.Name}' references endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}', but Vercel endpoint references can only target external HTTP or HTTPS endpoints. Configure an external endpoint or remove the reference."); - } - - if (!IsHttpEndpoint(endpoint)) - { - throw new DistributedApplicationException( - $"Resource '{resource.Name}' references endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}' with scheme '{endpoint.UriScheme}', but Vercel endpoint references support only HTTP or HTTPS endpoints."); - } - - if (!entriesByResourceName.TryGetValue(endpointReference.Resource.Name, out var referencedEntry)) - { - throw new DistributedApplicationException( - $"Resource '{resource.Name}' references endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}', but the referenced resource does not target this Vercel environment. Vercel endpoint references can only target workloads deployed to the same Vercel environment."); - } - - string host = $"{GetVercelProjectName(referencedEntry)}.vercel.app"; - const int port = 443; - - return endpointReferenceExpression.Property switch - { - // Production aliases are deterministic before deploy; preview/custom URLs are not. - // Keep endpoint references on the stable caller-visible Vercel HTTPS surface. - EndpointProperty.Url => $"https://{host}", - EndpointProperty.Host or EndpointProperty.IPV4Host => host, - EndpointProperty.Port => port.ToString(CultureInfo.InvariantCulture), - EndpointProperty.TargetPort => endpoint.TargetPort is int targetPort - ? targetPort.ToString(CultureInfo.InvariantCulture) - : throw new DistributedApplicationException( - // Azure publishers can carry ContainerPortReference placeholders in - // Bicep/Helm. Vercel deploy receives concrete CLI env values, so an - // unresolved TargetPort would become a bogus string rather than a - // target-native reference. - $"Resource '{resource.Name}' references endpoint property '{EndpointProperty.TargetPort}' for endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}', but the endpoint does not define an explicit target port. Configure a target port or avoid passing TargetPort to Vercel."), - EndpointProperty.Scheme => "https", - EndpointProperty.HostAndPort => $"{host}:{port.ToString(CultureInfo.InvariantCulture)}", - EndpointProperty.TlsEnabled => bool.TrueString, - _ => throw new DistributedApplicationException($"The endpoint property '{endpointReferenceExpression.Property}' is not supported for Vercel endpoint references.") - }; - } - - private static void ValidateEnvironmentVariableName(IResource resource, string name) - { - // Do not remap names to satisfy Vercel's env var shape. A lossy rename would break - // the consuming workload's contract, so invalid names fail with the original key. - if (string.IsNullOrWhiteSpace(name) - || (!char.IsAsciiLetter(name[0]) && name[0] != '_') - || name.Any(static character => !char.IsAsciiLetterOrDigit(character) && character != '_')) - { - throw new DistributedApplicationException( - $"Resource '{resource.Name}' configures invalid Vercel environment variable name '{name}'. Use letters, digits, and underscores, and start with a letter or underscore."); - } - } - - private static void ValidateUnsupportedRuntimeConfiguration( - IResource resource, - IExecutionConfigurationResult executionConfiguration) - { - // These Aspire concepts have no faithful Vercel Dockerfile-deploy equivalent in this - // preview. Rejecting them is safer than silently dropping entrypoint, args, or build - // values that would change the workload's deployed behavior. Aspire's built-in - // image build/push pipeline owns build-time Docker options; this validation only - // rejects runtime concepts the Vercel Build Output API path cannot preserve. - if (resource is ContainerResource { Entrypoint: not null }) - { - throw new DistributedApplicationException( - $"Resource '{resource.Name}' configures a container entrypoint, but Vercel Dockerfile deployments use the CMD/ENTRYPOINT from Aspire's publish output. Configure the workload's publish behavior or Vercel project settings instead."); - } - - if (executionConfiguration.ArgumentsWithUnprocessed.Any()) - { - throw new DistributedApplicationException( - $"Resource '{resource.Name}' configures Aspire command-line arguments, but Vercel Dockerfile deployments cannot override Docker CMD/ENTRYPOINT. Configure the workload's publish behavior or express the values as environment variables."); - } - } - - private static bool ContainsSecretReference(object? value) - { - // Connection strings are treated as secret-bearing even when the underlying provider - // does not mark each segment secret; Vercel should receive them through project env. - return value switch - { - null => false, - string => false, - ParameterResource parameter => parameter.Secret, - IResourceBuilder parameterBuilder => parameterBuilder.Resource.Secret, - IResourceWithConnectionString => true, - IResourceBuilder => true, - IValueWithReferences valueWithReferences => valueWithReferences.References.Any(ContainsSecretReference), - _ => false - }; - } - - private static bool ContainsCrossResourceEndpointReference(IResource resource, object? value) - { - return value switch - { - null => false, - EndpointReference endpointReference => IsCrossResourceEndpointReference(resource, endpointReference), - EndpointReferenceExpression endpointReferenceExpression => IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint), - IValueWithReferences valueWithReferences => valueWithReferences.References.Any(reference => ContainsCrossResourceEndpointReference(resource, reference)), - _ => false - }; - } - - private static bool IsCrossResourceEndpointReference(IResource resource, EndpointReference endpointReference) - => !IsSameResource(resource, endpointReference.Resource); - - private static bool ContainsUnsupportedResourceReference(IResource resource, object? value) - { - // Vercel only knows how to turn endpoint references into deterministic production - // aliases. Other resource references can represent connection strings, parameters, - // or custom values that need a target-native mechanism this preview does not have. - return value switch - { - null => false, - string => false, - ParameterResource => false, - IResourceBuilder => false, - EndpointReference => false, - EndpointReferenceExpression => false, - IResource referencedResource => !IsSameResource(resource, referencedResource), - IValueWithReferences valueWithReferences => valueWithReferences.References.Any(reference => ContainsUnsupportedResourceReference(resource, reference)), - IResourceBuilder resourceBuilder => !IsSameResource(resource, resourceBuilder.Resource), - _ => false - }; - } - - private static bool IsSameResource(IResource resource, IResource otherResource) - // Compare by Aspire resource name rather than object identity. Polyglot/ATS flows - // can recreate resource references across an RPC boundary, but resource name is the - // app-model identity used by deployment target maps. - => string.Equals(resource.Name, otherResource.Name, StringComparison.Ordinal); - - private static bool IsHttpEndpoint(EndpointAnnotation endpoint) - // URI scheme alone is not enough: Aspire can model a TCP transport with an HTTP - // URI scheme. Vercel's container ingress here is HTTP-family traffic over TCP. - => endpoint.Protocol == ProtocolType.Tcp - && (string.Equals(endpoint.UriScheme, "http", StringComparison.OrdinalIgnoreCase) - || string.Equals(endpoint.UriScheme, "https", StringComparison.OrdinalIgnoreCase)) - && (string.Equals(endpoint.Transport, "http", StringComparison.OrdinalIgnoreCase) - || string.Equals(endpoint.Transport, "http2", StringComparison.OrdinalIgnoreCase)); - - private static IReadOnlyDictionary GetDeploymentEntriesByResourceName(IReadOnlyList entries) - => entries.ToDictionary(static entry => entry.Resource.Name, StringComparer.Ordinal); - - internal static IEnumerable GetDeploymentEntries(DistributedApplicationModel model, VercelEnvironmentResource environment) - { - var computeEnvironments = model.Resources.OfType().Take(2).ToArray(); - // Match Aspire's single-environment convention only when Vercel is the sole compute - // environment. With mixed targets, implicit selection would accidentally deploy - // untargeted resources to every environment. - bool allowImplicitTargeting = computeEnvironments.Length == 1 && ReferenceEquals(computeEnvironments[0], environment); - - foreach (var resource in model.Resources.OfType()) - { - if (!IsTargetedToEnvironment(resource, environment, allowImplicitTargeting)) - { - continue; - } - - if (resource.TryGetLastAnnotation(out var dockerfile)) - { - yield return new(resource, dockerfile.ContextPath, dockerfile.DockerfilePath, dockerfile); - continue; - } - - if (resource is ProjectResource project) - { - string projectPath = project.GetProjectMetadata().ProjectPath; - string sourceRoot = Path.GetDirectoryName(projectPath) - ?? throw new DistributedApplicationException($"Project resource '{resource.Name}' has project path '{projectPath}' without a containing directory."); - yield return new(resource, sourceRoot); - continue; - } - - throw new DistributedApplicationException($"Resource '{resource.Name}' targets Vercel but is not an Aspire image build resource. Use a .NET project, a workload integration that publishes Dockerfile metadata, call PublishAsDockerFile, or configure the resource with WithDockerfile, WithDockerfileFactory, or WithDockerfileBuilder."); - } - } - - private static bool IsTargetedToEnvironment(IResource resource, VercelEnvironmentResource environment, bool allowImplicitTargeting) - { - var computeEnvironment = resource.GetComputeEnvironment(); - // Match Aspire's single-environment convention: when Vercel is the only compute - // environment, image-build workloads implicitly target it. - return ReferenceEquals(computeEnvironment, environment) - || (computeEnvironment is null && allowImplicitTargeting); - } - - private static void ValidateEntries(IReadOnlyList entries) - { - // Keep unsupported Vercel-preview cases in validation so publish/prereq/deploy fail - // before mutating provider projects. Each failure names the Aspire concept that - // cannot be projected rather than letting a later Vercel CLI call fail opaquely. - if (entries.Count == 0) - { - throw new DistributedApplicationException("No image-build compute resources target Vercel. Add a .NET project, a workload with Aspire Dockerfile publish metadata, or use WithComputeEnvironment to target Vercel when multiple compute environments are present."); - } - - foreach (var entry in entries) - { - if (!Directory.Exists(entry.SourceRoot)) - { - throw new DistributedApplicationException($"The Vercel source root '{entry.SourceRoot}' for resource '{entry.Resource.Name}' does not exist."); - } - - if (entry.Dockerfile is { DockerfileFactory: null } && !File.Exists(entry.DockerfilePath!)) - { - throw new DistributedApplicationException($"The Vercel Dockerfile '{entry.DockerfilePath}' for resource '{entry.Resource.Name}' does not exist. Configure the resource with an existing Dockerfile or Aspire-generated Dockerfile metadata."); - } - - ValidateUnsupportedResourceModel(entry); - } - - ValidateUniqueVercelProjectNames(entries); - } - - private static void ValidateUniqueVercelProjectNames(IReadOnlyList entries) - { - // Production endpoint references use https://{projectName}.vercel.app. If two - // resources resolve to the same Vercel project, endpoint references and destroy - // ownership would both become ambiguous. - var projectNames = entries - .Select(entry => new - { - Entry = entry, - ProjectLink = GetVercelProjectLink(entry), - Linked = HasVercelProjectLinkFile(entry.SourceRoot) - }) - .GroupBy(item => item.ProjectLink.ProjectName, StringComparer.Ordinal) - .Where(group => group.Count() > 1) - .ToArray(); - - if (projectNames.Length == 0) - { - return; - } - - var collision = projectNames[0]; - string resources = string.Join(", ", collision.Select(static item => $"'{item.Entry.Resource.Name}'").Order(StringComparer.Ordinal)); - throw new DistributedApplicationException( - $"Multiple Vercel resources resolve to project name '{collision.Key}' ({resources}). Vercel project names must be unique per environment because each resource deploys to and references one project production URL. Use WithVercelProjectName, distinct source directory names, or link each resource to a distinct Vercel project with .vercel/project.json."); - } - - private static void ValidateUnsupportedResourceModel(VercelDeploymentEntry entry) - { - IResource resource = entry.Resource; - - // This method is intentionally conservative. Each rejected annotation has run-mode - // or another deployment-target semantics that Vercel's Dockerfile deploy cannot - // project without changing what the user modeled in the AppHost. If Vercel gains a - // native equivalent later, add the mapping and tests here instead of silently ignoring it. - if (resource.Annotations.OfType().Any()) - { - throw new DistributedApplicationException( - $"Resource '{resource.Name}' configures Aspire generic container registry/image push metadata, but Vercel deployments use Vercel Container Registry through a provider-owned prebuilt artifact. Remove WithContainerRegistry before deploying with the Aspire Vercel integration."); - } - - if (resource.Annotations.OfType().Any()) - { - throw new DistributedApplicationException( - $"Resource '{resource.Name}' configures Aspire container volumes or bind mounts, but Vercel Dockerfile deployments do not support Aspire-managed container mounts. Move persistent state to a Vercel-supported external service or remove the mount."); - } - - if (resource.Annotations.OfType().Any() - || resource.Annotations.OfType().Any() - || resource.Annotations.OfType().Any()) - { - throw new DistributedApplicationException( - $"Resource '{resource.Name}' configures Aspire container file mounts, but Vercel Dockerfile deployments build the checked-in source tree directly. Include required files in the source tree or deploy this resource outside the Aspire Vercel integration."); - } - - if (resource.Annotations.OfType().Any() - || resource.Annotations.OfType().Any() - || resource.Annotations.OfType().Any()) - { - throw new DistributedApplicationException( - $"Resource '{resource.Name}' configures Aspire health checks or container probes, but the Vercel preview integration does not map them to Vercel-native checks. Remove the Aspire probes or configure health behavior in Vercel."); - } - - if (resource.Annotations.OfType().Any()) - { - throw new DistributedApplicationException( - $"Resource '{resource.Name}' configures Aspire replicas or scale, but the Vercel preview integration does not map replica counts to Vercel-native scaling. Configure scaling in Vercel instead."); - } - - if (resource.Annotations.OfType().Any()) - { - throw new DistributedApplicationException( - $"Resource '{resource.Name}' configures Aspire wait/dependency ordering, but Vercel deploys each project independently and the preview integration does not preserve Aspire startup ordering. Remove the wait relationship or deploy dependent services separately."); - } - - ValidateEndpointModel(entry); - ValidateProjectName(entry); - } - - private static void ValidateEndpointModel(VercelDeploymentEntry entry) - { - var endpoints = entry.Resource.Annotations.OfType().ToArray(); - - if (endpoints.Length == 0) - { - return; - } - - // Reject the tempting Compose/ACA shapes up front: private listeners, multiple - // target ports, and non-HTTP protocols do not have an equivalent in this preview's - // single public Vercel container ingress. - // Vercel's Dockerfile preview exposes one public platform ingress; it has no - // Aspire-modeled private service network for internal endpoints. - var internalEndpoint = endpoints.FirstOrDefault(static endpoint => !endpoint.IsExternal); - if (internalEndpoint is not null) - { - throw new DistributedApplicationException( - $"Resource '{entry.Resource.Name}' configures endpoint '{internalEndpoint.Name}' as internal, but Vercel Dockerfile deployments expose public platform HTTPS ingress only. Mark the endpoint external or remove it before deploying to Vercel."); - } - - var unsupportedEndpoint = endpoints.FirstOrDefault(static endpoint => !IsHttpEndpoint(endpoint)); - if (unsupportedEndpoint is not null) - { - throw new DistributedApplicationException( - $"Resource '{entry.Resource.Name}' configures endpoint '{unsupportedEndpoint.Name}' with scheme '{unsupportedEndpoint.UriScheme}' and transport '{unsupportedEndpoint.Transport}', but Vercel Dockerfile deployments support only HTTP or HTTPS endpoints with HTTP transports."); - } - - var targetPorts = endpoints - .Select(static endpoint => endpoint.TargetPort) - .Where(static targetPort => targetPort.HasValue) - .Select(static targetPort => targetPort!.Value) - .Distinct() - .ToArray(); - - // Vercel provides one runtime listener through $PORT. Additional target ports would - // look like ACA extra ports, but Vercel has no equivalent modeled here. - if (targetPorts.Length > 1) - { - throw new DistributedApplicationException( - $"Resource '{entry.Resource.Name}' configures multiple Aspire endpoint target ports, but Vercel Dockerfile deployments support only one HTTP listening port exposed through the $PORT environment variable."); - } - } - - private static void ValidateProjectName(VercelDeploymentEntry entry) - { - if (HasVercelProjectLinkFile(entry.SourceRoot)) - { - return; - } - - _ = GetVercelProjectName(entry); - } - - private static async Task PrepareDeploymentEntryAsync(PipelineStepContext context, VercelDeploymentEntry entry) - { - await ValidateVercelJsonAsync(entry.Resource, entry.SourceRoot, context.CancellationToken).ConfigureAwait(false); - - var outputService = context.Services.GetRequiredService(); - string tempDirectory = outputService.GetTempDirectory(entry.Resource); - string deployDirectory = Path.Combine(tempDirectory, "vercel-build-output"); - Directory.CreateDirectory(tempDirectory); - if (Directory.Exists(deployDirectory)) - { - Directory.Delete(deployDirectory, recursive: true); - } - - // This is an output-only Build Output API root, not a staged copy of the source. - // Aspire's built-in build/push steps read the real source/project directly; Vercel - // deploy receives only generated metadata that points at the digest already pushed to VCR. - Directory.CreateDirectory(deployDirectory); - - return entry with - { - TempDirectory = tempDirectory, - DeployDirectory = deployDirectory - }; - } - - private static async Task ValidateVercelJsonAsync( - IResource resource, - string sourceRoot, - CancellationToken cancellationToken) - { - string vercelJsonPath = Path.Combine(sourceRoot, VercelJsonFileName); - if (!File.Exists(vercelJsonPath)) - { - return; - } - - JsonObject root; - try - { - root = JsonNode.Parse(await File.ReadAllTextAsync(vercelJsonPath, cancellationToken).ConfigureAwait(false)) as JsonObject - ?? throw new DistributedApplicationException($"Resource '{resource.Name}' source root contains '{VercelJsonFileName}', but it is not a JSON object."); - } - catch (JsonException ex) - { - throw new DistributedApplicationException($"Resource '{resource.Name}' source root contains invalid '{VercelJsonFileName}'.", ex); - } - - var unsupportedKey = VercelJsonBuildOutputUnsupportedKeys.FirstOrDefault(root.ContainsKey); - if (unsupportedKey is not null) - { - throw new DistributedApplicationException( - $"Resource '{resource.Name}' source root contains '{VercelJsonFileName}' with top-level '{unsupportedKey}', but the Aspire Vercel integration owns the generated Build Output API container function and catch-all routing configuration. Move that setting into the Dockerfile, AppHost environment variables, or Vercel project settings before deploying with the Aspire Vercel integration."); - } - } - - private static string GetDisplayDockerfilePath(VercelDeploymentEntry entry) - => entry.Dockerfile is null - ? "" - : entry.Dockerfile.DockerfileFactory is null - ? Path.GetRelativePath(entry.SourceRoot, entry.DockerfilePath!) - : ""; - - internal static string GetVercelProjectName(VercelDeploymentEntry entry) - => GetVercelProjectLink(entry).ProjectName; - - internal static string GetVercelProjectName(IResource resource) - { - if (resource.TryGetLastAnnotation(out var dockerfile)) - { - return GetVercelProjectName(new VercelDeploymentEntry(resource, dockerfile.ContextPath, dockerfile.DockerfilePath, dockerfile)); - } - - if (resource is ProjectResource project) - { - string projectPath = project.GetProjectMetadata().ProjectPath; - string sourceRoot = Path.GetDirectoryName(projectPath) - ?? throw new DistributedApplicationException($"Project resource '{resource.Name}' has project path '{projectPath}' without a containing directory."); - return GetVercelProjectName(new VercelDeploymentEntry(resource, sourceRoot)); - } - - throw new DistributedApplicationException($"Resource '{resource.Name}' targets Vercel but is not an Aspire image build resource. Use a .NET project, a workload integration that publishes Dockerfile metadata, call PublishAsDockerFile, or configure the resource with WithDockerfile, WithDockerfileFactory, or WithDockerfileBuilder."); - } - - private static VercelProjectLink GetVercelProjectLink(VercelDeploymentEntry entry) - { - if (TryReadVercelProjectLink(entry.SourceRoot, out var projectLink)) - { - return projectLink; - } - - return new(GetManagedVercelProjectName(entry), ProjectId: null); - } - - private static string GetVercelProjectOption(VercelDeploymentEntry entry) - { - var projectLink = GetVercelProjectLink(entry); - return string.IsNullOrWhiteSpace(projectLink.ProjectId) - ? projectLink.ProjectName - : projectLink.ProjectId; - } - - private static string GetManagedVercelProjectName(VercelDeploymentEntry entry) - { - if (entry.Resource.TryGetLastAnnotation(out var options)) - { - return options.ProjectName; - } - - // The production endpoint contract is project-name based, so managed names must - // be stable and Vercel-valid before deploy starts. - string sourceRoot = Path.TrimEndingDirectorySeparator(entry.SourceRoot); - string sourceRootName = Path.GetFileName(sourceRoot); - - if (TryCreateVercelProjectName(sourceRootName, out string? projectName) - || TryCreateVercelProjectName(entry.Resource.Name, out projectName)) - { - return projectName; - } - - throw new DistributedApplicationException($"Could not infer a valid Vercel project name for resource '{entry.Resource.Name}' from source root '{entry.SourceRoot}'. Rename the source directory or link the source root to an existing Vercel project."); - } - - internal static bool IsValidVercelProjectName(string projectName) - { - if (string.IsNullOrWhiteSpace(projectName) - || projectName.Length > VercelProjectNameMaxLength - || !IsLowercaseAsciiLetterOrDigit(projectName[0]) - || !IsLowercaseAsciiLetterOrDigit(projectName[^1])) - { - return false; - } - - return projectName.All(static character => - IsLowercaseAsciiLetterOrDigit(character) - || character == '-'); - } - - private static bool TryCreateVercelProjectName(string? value, [NotNullWhen(true)] out string? projectName) - { - if (string.IsNullOrWhiteSpace(value)) - { - projectName = null; - return false; - } - - var builder = new StringBuilder(value.Length); - bool previousWasSeparator = false; - - foreach (char character in value) - { - if (IsAsciiLetterOrDigit(character)) - { - builder.Append(char.ToLowerInvariant(character)); - previousWasSeparator = false; - } - else if (!previousWasSeparator && builder.Length > 0) - { - builder.Append('-'); - previousWasSeparator = true; - } - } - - projectName = builder - .ToString() - .Trim('-'); - - if (projectName.Length > VercelProjectNameMaxLength) - { - projectName = projectName[..VercelProjectNameMaxLength].Trim('-'); - } - - if (projectName.Length == 0) - { - projectName = null; - return false; - } - - return true; - } - - private static bool IsAsciiLetterOrDigit(char character) - => character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9'; - - private static bool IsLowercaseAsciiLetterOrDigit(char character) - => character is >= 'a' and <= 'z' or >= '0' and <= '9'; - - private static bool HasVercelProjectLinkFile(string sourceRoot) - => File.Exists(GetVercelProjectJsonPath(sourceRoot)); - - private static bool TryReadVercelProjectLink(string sourceRoot, [NotNullWhen(true)] out VercelProjectLink? projectLink) - { - string projectJsonPath = GetVercelProjectJsonPath(sourceRoot); - - if (File.Exists(projectJsonPath)) - { - // Vercel CLI writes linked project identity as: - // .vercel/project.json: { "projectId": "...", "orgId": "...", "projectName": "..." } - // Treat it as user/provider ownership metadata rather than regenerating a managed - // name. Destroy preserves these linked projects and only removes tracked env vars. - using var document = JsonDocument.Parse(File.ReadAllText(projectJsonPath)); - string? projectName = GetJsonStringProperty(document.RootElement, "projectName"); - - if (!string.IsNullOrWhiteSpace(projectName)) - { - projectLink = new(projectName, GetJsonStringProperty(document.RootElement, "projectId")); - return true; - } - } - - projectLink = null; - return false; - } - - private static string? GetJsonStringProperty(JsonElement element, string propertyName) - => element.TryGetProperty(propertyName, out var property) - && property.ValueKind == JsonValueKind.String - && !string.IsNullOrWhiteSpace(property.GetString()) - ? property.GetString() - : null; - - internal static bool ProjectListContainsProject(string standardOutput, string projectName) - { - try - { - using var document = JsonDocument.Parse(standardOutput); - foreach (var project in EnumerateJsonArrayOrNamedArray(document.RootElement, "projects")) - { - if (string.Equals(GetJsonStringProperty(project, "name"), projectName, StringComparison.Ordinal)) - { - return true; - } - } - - return false; - } - catch (JsonException ex) - { - throw new DistributedApplicationException("Failed to parse JSON output from 'vercel project ls'.", ex); - } - } - - internal static bool EnvironmentVariableListContainsName(string standardOutput, string name) - { - try - { - using var document = JsonDocument.Parse(standardOutput); - foreach (var environmentVariable in EnumerateJsonArrayOrNamedArray(document.RootElement, "envs")) - { - if (string.Equals(GetJsonStringProperty(environmentVariable, "key"), name, StringComparison.Ordinal) - && string.IsNullOrWhiteSpace(GetJsonStringProperty(environmentVariable, "gitBranch"))) - { - return true; - } - } - - return false; - } - catch (JsonException ex) - { - throw new DistributedApplicationException("Failed to parse JSON output from 'vercel env ls'.", ex); - } - } - - private static JsonElement.ArrayEnumerator EnumerateJsonArrayOrNamedArray(JsonElement root, string propertyName) - { - if (root.ValueKind == JsonValueKind.Array) - { - return root.EnumerateArray(); - } - - if (root.ValueKind == JsonValueKind.Object - && root.TryGetProperty(propertyName, out var array) - && array.ValueKind == JsonValueKind.Array) - { - return array.EnumerateArray(); - } - - throw new JsonException($"Expected JSON array or object property '{propertyName}'."); - } - - private static string GetVercelProjectJsonPath(string sourceRoot) - => Path.Combine(sourceRoot, ".vercel", "project.json"); - - internal static string GetDeploymentUrl(string standardOutput) - => GetDeploymentResult(standardOutput).DeploymentUrl; - - internal static VercelDeploymentResult GetDeploymentResult(string standardOutput) - { - // `vercel deploy` output has changed between CLI versions and flags. Prefer structured - // JSON when present, then fall back to the last plain HTTP(S) URL printed by the CLI. - if (TryGetJsonDeploymentResult(standardOutput) is { } jsonDeploymentResult) - { - return jsonDeploymentResult; - } - - // Older CLI versions printed the deployment URL as plain text. Keep the fallback so - // we fail only when no usable URL exists, not because formatting changed slightly. - string[] lines = standardOutput - .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - string? deploymentUrl = lines.LastOrDefault(IsHttpUrl); - if (deploymentUrl is null) - { - throw new DistributedApplicationException($"Vercel deploy output did not contain an HTTP or HTTPS deployment URL. Output: {GetTrimmedOutput(standardOutput)}"); - } - - return new(DeploymentId: null, deploymentUrl); - } - - internal static VercelDeploymentInspection GetDeploymentInspection(string standardOutput) - { - try - { - // Parse the Vercel inspect JSON shapes observed across CLI versions: - // { "readyState": "READY" } - // { "state": "READY" } - // { "deployment": { "readyState": "READY" } } - // { "deployment": { "state": "READY" } } - using var document = JsonDocument.Parse(standardOutput); - var root = document.RootElement; - string? readyState = GetJsonStringProperty(root, "readyState") - ?? GetJsonStringProperty(root, "state") - ?? (root.TryGetProperty("deployment", out var deployment) ? GetJsonStringProperty(deployment, "readyState") : null) - ?? (root.TryGetProperty("deployment", out deployment) ? GetJsonStringProperty(deployment, "state") : null); - - return new(readyState); - } - catch (JsonException ex) - { - throw new DistributedApplicationException("Failed to parse JSON output from 'vercel inspect'.", ex); - } - } - - private static VercelDeploymentResult? TryGetJsonDeploymentResult(string standardOutput) - { - if (!standardOutput.AsSpan().TrimStart().StartsWith("{", StringComparison.Ordinal)) - { - return null; - } - - try - { - using var document = JsonDocument.Parse(standardOutput); - var root = document.RootElement; - - if (TryGetDeploymentResult(root, out var deploymentResult)) - { - return deploymentResult; - } - } - catch (JsonException) - { - // Older Vercel CLI output is plain text; fall back to line-based URL extraction. - } - - return null; - } - - private static bool TryGetDeploymentResult(JsonElement root, [NotNullWhen(true)] out VercelDeploymentResult? deploymentResult) - { - // Parse the Vercel deploy JSON shapes observed from different CLI versions: - // { "deployment": { "url": "https://...", "id": "..." } } - // { "url": "https://...", "id": "..." } - // Callers fall back to line-based extraction when deploy output is plain text. - if (root.TryGetProperty("deployment", out var deployment) - && deployment.TryGetProperty("url", out var nestedUrl) - && TryGetHttpUrl(nestedUrl, out var nestedDeploymentUrl)) - { - string? deploymentId = deployment.TryGetProperty("id", out var nestedId) && nestedId.ValueKind == JsonValueKind.String - ? nestedId.GetString() - : null; - - deploymentResult = new(deploymentId, nestedDeploymentUrl); - return true; - } - - if (root.TryGetProperty("url", out var url) - && TryGetHttpUrl(url, out var rootDeploymentUrl)) - { - string? deploymentId = root.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String - ? id.GetString() - : null; - - deploymentResult = new(deploymentId, rootDeploymentUrl); - return true; - } - - deploymentResult = null; - return false; - } - - private static bool TryGetHttpUrl(JsonElement urlElement, [NotNullWhen(true)] out string? url) - { - url = urlElement.ValueKind == JsonValueKind.String - ? urlElement.GetString() - : null; - - return IsHttpUrl(url); - } - - private static bool IsHttpUrl([NotNullWhen(true)] string? url) - => Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http"; - - internal static bool TryGetVercelCliVersion(string output, [NotNullWhen(true)] out Version? version) - { - // The CLI can print banners/warnings around the version. Extract the first semantic - // x.y.z token instead of requiring a line to be exactly the version string. - var match = Regex.Match(output, @"(?\d+)\.(?\d+)\.(?\d+)(?!\d)", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); - if (!match.Success) - { - version = null; - return false; - } - - version = new( - int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture), - int.Parse(match.Groups["minor"].Value, CultureInfo.InvariantCulture), - int.Parse(match.Groups["patch"].Value, CultureInfo.InvariantCulture)); - return true; - } - - private static string GetTrimmedOutput(string output) - => string.IsNullOrWhiteSpace(output) ? "" : output.Trim(); - - private static DistributedApplicationException CreateCliException(string operation, string cliPath, VercelCliResult result) - { - string output = string.IsNullOrWhiteSpace(result.StandardError) - ? result.StandardOutput.Trim() - : result.StandardError.Trim(); - - return new DistributedApplicationException($"Failed to {operation} using '{cliPath}' (exit code {result.ExitCode}). {output}"); - } -} - -internal sealed record VercelDeploymentEntry( - IResource Resource, - string SourceRoot, - string? DockerfilePath = null, - DockerfileBuildAnnotation? Dockerfile = null, - string TempDirectory = "", - string DeployDirectory = ""); - -internal sealed record VercelDeploymentPlan(string Environment, VercelDeploymentPlanEntry[] Deployments); - -internal sealed record VercelDeploymentPlanEntry(string ResourceName, string DockerfilePath, string DeployCommand, string[] EnvironmentVariables); - -internal sealed record VercelEnvironmentConfiguration( - IReadOnlyList> DeploymentEnvironmentVariables, - IReadOnlyList> ProjectEnvironmentVariables) -{ - public static VercelEnvironmentConfiguration Empty { get; } = new([], []); - - public IEnumerable AllEnvironmentVariableNames => - DeploymentEnvironmentVariables.Select(static variable => variable.Key) - .Concat(ProjectEnvironmentVariables.Select(static variable => variable.Key)); -} - -internal sealed record VercelDeploymentResult(string? DeploymentId, string DeploymentUrl); - -internal sealed record VercelDeploymentInspection(string? ReadyState); - -internal sealed record PreviousVercelDeployment(VercelDeploymentStateEntry Entry, string ProjectEnvironment); - -internal sealed record VercelDeploymentState( - int SchemaVersion, - string Environment, - string? Scope, - string? Target, - bool Production, - VercelDeploymentStateEntry[] Deployments); - -internal sealed record VercelDeploymentStateEntry( - string ResourceName, - string ProjectName, - string? ProjectId, - string? DeploymentId, - string? DeploymentUrl, - string SourceRoot, - bool ManagedByAspire) -{ - public string? ProductionUrl { get; init; } - - public string? VcrImageDigest { get; init; } - - public int? BuildOutputApiVersion { get; init; } - - public string[] ProjectEnvironmentVariables { get; init; } = []; -} - -internal sealed record VercelImageReference(string Reference, string Digest); - -internal sealed record VercelPreparedDeploymentAnnotation( - VercelDeploymentEntry Entry, - VercelProjectLink ProjectLink, - VercelPulledProjectContext ProjectContext, - bool ManagedByAspire, - string RemoteImageName, - string RemoteImageTag, - string TaggedImageReference) : IResourceAnnotation; - -internal sealed class VercelImagePushOptionsCallbackAnnotation : IResourceAnnotation -{ } - -internal sealed record VercelProjectLink(string ProjectName, string? ProjectId); - -internal sealed record VercelPulledProject( - string ProjectName, - string? ProjectId, - string? OrgId, - string ProjectJsonContent, - string OidcToken); - -internal sealed record VercelPulledProjectContext( - VercelEnvironmentConfiguration EnvironmentConfiguration, - VercelPulledProject PulledProject, - VercelOidcClaims OidcClaims); - -internal sealed record VercelPulledProjectSettings(string ProjectName, string? ProjectId, string? OrgId); - -internal sealed record VercelOidcClaims(string? OwnerId, string? Owner, string? Project, string? ProjectId); From 15c630eabf8e1da4eb77e5b04582bcbdb7ddb311 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Thu, 2 Jul 2026 23:41:32 -0700 Subject: [PATCH 24/33] Extract Vercel deployment components Move Vercel CLI arguments, output parsing, Docker digest parsing, project naming, Build Output generation, environment mapping, deployment model validation, plan writing, and state handling into focused internal components while keeping the deployment step as the Aspire pipeline coordinator. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../VercelBuildOutputWriter.cs | 67 +++ .../VercelCliArguments.cs | 246 ++++++++++ .../VercelCliOutputParser.cs | 194 ++++++++ ...Step.Model.cs => VercelDeploymentModel.cs} | 423 +++++------------- .../VercelDeploymentPaths.cs | 7 + .../VercelDeploymentPlanWriter.cs | 165 +++++++ .../VercelDeploymentStateStore.cs | 185 ++++++++ .../VercelDeploymentStep.BuildOutput.cs | 325 +------------- .../VercelDeploymentStep.Cli.cs | 414 +---------------- .../VercelDeploymentStep.ComponentFacade.cs | 100 +++++ .../VercelDeploymentStep.Plan.cs | 139 +----- .../VercelDeploymentStep.State.cs | 210 +-------- .../VercelDeploymentStep.cs | 50 +-- .../VercelDockerImageDigestParser.cs | 85 ++++ .../VercelDotEnvParser.cs | 49 ++ ...ironment.cs => VercelEnvironmentMapper.cs} | 108 ++--- .../VercelEnvironmentResource.cs | 2 +- .../VercelFileSystem.cs | 20 + .../VercelJson.cs | 37 ++ .../VercelOidcToken.cs | 42 ++ .../VercelProjectEnvironment.cs | 24 + .../VercelProjectNameResolver.cs | 170 +++++++ .../VercelProjectSettingsReader.cs | 34 ++ .../VercelResourceBuilderExtensions.cs | 2 +- 24 files changed, 1647 insertions(+), 1451 deletions(-) create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelBuildOutputWriter.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliArguments.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliOutputParser.cs rename src/CommunityToolkit.Aspire.Hosting.Vercel/{VercelDeploymentStep.Model.cs => VercelDeploymentModel.cs} (58%) create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPaths.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPlanWriter.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStateStore.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.ComponentFacade.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDockerImageDigestParser.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDotEnvParser.cs rename src/CommunityToolkit.Aspire.Hosting.Vercel/{VercelDeploymentStep.Environment.cs => VercelEnvironmentMapper.cs} (86%) create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelFileSystem.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelJson.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelOidcToken.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectEnvironment.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectSettingsReader.cs diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelBuildOutputWriter.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelBuildOutputWriter.cs new file mode 100644 index 000000000..e10711297 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelBuildOutputWriter.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal static class VercelBuildOutputWriter +{ + private const int VercelBuildOutputApiVersion = 3; + private const string VercelDirectoryName = ".vercel"; + private const string VercelOutputDirectoryName = "output"; + private const string VercelProjectFileName = "project.json"; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + public static async Task WriteAsync( + VercelDeploymentEntry entry, + VercelPulledProject project, + string imageReference, + CancellationToken cancellationToken) + { + // Vercel Build Output API v3 expects the file-system contract documented at + // https://vercel.com/docs/build-output-api and + // https://vercel.com/docs/build-output-api/configuration: + // .vercel/project.json copied project identity from `vercel pull` + // .vercel/output/config.json routes and API version + // .vercel/output/functions/index.func/.vc-config.json + // { "runtime": "container", "handler": "@sha256:..." } + // There is intentionally no user source copy here; Aspire's build/push pipeline has + // already built the image, and Vercel deploy uploads only metadata that points at it. + string vercelDirectory = Path.Combine(entry.DeployDirectory, VercelDirectoryName); + string outputDirectory = Path.Combine(vercelDirectory, VercelOutputDirectoryName); + string functionDirectory = Path.Combine(outputDirectory, "functions", "index.func"); + Directory.CreateDirectory(functionDirectory); + + await File.WriteAllTextAsync(Path.Combine(vercelDirectory, VercelProjectFileName), project.ProjectJsonContent, cancellationToken).ConfigureAwait(false); + + var outputConfig = new JsonObject + { + ["version"] = VercelBuildOutputApiVersion, + ["routes"] = new JsonArray + { + new JsonObject + { + ["handle"] = "filesystem" + }, + new JsonObject + { + ["src"] = "/(.*)", + ["dest"] = "/index" + } + } + }; + + var functionConfig = new JsonObject + { + ["handler"] = imageReference, + ["runtime"] = "container", + ["environment"] = new JsonObject() + }; + + await File.WriteAllTextAsync(Path.Combine(outputDirectory, "config.json"), outputConfig.ToJsonString(JsonOptions), cancellationToken).ConfigureAwait(false); + await File.WriteAllTextAsync(Path.Combine(functionDirectory, ".vc-config.json"), functionConfig.ToJsonString(JsonOptions), cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliArguments.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliArguments.cs new file mode 100644 index 000000000..1d5585afc --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliArguments.cs @@ -0,0 +1,246 @@ +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal static class VercelCliArguments +{ + private const string VcrRegistry = "vcr.vercel.com"; + private const string VercelContainerServiceName = "app"; + + public static string[] BuildDeployArguments(VercelEnvironmentOptionsAnnotation options, VercelDeploymentEntry entry) + => BuildDeployArguments(options, VercelDeploymentPaths.GetDeployDirectory(entry), VercelProjectNameResolver.GetProjectOption(entry), environmentVariables: []); + + public static string[] BuildDockerInspectDigestArguments(string imageReference) + => ["buildx", "imagetools", "inspect", "--format", "{{json .Manifest}}", imageReference]; + + public static string[] BuildDestroyProjectArguments(VercelEnvironmentOptionsAnnotation options, string projectName) + { + List arguments = []; + + arguments.Add("project"); + arguments.Add("remove"); + arguments.Add(projectName); + AddOptionalScopeArgument(arguments, options); + + return [.. arguments]; + } + + public static string[] BuildListProjectEnvironmentVariablesArguments( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string targetEnvironment) + { + List arguments = []; + + arguments.Add("env"); + arguments.Add("ls"); + arguments.Add(targetEnvironment); + AddOptionalScopeArgument(arguments, options); + arguments.Add("--cwd"); + arguments.Add(projectLinkDirectory); + arguments.Add("--format=json"); + + return [.. arguments]; + } + + public static string[] BuildAddProjectArguments(VercelEnvironmentOptionsAnnotation options, string projectName) + { + List arguments = []; + + arguments.Add("project"); + arguments.Add("add"); + arguments.Add(projectName); + AddOptionalScopeArgument(arguments, options); + + return [.. arguments]; + } + + public static string[] BuildAddProjectEnvironmentVariableArguments( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string name, + string targetEnvironment) + { + List arguments = []; + + arguments.Add("env"); + arguments.Add("add"); + arguments.Add(name); + arguments.Add(targetEnvironment); + AddOptionalScopeArgument(arguments, options); + // Vercel env commands must run inside a linked project directory. Use the + // Aspire-owned scratch link instead of the source root so provider metadata + // and pulled env files never mutate the user's checkout. + arguments.Add("--cwd"); + arguments.Add(projectLinkDirectory); + arguments.Add("--yes"); + arguments.Add("--force"); + arguments.Add("--sensitive"); + + return [.. arguments]; + } + + public static string[] BuildRemoveProjectEnvironmentVariableArguments( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string name, + string targetEnvironment) + { + List arguments = []; + + arguments.Add("env"); + arguments.Add("rm"); + arguments.Add(name); + arguments.Add(targetEnvironment); + AddOptionalScopeArgument(arguments, options); + arguments.Add("--cwd"); + arguments.Add(projectLinkDirectory); + arguments.Add("--yes"); + + return [.. arguments]; + } + + public static string[] BuildLinkProjectArguments( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string projectNameOrId) + { + List arguments = []; + + arguments.Add("link"); + AddOptionalScopeArgument(arguments, options); + arguments.Add("--cwd"); + arguments.Add(projectLinkDirectory); + arguments.Add("--yes"); + arguments.Add("--project"); + arguments.Add(projectNameOrId); + + return [.. arguments]; + } + + public static string[] BuildPullProjectSettingsArguments( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string targetEnvironment) + { + List arguments = []; + + arguments.Add("pull"); + AddOptionalScopeArgument(arguments, options); + arguments.Add("--cwd"); + arguments.Add(projectLinkDirectory); + arguments.Add("--yes"); + arguments.Add("--environment"); + arguments.Add(targetEnvironment); + + return [.. arguments]; + } + + public static string[] BuildDockerLoginArguments(string username) + => ["login", VcrRegistry, "--username", username, "--password-stdin"]; + + public static string[] BuildInspectDeploymentArguments(VercelEnvironmentOptionsAnnotation options, string deploymentUrl) + { + List arguments = []; + + arguments.Add("inspect"); + arguments.Add(deploymentUrl); + AddOptionalScopeArgument(arguments, options); + arguments.Add("--wait"); + arguments.Add("--timeout"); + arguments.Add("120s"); + arguments.Add("--format=json"); + + return [.. arguments]; + } + + public static string[] BuildValidateScopeArguments(VercelEnvironmentOptionsAnnotation options) + => BuildListProjectsArguments(options); + + public static string[] BuildListProjectsArguments(VercelEnvironmentOptionsAnnotation options, string? filter = null) + { + List arguments = []; + + arguments.Add("project"); + arguments.Add("ls"); + AddOptionalScopeArgument(arguments, options); + if (!string.IsNullOrWhiteSpace(filter)) + { + arguments.Add("--filter"); + arguments.Add(filter); + } + + arguments.Add("--format=json"); + + return [.. arguments]; + } + + public static string[] BuildDeployArguments( + VercelEnvironmentOptionsAnnotation options, + string deployDirectory, + string? projectNameOrId, + IReadOnlyList> environmentVariables) + { + List arguments = []; + + arguments.Add("deploy"); + AddOptionalScopeArgument(arguments, options); + arguments.Add("--cwd"); + arguments.Add(deployDirectory); + AddOptionalProjectArgument(arguments, projectNameOrId); + arguments.Add("--prebuilt"); + arguments.Add("--yes"); + + if (options.Production) + { + arguments.Add("--prod"); + } + + if (!string.IsNullOrWhiteSpace(options.Target)) + { + arguments.Add("--target"); + arguments.Add(options.Target); + } + + foreach (var environmentVariable in environmentVariables.OrderBy(static variable => variable.Key, StringComparer.Ordinal)) + { + arguments.Add("--env"); + arguments.Add($"{environmentVariable.Key}={environmentVariable.Value}"); + } + + return [.. arguments]; + } + + public static string BuildDisplayDeployCommand( + VercelEnvironmentOptionsAnnotation options, + string resourceName, + IReadOnlyList> environmentVariables) + { + // The plan should explain the command shape without leaking concrete source roots, + // project names, or environment values that may be machine- or account-specific. + var displayEnvironmentVariables = environmentVariables + .Select(static environmentVariable => new KeyValuePair(environmentVariable.Key, "")) + .ToArray(); + + string displayImage = $"vcr.vercel.com///{VercelContainerServiceName}:"; + string displayDeployDirectory = $"<{resourceName}-build-output>"; + string displayProject = $"<{resourceName}-vercel-project>"; + return $"vercel pull --cwd <{resourceName}-vercel-project-link> --yes --environment {VercelProjectEnvironment.GetName(options)} && aspire build/push {resourceName} -> {displayImage} && docker {string.Join(" ", BuildDockerInspectDigestArguments(displayImage))} && vercel {string.Join(" ", BuildDeployArguments(options, displayDeployDirectory, displayProject, displayEnvironmentVariables))}"; + } + + private static void AddOptionalProjectArgument(List arguments, string? projectNameOrId) + { + if (!string.IsNullOrWhiteSpace(projectNameOrId)) + { + arguments.Add("--project"); + arguments.Add(projectNameOrId); + } + } + + private static void AddOptionalScopeArgument(List arguments, VercelEnvironmentOptionsAnnotation options) + { + if (!string.IsNullOrWhiteSpace(options.Scope)) + { + arguments.Add("--scope"); + arguments.Add(options.Scope); + } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliOutputParser.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliOutputParser.cs new file mode 100644 index 000000000..287826f43 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliOutputParser.cs @@ -0,0 +1,194 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal static class VercelCliOutputParser +{ + public static string GetDeploymentUrl(string standardOutput) + => GetDeploymentResult(standardOutput).DeploymentUrl; + + public static VercelDeploymentResult GetDeploymentResult(string standardOutput) + { + // `vercel deploy` output has changed between CLI versions and flags. Prefer structured + // JSON when present, then fall back to the last plain HTTP(S) URL printed by the CLI. + if (TryGetJsonDeploymentResult(standardOutput) is { } jsonDeploymentResult) + { + return jsonDeploymentResult; + } + + // Older CLI versions printed the deployment URL as plain text. Keep the fallback so + // we fail only when no usable URL exists, not because formatting changed slightly. + string[] lines = standardOutput + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + string? deploymentUrl = lines.LastOrDefault(IsHttpUrl); + if (deploymentUrl is null) + { + throw new DistributedApplicationException($"Vercel deploy output did not contain an HTTP or HTTPS deployment URL. Output: {GetTrimmedOutput(standardOutput)}"); + } + + return new(DeploymentId: null, deploymentUrl); + } + + public static VercelDeploymentInspection GetDeploymentInspection(string standardOutput) + { + try + { + // Parse the Vercel inspect JSON shapes observed across CLI versions: + // { "readyState": "READY" } + // { "state": "READY" } + // { "deployment": { "readyState": "READY" } } + // { "deployment": { "state": "READY" } } + using var document = JsonDocument.Parse(standardOutput); + var root = document.RootElement; + string? readyState = VercelJson.GetStringProperty(root, "readyState") + ?? VercelJson.GetStringProperty(root, "state") + ?? (root.TryGetProperty("deployment", out var deployment) ? VercelJson.GetStringProperty(deployment, "readyState") : null) + ?? (root.TryGetProperty("deployment", out deployment) ? VercelJson.GetStringProperty(deployment, "state") : null); + + return new(readyState); + } + catch (JsonException ex) + { + throw new DistributedApplicationException("Failed to parse JSON output from 'vercel inspect'.", ex); + } + } + + public static bool TryGetCliVersion(string output, [NotNullWhen(true)] out Version? version) + { + // The CLI can print banners/warnings around the version. Extract the first semantic + // x.y.z token instead of requiring a line to be exactly the version string. + var match = Regex.Match(output, @"(?\d+)\.(?\d+)\.(?\d+)(?!\d)", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); + if (!match.Success) + { + version = null; + return false; + } + + version = new( + int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture), + int.Parse(match.Groups["minor"].Value, CultureInfo.InvariantCulture), + int.Parse(match.Groups["patch"].Value, CultureInfo.InvariantCulture)); + return true; + } + + public static bool ProjectListContainsProject(string standardOutput, string projectName) + { + try + { + using var document = JsonDocument.Parse(standardOutput); + foreach (var project in VercelJson.EnumerateArrayOrNamedArray(document.RootElement, "projects")) + { + if (string.Equals(VercelJson.GetStringProperty(project, "name"), projectName, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + catch (JsonException ex) + { + throw new DistributedApplicationException("Failed to parse JSON output from 'vercel project ls'.", ex); + } + } + + public static bool EnvironmentVariableListContainsName(string standardOutput, string name) + { + try + { + using var document = JsonDocument.Parse(standardOutput); + foreach (var environmentVariable in VercelJson.EnumerateArrayOrNamedArray(document.RootElement, "envs")) + { + if (string.Equals(VercelJson.GetStringProperty(environmentVariable, "key"), name, StringComparison.Ordinal) + && string.IsNullOrWhiteSpace(VercelJson.GetStringProperty(environmentVariable, "gitBranch"))) + { + return true; + } + } + + return false; + } + catch (JsonException ex) + { + throw new DistributedApplicationException("Failed to parse JSON output from 'vercel env ls'.", ex); + } + } + + public static string GetTrimmedOutput(string output) + => string.IsNullOrWhiteSpace(output) ? "" : output.Trim(); + + private static VercelDeploymentResult? TryGetJsonDeploymentResult(string standardOutput) + { + if (!standardOutput.AsSpan().TrimStart().StartsWith("{", StringComparison.Ordinal)) + { + return null; + } + + try + { + using var document = JsonDocument.Parse(standardOutput); + var root = document.RootElement; + + if (TryGetDeploymentResult(root, out var deploymentResult)) + { + return deploymentResult; + } + } + catch (JsonException) + { + // Older Vercel CLI output is plain text; fall back to line-based URL extraction. + } + + return null; + } + + private static bool TryGetDeploymentResult(JsonElement root, [NotNullWhen(true)] out VercelDeploymentResult? deploymentResult) + { + // Parse the Vercel deploy JSON shapes observed from different CLI versions: + // { "deployment": { "url": "https://...", "id": "..." } } + // { "url": "https://...", "id": "..." } + // Callers fall back to line-based extraction when deploy output is plain text. + if (root.TryGetProperty("deployment", out var deployment) + && deployment.TryGetProperty("url", out var nestedUrl) + && TryGetHttpUrl(nestedUrl, out var nestedDeploymentUrl)) + { + string? deploymentId = deployment.TryGetProperty("id", out var nestedId) && nestedId.ValueKind == JsonValueKind.String + ? nestedId.GetString() + : null; + + deploymentResult = new(deploymentId, nestedDeploymentUrl); + return true; + } + + if (root.TryGetProperty("url", out var url) + && TryGetHttpUrl(url, out var rootDeploymentUrl)) + { + string? deploymentId = root.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String + ? id.GetString() + : null; + + deploymentResult = new(deploymentId, rootDeploymentUrl); + return true; + } + + deploymentResult = null; + return false; + } + + private static bool TryGetHttpUrl(JsonElement urlElement, [NotNullWhen(true)] out string? url) + { + url = urlElement.ValueKind == JsonValueKind.String + ? urlElement.GetString() + : null; + + return IsHttpUrl(url); + } + + private static bool IsHttpUrl([NotNullWhen(true)] string? url) + => Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http"; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Model.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs similarity index 58% rename from src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Model.cs rename to src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs index c45a5c756..d14d67b29 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Model.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs @@ -1,34 +1,50 @@ #pragma warning disable ASPIREPIPELINES001 -#pragma warning disable ASPIREPIPELINES002 -#pragma warning disable ASPIREPIPELINES003 #pragma warning disable ASPIREPIPELINES004 #pragma warning disable ASPIRECOMPUTE003 #pragma warning disable ASPIREPROBES001 #pragma warning disable CTASPIREVERCEL001 -using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.ApplicationModel.Docker; using Aspire.Hosting.Pipelines; using Aspire.Hosting.Publishing; +using Aspire.Hosting; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; using System.Net.Sockets; -using System.Text; using System.Text.Json; using System.Text.Json.Nodes; -using System.Text.RegularExpressions; namespace CommunityToolkit.Aspire.Hosting.Vercel; -internal static partial class VercelDeploymentStep +internal static class VercelDeploymentModel { - private static IReadOnlyDictionary GetDeploymentEntriesByResourceName(IReadOnlyList entries) + private const string VercelJsonFileName = "vercel.json"; + + private static readonly string[] VercelJsonBuildOutputUnsupportedKeys = + [ + "build", + "builds", + "buildCommand", + "devCommand", + "env", + "experimentalServices", + "experimentalServicesV2", + "framework", + "functions", + "headers", + "ignoreCommand", + "installCommand", + "outputDirectory", + "redirects", + "rewrites", + "routes", + "services" + ]; + + public static IReadOnlyDictionary GetEntriesByResourceName(IReadOnlyList entries) => entries.ToDictionary(static entry => entry.Resource.Name, StringComparer.Ordinal); - internal static IEnumerable GetDeploymentEntries(DistributedApplicationModel model, VercelEnvironmentResource environment) + public static IEnumerable GetEntries(DistributedApplicationModel model, VercelEnvironmentResource environment) { var computeEnvironments = model.Resources.OfType().Take(2).ToArray(); // Match Aspire's single-environment convention only when Vercel is the sole compute @@ -62,16 +78,7 @@ internal static IEnumerable GetDeploymentEntries(Distribu } } - private static bool IsTargetedToEnvironment(IResource resource, VercelEnvironmentResource environment, bool allowImplicitTargeting) - { - var computeEnvironment = resource.GetComputeEnvironment(); - // Match Aspire's single-environment convention: when Vercel is the only compute - // environment, image-build workloads implicitly target it. - return ReferenceEquals(computeEnvironment, environment) - || (computeEnvironment is null && allowImplicitTargeting); - } - - private static void ValidateEntries(IReadOnlyList entries) + public static void ValidateEntries(IReadOnlyList entries) { // Keep unsupported Vercel-preview cases in validation so publish/prereq/deploy fail // before mutating provider projects. Each failure names the Aspire concept that @@ -96,10 +103,90 @@ private static void ValidateEntries(IReadOnlyList entries ValidateUnsupportedResourceModel(entry); } - ValidateUniqueVercelProjectNames(entries); + ValidateUniqueProjectNames(entries); } - private static void ValidateUniqueVercelProjectNames(IReadOnlyList entries) + public static async Task PrepareEntryAsync(PipelineStepContext context, VercelDeploymentEntry entry) + { + await ValidateVercelJsonAsync(entry.Resource, entry.SourceRoot, context.CancellationToken).ConfigureAwait(false); + + var outputService = context.Services.GetRequiredService(); + string tempDirectory = outputService.GetTempDirectory(entry.Resource); + string deployDirectory = Path.Combine(tempDirectory, "vercel-build-output"); + Directory.CreateDirectory(tempDirectory); + if (Directory.Exists(deployDirectory)) + { + Directory.Delete(deployDirectory, recursive: true); + } + + // This is an output-only Build Output API root, not a staged copy of the source. + // Aspire's built-in build/push steps read the real source/project directly; Vercel + // deploy receives only generated metadata that points at the digest already pushed to VCR. + Directory.CreateDirectory(deployDirectory); + + return entry with + { + TempDirectory = tempDirectory, + DeployDirectory = deployDirectory + }; + } + + public static async Task ValidateVercelJsonAsync( + IResource resource, + string sourceRoot, + CancellationToken cancellationToken) + { + string vercelJsonPath = Path.Combine(sourceRoot, VercelJsonFileName); + if (!File.Exists(vercelJsonPath)) + { + return; + } + + JsonObject root; + try + { + root = JsonNode.Parse(await File.ReadAllTextAsync(vercelJsonPath, cancellationToken).ConfigureAwait(false)) as JsonObject + ?? throw new DistributedApplicationException($"Resource '{resource.Name}' source root contains '{VercelJsonFileName}', but it is not a JSON object."); + } + catch (JsonException ex) + { + throw new DistributedApplicationException($"Resource '{resource.Name}' source root contains invalid '{VercelJsonFileName}'.", ex); + } + + var unsupportedKey = VercelJsonBuildOutputUnsupportedKeys.FirstOrDefault(root.ContainsKey); + if (unsupportedKey is not null) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' source root contains '{VercelJsonFileName}' with top-level '{unsupportedKey}', but the Aspire Vercel integration owns the generated Build Output API container function and catch-all routing configuration. Move that setting into the Dockerfile, AppHost environment variables, or Vercel project settings before deploying with the Aspire Vercel integration."); + } + } + + public static string GetDisplayDockerfilePath(VercelDeploymentEntry entry) + => entry.Dockerfile is null + ? "" + : entry.Dockerfile.DockerfileFactory is null + ? Path.GetRelativePath(entry.SourceRoot, entry.DockerfilePath!) + : ""; + + public static bool IsHttpEndpoint(EndpointAnnotation endpoint) + // URI scheme alone is not enough: Aspire can model a TCP transport with an HTTP + // URI scheme. Vercel's container ingress here is HTTP-family traffic over TCP. + => endpoint.Protocol == ProtocolType.Tcp + && (string.Equals(endpoint.UriScheme, "http", StringComparison.OrdinalIgnoreCase) + || string.Equals(endpoint.UriScheme, "https", StringComparison.OrdinalIgnoreCase)) + && (string.Equals(endpoint.Transport, "http", StringComparison.OrdinalIgnoreCase) + || string.Equals(endpoint.Transport, "http2", StringComparison.OrdinalIgnoreCase)); + + private static bool IsTargetedToEnvironment(IResource resource, VercelEnvironmentResource environment, bool allowImplicitTargeting) + { + var computeEnvironment = resource.GetComputeEnvironment(); + // Match Aspire's single-environment convention: when Vercel is the only compute + // environment, image-build workloads implicitly target it. + return ReferenceEquals(computeEnvironment, environment) + || (computeEnvironment is null && allowImplicitTargeting); + } + + private static void ValidateUniqueProjectNames(IReadOnlyList entries) { // Production endpoint references use https://{projectName}.vercel.app. If two // resources resolve to the same Vercel project, endpoint references and destroy @@ -108,8 +195,7 @@ private static void ValidateUniqueVercelProjectNames(IReadOnlyList new { Entry = entry, - ProjectLink = GetVercelProjectLink(entry), - Linked = HasVercelProjectLinkFile(entry.SourceRoot) + ProjectLink = VercelProjectNameResolver.GetProjectLink(entry) }) .GroupBy(item => item.ProjectLink.ProjectName, StringComparer.Ordinal) .Where(group => group.Count() > 1) @@ -224,294 +310,11 @@ private static void ValidateEndpointModel(VercelDeploymentEntry entry) private static void ValidateProjectName(VercelDeploymentEntry entry) { - if (HasVercelProjectLinkFile(entry.SourceRoot)) - { - return; - } - - _ = GetVercelProjectName(entry); - } - - private static async Task PrepareDeploymentEntryAsync(PipelineStepContext context, VercelDeploymentEntry entry) - { - await ValidateVercelJsonAsync(entry.Resource, entry.SourceRoot, context.CancellationToken).ConfigureAwait(false); - - var outputService = context.Services.GetRequiredService(); - string tempDirectory = outputService.GetTempDirectory(entry.Resource); - string deployDirectory = Path.Combine(tempDirectory, "vercel-build-output"); - Directory.CreateDirectory(tempDirectory); - if (Directory.Exists(deployDirectory)) - { - Directory.Delete(deployDirectory, recursive: true); - } - - // This is an output-only Build Output API root, not a staged copy of the source. - // Aspire's built-in build/push steps read the real source/project directly; Vercel - // deploy receives only generated metadata that points at the digest already pushed to VCR. - Directory.CreateDirectory(deployDirectory); - - return entry with - { - TempDirectory = tempDirectory, - DeployDirectory = deployDirectory - }; - } - - private static async Task ValidateVercelJsonAsync( - IResource resource, - string sourceRoot, - CancellationToken cancellationToken) - { - string vercelJsonPath = Path.Combine(sourceRoot, VercelJsonFileName); - if (!File.Exists(vercelJsonPath)) + if (VercelProjectNameResolver.HasProjectLinkFile(entry.SourceRoot)) { return; } - JsonObject root; - try - { - root = JsonNode.Parse(await File.ReadAllTextAsync(vercelJsonPath, cancellationToken).ConfigureAwait(false)) as JsonObject - ?? throw new DistributedApplicationException($"Resource '{resource.Name}' source root contains '{VercelJsonFileName}', but it is not a JSON object."); - } - catch (JsonException ex) - { - throw new DistributedApplicationException($"Resource '{resource.Name}' source root contains invalid '{VercelJsonFileName}'.", ex); - } - - var unsupportedKey = VercelJsonBuildOutputUnsupportedKeys.FirstOrDefault(root.ContainsKey); - if (unsupportedKey is not null) - { - throw new DistributedApplicationException( - $"Resource '{resource.Name}' source root contains '{VercelJsonFileName}' with top-level '{unsupportedKey}', but the Aspire Vercel integration owns the generated Build Output API container function and catch-all routing configuration. Move that setting into the Dockerfile, AppHost environment variables, or Vercel project settings before deploying with the Aspire Vercel integration."); - } + _ = VercelProjectNameResolver.GetProjectName(entry); } - - private static string GetDisplayDockerfilePath(VercelDeploymentEntry entry) - => entry.Dockerfile is null - ? "" - : entry.Dockerfile.DockerfileFactory is null - ? Path.GetRelativePath(entry.SourceRoot, entry.DockerfilePath!) - : ""; - - internal static string GetVercelProjectName(VercelDeploymentEntry entry) - => GetVercelProjectLink(entry).ProjectName; - - internal static string GetVercelProjectName(IResource resource) - { - if (resource.TryGetLastAnnotation(out var dockerfile)) - { - return GetVercelProjectName(new VercelDeploymentEntry(resource, dockerfile.ContextPath, dockerfile.DockerfilePath, dockerfile)); - } - - if (resource is ProjectResource project) - { - string projectPath = project.GetProjectMetadata().ProjectPath; - string sourceRoot = Path.GetDirectoryName(projectPath) - ?? throw new DistributedApplicationException($"Project resource '{resource.Name}' has project path '{projectPath}' without a containing directory."); - return GetVercelProjectName(new VercelDeploymentEntry(resource, sourceRoot)); - } - - throw new DistributedApplicationException($"Resource '{resource.Name}' targets Vercel but is not an Aspire image build resource. Use a .NET project, a workload integration that publishes Dockerfile metadata, call PublishAsDockerFile, or configure the resource with WithDockerfile, WithDockerfileFactory, or WithDockerfileBuilder."); - } - - private static VercelProjectLink GetVercelProjectLink(VercelDeploymentEntry entry) - { - if (TryReadVercelProjectLink(entry.SourceRoot, out var projectLink)) - { - return projectLink; - } - - return new(GetManagedVercelProjectName(entry), ProjectId: null); - } - - private static string GetVercelProjectOption(VercelDeploymentEntry entry) - { - var projectLink = GetVercelProjectLink(entry); - return string.IsNullOrWhiteSpace(projectLink.ProjectId) - ? projectLink.ProjectName - : projectLink.ProjectId; - } - - private static string GetManagedVercelProjectName(VercelDeploymentEntry entry) - { - if (entry.Resource.TryGetLastAnnotation(out var options)) - { - return options.ProjectName; - } - - // The production endpoint contract is project-name based, so managed names must - // be stable and Vercel-valid before deploy starts. - string sourceRoot = Path.TrimEndingDirectorySeparator(entry.SourceRoot); - string sourceRootName = Path.GetFileName(sourceRoot); - - if (TryCreateVercelProjectName(sourceRootName, out string? projectName) - || TryCreateVercelProjectName(entry.Resource.Name, out projectName)) - { - return projectName; - } - - throw new DistributedApplicationException($"Could not infer a valid Vercel project name for resource '{entry.Resource.Name}' from source root '{entry.SourceRoot}'. Rename the source directory or link the source root to an existing Vercel project."); - } - - internal static bool IsValidVercelProjectName(string projectName) - { - if (string.IsNullOrWhiteSpace(projectName) - || projectName.Length > VercelProjectNameMaxLength - || !IsLowercaseAsciiLetterOrDigit(projectName[0]) - || !IsLowercaseAsciiLetterOrDigit(projectName[^1])) - { - return false; - } - - return projectName.All(static character => - IsLowercaseAsciiLetterOrDigit(character) - || character == '-'); - } - - private static bool TryCreateVercelProjectName(string? value, [NotNullWhen(true)] out string? projectName) - { - if (string.IsNullOrWhiteSpace(value)) - { - projectName = null; - return false; - } - - var builder = new StringBuilder(value.Length); - bool previousWasSeparator = false; - - foreach (char character in value) - { - if (IsAsciiLetterOrDigit(character)) - { - builder.Append(char.ToLowerInvariant(character)); - previousWasSeparator = false; - } - else if (!previousWasSeparator && builder.Length > 0) - { - builder.Append('-'); - previousWasSeparator = true; - } - } - - projectName = builder - .ToString() - .Trim('-'); - - if (projectName.Length > VercelProjectNameMaxLength) - { - projectName = projectName[..VercelProjectNameMaxLength].Trim('-'); - } - - if (projectName.Length == 0) - { - projectName = null; - return false; - } - - return true; - } - - private static bool IsAsciiLetterOrDigit(char character) - => character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9'; - - private static bool IsLowercaseAsciiLetterOrDigit(char character) - => character is >= 'a' and <= 'z' or >= '0' and <= '9'; - - private static bool HasVercelProjectLinkFile(string sourceRoot) - => File.Exists(GetVercelProjectJsonPath(sourceRoot)); - - private static bool TryReadVercelProjectLink(string sourceRoot, [NotNullWhen(true)] out VercelProjectLink? projectLink) - { - string projectJsonPath = GetVercelProjectJsonPath(sourceRoot); - - if (File.Exists(projectJsonPath)) - { - // Vercel CLI writes linked project identity as: - // .vercel/project.json: { "projectId": "...", "orgId": "...", "projectName": "..." } - // Treat it as user/provider ownership metadata rather than regenerating a managed - // name. Destroy preserves these linked projects and only removes tracked env vars. - using var document = JsonDocument.Parse(File.ReadAllText(projectJsonPath)); - string? projectName = GetJsonStringProperty(document.RootElement, "projectName"); - - if (!string.IsNullOrWhiteSpace(projectName)) - { - projectLink = new(projectName, GetJsonStringProperty(document.RootElement, "projectId")); - return true; - } - } - - projectLink = null; - return false; - } - - private static string? GetJsonStringProperty(JsonElement element, string propertyName) - => element.TryGetProperty(propertyName, out var property) - && property.ValueKind == JsonValueKind.String - && !string.IsNullOrWhiteSpace(property.GetString()) - ? property.GetString() - : null; - - internal static bool ProjectListContainsProject(string standardOutput, string projectName) - { - try - { - using var document = JsonDocument.Parse(standardOutput); - foreach (var project in EnumerateJsonArrayOrNamedArray(document.RootElement, "projects")) - { - if (string.Equals(GetJsonStringProperty(project, "name"), projectName, StringComparison.Ordinal)) - { - return true; - } - } - - return false; - } - catch (JsonException ex) - { - throw new DistributedApplicationException("Failed to parse JSON output from 'vercel project ls'.", ex); - } - } - - internal static bool EnvironmentVariableListContainsName(string standardOutput, string name) - { - try - { - using var document = JsonDocument.Parse(standardOutput); - foreach (var environmentVariable in EnumerateJsonArrayOrNamedArray(document.RootElement, "envs")) - { - if (string.Equals(GetJsonStringProperty(environmentVariable, "key"), name, StringComparison.Ordinal) - && string.IsNullOrWhiteSpace(GetJsonStringProperty(environmentVariable, "gitBranch"))) - { - return true; - } - } - - return false; - } - catch (JsonException ex) - { - throw new DistributedApplicationException("Failed to parse JSON output from 'vercel env ls'.", ex); - } - } - - private static JsonElement.ArrayEnumerator EnumerateJsonArrayOrNamedArray(JsonElement root, string propertyName) - { - if (root.ValueKind == JsonValueKind.Array) - { - return root.EnumerateArray(); - } - - if (root.ValueKind == JsonValueKind.Object - && root.TryGetProperty(propertyName, out var array) - && array.ValueKind == JsonValueKind.Array) - { - return array.EnumerateArray(); - } - - throw new JsonException($"Expected JSON array or object property '{propertyName}'."); - } - - private static string GetVercelProjectJsonPath(string sourceRoot) - => Path.Combine(sourceRoot, ".vercel", "project.json"); } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPaths.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPaths.cs new file mode 100644 index 000000000..c1caf1475 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPaths.cs @@ -0,0 +1,7 @@ +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal static class VercelDeploymentPaths +{ + public static string GetDeployDirectory(VercelDeploymentEntry entry) + => string.IsNullOrWhiteSpace(entry.DeployDirectory) ? entry.SourceRoot : entry.DeployDirectory; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPlanWriter.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPlanWriter.cs new file mode 100644 index 000000000..c06a1df09 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPlanWriter.cs @@ -0,0 +1,165 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Publishing; +using Microsoft.Extensions.Logging; +using System.Text.Json; +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal static class VercelDeploymentPlanWriter +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + public static async Task WriteAsync( + DistributedApplicationModel model, + VercelEnvironmentResource environment, + string outputDirectory, + CancellationToken cancellationToken) + => await WriteAsync( + executionContext: null, + logger: null, + model, + environment, + outputDirectory, + cancellationToken).ConfigureAwait(false); + + public static async Task WriteAsync( + DistributedApplicationExecutionContext? executionContext, + ILogger? logger, + DistributedApplicationModel model, + VercelEnvironmentResource environment, + string outputDirectory, + CancellationToken cancellationToken) + { + var entries = VercelDeploymentModel.GetEntries(model, environment).ToList(); + VercelDeploymentModel.ValidateEntries(entries); + + // Publish is a reviewable handoff, not a dry-run deploy. Keep it deterministic: + // show commands, Dockerfile paths, and env var names without resolving secrets or + // depending on mutable Vercel state. + Directory.CreateDirectory(outputDirectory); + var options = environment.GetVercelOptions(); + + var plan = new VercelDeploymentPlan( + environment.Name, + await CreateEntriesAsync( + executionContext, + logger, + options, + entries, + cancellationToken).ConfigureAwait(false)); + + string planPath = Path.Combine(outputDirectory, VercelDeploymentStep.DeploymentPlanFileName); + await using FileStream stream = File.Create(planPath); + await JsonSerializer.SerializeAsync(stream, plan, JsonOptions, cancellationToken).ConfigureAwait(false); + + return planPath; + } + + public static async Task BuildDeployArgumentsAsync( + DistributedApplicationExecutionContext executionContext, + ILogger logger, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry, + CancellationToken cancellationToken) + => await BuildDeployArgumentsAsync( + executionContext, + logger, + options, + entry, + [entry], + cancellationToken).ConfigureAwait(false); + + public static async Task BuildDeployArgumentsAsync( + DistributedApplicationExecutionContext executionContext, + ILogger logger, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry, + IReadOnlyList entries, + CancellationToken cancellationToken) + { + // Publish plans must be useful without Vercel credentials or secret resolution. + // Secret-bearing values are reduced to names/placeholders here; deploy resolves them + // only when they are sent to Vercel's project env store over stdin. + var entriesByResourceName = VercelDeploymentModel.GetEntriesByResourceName(entries); + var environmentConfiguration = await GetEnvironmentConfigurationAsync( + executionContext, + logger, + options, + entry, + entriesByResourceName, + resolveProjectEnvironmentVariableValues: false, + cancellationToken).ConfigureAwait(false); + + return VercelCliArguments.BuildDeployArguments(options, VercelDeploymentPaths.GetDeployDirectory(entry), VercelProjectNameResolver.GetProjectOption(entry), environmentConfiguration.DeploymentEnvironmentVariables); + } + + public static async Task GetEnvironmentConfigurationAsync( + DistributedApplicationExecutionContext executionContext, + ILogger logger, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry, + IReadOnlyDictionary entriesByResourceName, + bool resolveProjectEnvironmentVariableValues, + CancellationToken cancellationToken) + { + var executionConfiguration = await ExecutionConfigurationBuilder + .Create(entry.Resource) + .WithEnvironmentVariablesConfig() + .WithArgumentsConfig() + .BuildAsync(executionContext, logger, cancellationToken) + .ConfigureAwait(false); + + if (executionConfiguration.Exception is not null) + { + throw new DistributedApplicationException($"Failed to process deployment configuration for resource '{entry.Resource.Name}'.", executionConfiguration.Exception); + } + + VercelEnvironmentMapper.ValidateUnsupportedRuntimeConfiguration(entry.Resource, executionConfiguration); + + var environmentVariables = await VercelEnvironmentMapper.GetConfigurationAsync( + entry.Resource, + options, + executionConfiguration, + entriesByResourceName, + resolveProjectEnvironmentVariableValues, + cancellationToken).ConfigureAwait(false); + + return environmentVariables; + } + + private static async Task CreateEntriesAsync( + DistributedApplicationExecutionContext? executionContext, + ILogger? logger, + VercelEnvironmentOptionsAnnotation options, + IReadOnlyList entries, + CancellationToken cancellationToken) + { + List planEntries = []; + var entriesByResourceName = VercelDeploymentModel.GetEntriesByResourceName(entries); + + foreach (var entry in entries) + { + // When execution context is available, include the same target-native env names + // deploy will use. Values stay redacted because publish output is committed or + // handed off more often than deploy logs. + var environmentConfiguration = executionContext is null || logger is null + ? VercelEnvironmentConfiguration.Empty + : await GetEnvironmentConfigurationAsync(executionContext, logger, options, entry, entriesByResourceName, resolveProjectEnvironmentVariableValues: false, cancellationToken).ConfigureAwait(false); + + planEntries.Add(new( + entry.Resource.Name, + VercelDeploymentModel.GetDisplayDockerfilePath(entry), + VercelCliArguments.BuildDisplayDeployCommand(options, entry.Resource.Name, environmentConfiguration.DeploymentEnvironmentVariables), + [.. environmentConfiguration.AllEnvironmentVariableNames.Order(StringComparer.Ordinal)])); + } + + return [.. planEntries]; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStateStore.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStateStore.cs new file mode 100644 index 000000000..b1db3e9b0 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStateStore.cs @@ -0,0 +1,185 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES002 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; +using Microsoft.Extensions.DependencyInjection; +using Aspire.Hosting; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal static class VercelDeploymentStateStore +{ + private const string StateSectionNamePrefix = "communitytoolkit.vercel."; + private const int DeploymentStateSchemaVersion = 1; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + public static async Task ValidateExistingAsync( + PipelineStepContext context, + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options) + { + var stateManager = context.Services.GetRequiredService(); + var stateSection = await stateManager.AcquireSectionAsync(GetSectionName(environment), context.CancellationToken).ConfigureAwait(false); + var existingState = Read(stateSection); + if (existingState is not null) + { + Validate(environment, options, existingState); + } + } + + public static async Task GetPreviousAsync( + PipelineStepContext context, + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + string resourceName, + string projectName) + { + var stateManager = context.Services.GetRequiredService(); + var stateSection = await stateManager.AcquireSectionAsync(GetSectionName(environment), context.CancellationToken).ConfigureAwait(false); + var existingState = Read(stateSection); + if (existingState is null) + { + return null; + } + + Validate(environment, options, existingState); + var entry = existingState.Deployments.FirstOrDefault(deployment => + string.Equals(deployment.ResourceName, resourceName, StringComparison.Ordinal) + && string.Equals(deployment.ProjectName, projectName, StringComparison.Ordinal)); + + return entry is null + ? null + : new(entry, VercelProjectEnvironment.GetName(existingState)); + } + + public static async Task SaveEntryAsync( + PipelineStepContext context, + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentStateEntry deployment) + { + var stateManager = context.Services.GetRequiredService(); + var stateSection = await stateManager.AcquireSectionAsync(GetSectionName(environment), context.CancellationToken).ConfigureAwait(false); + var existingState = Read(stateSection); + var state = existingState is null + ? Create(environment, options, [deployment]) + : Merge(environment, options, existingState, deployment); + + stateSection.SetValue(JsonSerializer.Serialize(state, JsonOptions)); + + await stateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); + } + + public static VercelDeploymentState? Read(DeploymentStateSection stateSection) + { + // DeploymentStateSection storage shape has changed across Aspire builds. Accept the + // known wrappers so destroy can still clean up projects created by an older CLI. + if (stateSection.Data.TryGetPropertyValue("value", out JsonNode? value) + && value is not null) + { + return Deserialize(value); + } + + value = stateSection.Data.FirstOrDefault().Value; + if (value is not null) + { + return Deserialize(value); + } + + if (stateSection.Data.ContainsKey("schemaVersion")) + { + return stateSection.Data.Deserialize(JsonOptions); + } + + return null; + } + + public static void Validate( + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentState state) + { + if (state.SchemaVersion != DeploymentStateSchemaVersion) + { + throw new DistributedApplicationException($"Vercel deployment state for environment '{environment.Name}' uses unsupported schema version '{state.SchemaVersion}'. Redeploy the environment before destroying it."); + } + + if (!string.Equals(state.Environment, environment.Name, StringComparison.Ordinal)) + { + throw new DistributedApplicationException($"Vercel deployment state for environment '{state.Environment}' cannot be used to destroy environment '{environment.Name}'."); + } + + string? configuredScope = NormalizeScope(options.Scope); + if (!string.Equals(state.Scope, configuredScope, StringComparison.Ordinal)) + { + string stateScope = string.IsNullOrWhiteSpace(state.Scope) ? "" : state.Scope; + string requestedScope = string.IsNullOrWhiteSpace(configuredScope) ? "" : configuredScope; + throw new DistributedApplicationException($"Vercel deployment state for environment '{environment.Name}' was created for scope '{stateScope}', but destroy is configured for scope '{requestedScope}'. Use the same Vercel scope that created the deployment state."); + } + } + + public static VercelDeploymentState RemoveManagedProject(VercelDeploymentState state, string projectName) + => state with + { + Deployments = state.Deployments + .Where(deployment => !deployment.ManagedByAspire || !string.Equals(deployment.ProjectName, projectName, StringComparison.Ordinal)) + .ToArray() + }; + + public static string GetSectionName(VercelEnvironmentResource environment) => $"{StateSectionNamePrefix}{environment.Name}"; + + public static string? GetProductionUrl(VercelEnvironmentOptionsAnnotation options, string projectName) + => options.Production ? $"https://{projectName}.vercel.app" : null; + + private static VercelDeploymentState Create( + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentStateEntry[] deployments) + => new( + DeploymentStateSchemaVersion, + environment.Name, + NormalizeScope(options.Scope), + NormalizeTarget(options.Target), + options.Production, + deployments); + + private static VercelDeploymentState Merge( + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentState existingState, + VercelDeploymentStateEntry deployment) + { + Validate(environment, options, existingState); + + return Create( + environment, + options, + [ + .. existingState.Deployments.Where(existing => + !string.Equals(existing.ResourceName, deployment.ResourceName, StringComparison.Ordinal) + || !string.Equals(existing.ProjectName, deployment.ProjectName, StringComparison.Ordinal)), + deployment + ]); + } + + private static VercelDeploymentState? Deserialize(JsonNode value) + { + return value.GetValueKind() == JsonValueKind.String + ? JsonSerializer.Deserialize(value.GetValue(), JsonOptions) + : value.Deserialize(JsonOptions); + } + + private static string? NormalizeScope(string? scope) + => string.IsNullOrWhiteSpace(scope) ? null : scope; + + private static string? NormalizeTarget(string? target) + => string.IsNullOrWhiteSpace(target) ? null : target; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.BuildOutput.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.BuildOutput.cs index 1de772a6f..7a03e662b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.BuildOutput.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.BuildOutput.cs @@ -1,25 +1,9 @@ #pragma warning disable ASPIREPIPELINES001 -#pragma warning disable ASPIREPIPELINES002 -#pragma warning disable ASPIREPIPELINES003 #pragma warning disable ASPIREPIPELINES004 -#pragma warning disable ASPIRECOMPUTE003 -#pragma warning disable ASPIREPROBES001 -#pragma warning disable CTASPIREVERCEL001 using Aspire.Hosting; -using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.ApplicationModel.Docker; using Aspire.Hosting.Pipelines; -using Aspire.Hosting.Publishing; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Net.Sockets; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; namespace CommunityToolkit.Aspire.Hosting.Vercel; @@ -33,18 +17,14 @@ private static async Task PrepareProjectEnvironmentDirectoryAsync( { var outputService = context.Services.GetRequiredService(); string projectLinkDirectory = Path.Combine(outputService.GetTempDirectory(entry.Resource), ".vercel-project"); - if (Directory.Exists(projectLinkDirectory)) - { - Directory.Delete(projectLinkDirectory, recursive: true); - } - + VercelFileSystem.DeleteDirectoryIfExists(projectLinkDirectory); Directory.CreateDirectory(projectLinkDirectory); // `vercel env add` is project-scoped but intentionally does not accept --project. // See https://vercel.com/docs/cli/env and https://vercel.com/docs/cli/link. // Link a scratch directory instead of the source root so secret configuration can use // the CLI's native project lookup without writing .vercel metadata into user code. - string[] linkArguments = BuildLinkProjectArguments(options, projectLinkDirectory, GetVercelProjectOption(entry)); + string[] linkArguments = VercelCliArguments.BuildLinkProjectArguments(options, projectLinkDirectory, VercelProjectNameResolver.GetProjectOption(entry)); var result = await runner.RunAsync(VercelCliFileName, linkArguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); if (!result.Succeeded) { @@ -64,8 +44,8 @@ private static async Task PullProjectSettingsAsync( // `vercel pull` is the documented way to materialize project settings and environment // files under `.vercel/`; VCR authentication depends on the pulled VERCEL_OIDC_TOKEN. // See https://vercel.com/docs/cli/pull and https://vercel.com/docs/container-registry. - string targetEnvironment = GetVercelProjectEnvironmentName(options); - string[] arguments = BuildPullProjectSettingsArguments(options, projectLinkDirectory, targetEnvironment); + string targetEnvironment = VercelProjectEnvironment.GetName(options); + string[] arguments = VercelCliArguments.BuildPullProjectSettingsArguments(options, projectLinkDirectory, targetEnvironment); var result = await runner.RunAsync(VercelCliFileName, arguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); if (!result.Succeeded) { @@ -86,7 +66,7 @@ private static async Task PullProjectSettingsAsync( throw new DistributedApplicationException($"Vercel pull did not write expected environment file '{environmentPath}' for resource '{entry.Resource.Name}'."); } - var environmentVariables = ParseDotEnvFile(await File.ReadAllLinesAsync(environmentPath, context.CancellationToken).ConfigureAwait(false)); + var environmentVariables = VercelDotEnvParser.Parse(await File.ReadAllLinesAsync(environmentPath, context.CancellationToken).ConfigureAwait(false)); if (!environmentVariables.TryGetValue(VercelOidcTokenEnvironmentVariable, out string? oidcToken) || string.IsNullOrWhiteSpace(oidcToken)) { @@ -94,13 +74,13 @@ private static async Task PullProjectSettingsAsync( } string projectJsonContent = await File.ReadAllTextAsync(projectJsonPath, context.CancellationToken).ConfigureAwait(false); - var project = ReadVercelProjectSettings(projectJsonPath, projectJsonContent); + var project = VercelProjectSettingsReader.Read(projectJsonPath, projectJsonContent); // `vercel pull` materializes project secrets next to the scratch link so local // builders can read them. This integration only needs the short-lived OIDC token // and project metadata; delete the env files before creating deploy artifacts. - DeleteIfExists(environmentPath); - DeleteIfExists(Path.Combine(projectLinkDirectory, ".env.local")); + VercelFileSystem.DeleteFileIfExists(environmentPath); + VercelFileSystem.DeleteFileIfExists(Path.Combine(projectLinkDirectory, ".env.local")); return new(project.ProjectName, project.ProjectId, project.OrgId, projectJsonContent, oidcToken); } @@ -126,7 +106,7 @@ internal static async Task LoginToVcrAsync( // VCR supports Docker-compatible tooling at vcr.vercel.com. This login uses the // Vercel-issued OIDC token pulled for the linked project. // See https://vercel.com/docs/container-registry. - string[] arguments = BuildDockerLoginArguments(claims.OwnerId); + string[] arguments = VercelCliArguments.BuildDockerLoginArguments(claims.OwnerId); var result = await runner.RunAsync(DockerCliFileName, arguments, workingDirectory: null, cancellationToken, standardInput: oidcToken).ConfigureAwait(false); if (!result.Succeeded) { @@ -134,269 +114,6 @@ internal static async Task LoginToVcrAsync( } } - internal static async Task WriteBuildOutputAsync( - VercelDeploymentEntry entry, - VercelPulledProject project, - string imageReference, - CancellationToken cancellationToken) - { - // Vercel Build Output API v3 expects the file-system contract documented at - // https://vercel.com/docs/build-output-api and - // https://vercel.com/docs/build-output-api/configuration: - // .vercel/project.json copied project identity from `vercel pull` - // .vercel/output/config.json routes and API version - // .vercel/output/functions/index.func/.vc-config.json - // { "runtime": "container", "handler": "@sha256:..." } - // There is intentionally no user source copy here; Aspire's build/push pipeline has - // already built the image, and Vercel deploy uploads only metadata that points at it. - string vercelDirectory = Path.Combine(entry.DeployDirectory, VercelDirectoryName); - string outputDirectory = Path.Combine(vercelDirectory, VercelOutputDirectoryName); - string functionDirectory = Path.Combine(outputDirectory, "functions", "index.func"); - Directory.CreateDirectory(functionDirectory); - - await File.WriteAllTextAsync(Path.Combine(vercelDirectory, VercelProjectFileName), project.ProjectJsonContent, cancellationToken).ConfigureAwait(false); - - var outputConfig = new JsonObject - { - ["version"] = VercelBuildOutputApiVersion, - ["routes"] = new JsonArray - { - new JsonObject - { - ["handle"] = "filesystem" - }, - new JsonObject - { - ["src"] = "/(.*)", - ["dest"] = "/index" - } - } - }; - - var functionConfig = new JsonObject - { - ["handler"] = imageReference, - ["runtime"] = "container", - ["environment"] = new JsonObject() - }; - - await File.WriteAllTextAsync(Path.Combine(outputDirectory, "config.json"), outputConfig.ToJsonString(JsonOptions), cancellationToken).ConfigureAwait(false); - await File.WriteAllTextAsync(Path.Combine(functionDirectory, ".vc-config.json"), functionConfig.ToJsonString(JsonOptions), cancellationToken).ConfigureAwait(false); - } - - private static VercelPulledProjectSettings ReadVercelProjectSettings(string projectJsonPath, string projectJsonContent) - { - try - { - // `vercel pull` writes `.vercel/project.json`. Only project identity fields are - // needed: they select the linked provider project and are safe to persist in state. - using var document = JsonDocument.Parse(projectJsonContent); - var root = document.RootElement; - - string projectName = root.TryGetProperty("projectName", out var projectNameElement) && projectNameElement.ValueKind == JsonValueKind.String - ? projectNameElement.GetString() ?? string.Empty - : string.Empty; - string? projectId = root.TryGetProperty("projectId", out var projectIdElement) && projectIdElement.ValueKind == JsonValueKind.String - ? projectIdElement.GetString() - : null; - string? orgId = root.TryGetProperty("orgId", out var orgIdElement) && orgIdElement.ValueKind == JsonValueKind.String - ? orgIdElement.GetString() - : null; - - return new(projectName, projectId, orgId); - } - catch (JsonException ex) - { - throw new DistributedApplicationException($"Vercel project settings file '{projectJsonPath}' is invalid JSON.", ex); - } - } - - internal static string GetDockerImageDigest(string output) - { - string trimmed = output.Trim(); - if (trimmed.StartsWith("\"", StringComparison.Ordinal)) - { - try - { - // `docker buildx imagetools inspect --format '{{json .Digest}}'` style output - // is a JSON string. Older experiments used this shape before Vercel required - // selecting the concrete linux/amd64 manifest from the OCI index. - string? digest = JsonSerializer.Deserialize(trimmed); - if (IsSha256Digest(digest)) - { - return digest!; - } - } - catch (JsonException ex) - { - throw new DistributedApplicationException("Docker returned invalid JSON while resolving the pushed VCR image digest.", ex); - } - } - - if (trimmed.StartsWith("{", StringComparison.Ordinal)) - { - try - { - // Current path uses `--format '{{json .Manifest}}'`. Docker may return an OCI - // image index with a `manifests[]` array, or a single manifest object. Vercel's - // Container Images docs describe VCR-backed OCI images; live smoke tests rejected - // index digests, so prefer the linux/amd64 child. - // See https://vercel.com/docs/functions/container-images. - using var document = JsonDocument.Parse(trimmed); - var root = document.RootElement; - - if (root.TryGetProperty("manifests", out var manifests) && manifests.ValueKind == JsonValueKind.Array) - { - foreach (var manifest in manifests.EnumerateArray()) - { - if (manifest.TryGetProperty("platform", out var platform) - && platform.TryGetProperty("os", out var osElement) - && platform.TryGetProperty("architecture", out var architectureElement) - && string.Equals(osElement.GetString(), "linux", StringComparison.OrdinalIgnoreCase) - && string.Equals(architectureElement.GetString(), "amd64", StringComparison.OrdinalIgnoreCase) - && TryGetJsonString(manifest, "digest", out var platformDigest) - && IsSha256Digest(platformDigest)) - { - return platformDigest!; - } - } - - throw new DistributedApplicationException("Docker did not return a linux/amd64 manifest digest for the pushed VCR image. Vercel requires linux/amd64 container images."); - } - - if (TryGetJsonString(root, "digest", out var digest) && IsSha256Digest(digest)) - { - return digest!; - } - } - catch (JsonException ex) - { - throw new DistributedApplicationException("Docker returned invalid JSON while resolving the pushed VCR image digest.", ex); - } - } - - var match = Regex.Match(trimmed, @"sha256:[a-fA-F0-9]{64}", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); - if (match.Success) - { - return match.Value; - } - - throw new DistributedApplicationException($"Docker did not return a valid sha256 image digest. Output: {GetTrimmedOutput(output)}"); - } - - private static bool TryGetJsonString(JsonElement element, string propertyName, [NotNullWhen(true)] out string? value) - { - value = element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String - ? property.GetString() - : null; - - return !string.IsNullOrWhiteSpace(value); - } - - internal static VercelOidcClaims DecodeUnvalidatedOidcClaims(string token) - { - string[] parts = token.Split('.'); - if (parts.Length != 3) - { - throw new DistributedApplicationException("The Vercel OIDC token is not a valid compact JWT."); - } - - try - { - // This is an unvalidated decode of the Vercel-issued token from `vercel pull`. - // Docker/Vercel validate the token when it is used; here we only need routing - // metadata such as owner_id/project to construct the VCR login and repository. - byte[] payloadBytes = Convert.FromBase64String(PadBase64Url(parts[1])); - using var document = JsonDocument.Parse(payloadBytes); - var root = document.RootElement; - - return new( - GetStringClaim(root, "owner_id"), - GetStringClaim(root, "owner"), - GetStringClaim(root, "project"), - GetStringClaim(root, "project_id")); - } - catch (Exception ex) when (ex is FormatException or JsonException) - { - throw new DistributedApplicationException("The Vercel OIDC token payload could not be decoded.", ex); - } - } - - private static string? GetStringClaim(JsonElement root, string name) - => root.TryGetProperty(name, out var element) && element.ValueKind == JsonValueKind.String - ? element.GetString() - : null; - - private static string PadBase64Url(string value) - { - string padded = value.Replace('-', '+').Replace('_', '/'); - return padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '='); - } - - internal static Dictionary ParseDotEnvFile(IEnumerable lines) - { - // Vercel writes dotenv files such as `.vercel/.env.production.local` during pull. - // We only need the VERCEL_OIDC_TOKEN line. This intentionally supports the subset the - // CLI emits: comments/blank lines, KEY=value, single/double quoted values, and common - // backslash escapes. It is not a general dotenv evaluator with interpolation. - Dictionary values = new(StringComparer.Ordinal); - - foreach (string rawLine in lines) - { - string line = rawLine.Trim(); - if (line.Length == 0 || line[0] == '#') - { - continue; - } - - int separator = line.IndexOf('='); - if (separator <= 0) - { - continue; - } - - string key = line[..separator].Trim(); - string value = line[(separator + 1)..].Trim(); - values[key] = UnquoteDotEnvValue(value); - } - - return values; - } - - private static string UnquoteDotEnvValue(string value) - { - if (value.Length >= 2 - && ((value[0] == '"' && value[^1] == '"') - || (value[0] == '\'' && value[^1] == '\''))) - { - value = value[1..^1]; - } - - return value.Replace("\\n", "\n", StringComparison.Ordinal) - .Replace("\\r", "\r", StringComparison.Ordinal) - .Replace("\\\"", "\"", StringComparison.Ordinal) - .Replace("\\\\", "\\", StringComparison.Ordinal); - } - - private static bool IsSha256Digest([NotNullWhen(true)] string? value) - => value is not null && Regex.IsMatch(value, "^sha256:[a-f0-9]{64}$", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); - - private static void DeleteIfExists(string path) - { - if (File.Exists(path)) - { - File.Delete(path); - } - } - - private static void DeleteDirectoryIfExists(string path) - { - if (Directory.Exists(path)) - { - Directory.Delete(path, recursive: true); - } - } - private static async Task EnsureManagedProjectAsync( PipelineStepContext context, IVercelCliRunner runner, @@ -406,32 +123,12 @@ private static async Task EnsureManagedProjectAsync( // `vercel project add` is idempotent for the current login/scope in the CLI versions // this integration supports: it creates the project or validates that it already // exists and is accessible. Failure here means deploy should not proceed to image push. - string projectName = GetVercelProjectName(entry); - string[] arguments = BuildAddProjectArguments(options, projectName); + string projectName = VercelProjectNameResolver.GetProjectName(entry); + string[] arguments = VercelCliArguments.BuildAddProjectArguments(options, projectName); var result = await runner.RunAsync(VercelCliFileName, arguments, workingDirectory: null, context.CancellationToken).ConfigureAwait(false); if (!result.Succeeded) { throw CreateCliException($"create or validate Vercel project '{projectName}' for resource '{entry.Resource.Name}'", VercelCliFileName, result); } } - - private static string GetVercelProjectEnvironmentName(VercelEnvironmentOptionsAnnotation options) - { - if (options.Production) - { - return "production"; - } - - return string.IsNullOrWhiteSpace(options.Target) ? "preview" : options.Target; - } - - private static string GetVercelProjectEnvironmentName(VercelDeploymentState state) - { - if (state.Production) - { - return "production"; - } - - return string.IsNullOrWhiteSpace(state.Target) ? "preview" : state.Target; - } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Cli.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Cli.cs index 042885a5a..4914c618b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Cli.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Cli.cs @@ -1,25 +1,10 @@ #pragma warning disable ASPIREPIPELINES001 -#pragma warning disable ASPIREPIPELINES002 -#pragma warning disable ASPIREPIPELINES003 -#pragma warning disable ASPIREPIPELINES004 -#pragma warning disable ASPIRECOMPUTE003 -#pragma warning disable ASPIREPROBES001 #pragma warning disable CTASPIREVERCEL001 -using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.ApplicationModel.Docker; using Aspire.Hosting.Pipelines; -using Aspire.Hosting.Publishing; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Net.Sockets; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; +using Aspire.Hosting; namespace CommunityToolkit.Aspire.Hosting.Vercel; @@ -37,10 +22,10 @@ public static async Task ValidateCliPrerequisitesAsync(PipelineStepContext conte } var versionOutput = $"{versionResult.StandardOutput}{Environment.NewLine}{versionResult.StandardError}"; - if (!TryGetVercelCliVersion(versionOutput, out var version)) + if (!VercelCliOutputParser.TryGetCliVersion(versionOutput, out var version)) { throw new DistributedApplicationException( - $"Failed to determine Vercel CLI version from '{GetTrimmedOutput(versionOutput)}'. Install Vercel CLI {MinimumVercelCliVersion} or later from https://vercel.com/docs/cli."); + $"Failed to determine Vercel CLI version from '{VercelCliOutputParser.GetTrimmedOutput(versionOutput)}'. Install Vercel CLI {MinimumVercelCliVersion} or later from https://vercel.com/docs/cli."); } // The preview relies on newer CLI behavior: project-scoped link/pull, @@ -60,7 +45,7 @@ public static async Task ValidateCliPrerequisitesAsync(PipelineStepContext conte if (!string.IsNullOrWhiteSpace(options.Scope)) { - var scopeResult = await runner.RunAsync(VercelCliFileName, BuildValidateScopeArguments(options), workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + var scopeResult = await runner.RunAsync(VercelCliFileName, VercelCliArguments.BuildValidateScopeArguments(options), workingDirectory: null, context.CancellationToken).ConfigureAwait(false); if (!scopeResult.Succeeded) { throw CreateCliException($"validate Vercel scope '{options.Scope}'", VercelCliFileName, scopeResult); @@ -68,195 +53,6 @@ public static async Task ValidateCliPrerequisitesAsync(PipelineStepContext conte } } - internal static string[] BuildDeployArguments(VercelEnvironmentOptionsAnnotation options, VercelDeploymentEntry entry) - => BuildDeployArguments(options, GetDeployDirectory(entry), GetVercelProjectOption(entry), environmentVariables: []); - - // Keep CLI argument construction as pure array-returning helpers. Tests assert exact - // argument boundaries so Vercel quirks such as `env add` requiring --cwd, not --project, - // cannot regress into shell-quoted or source-mutating command strings. - internal static string[] BuildDockerInspectDigestArguments(string imageReference) - => ["buildx", "imagetools", "inspect", "--format", "{{json .Manifest}}", imageReference]; - - internal static string[] BuildDestroyProjectArguments(VercelEnvironmentOptionsAnnotation options, string projectName) - { - List arguments = []; - - arguments.Add("project"); - arguments.Add("remove"); - arguments.Add(projectName); - AddOptionalScopeArgument(arguments, options); - - return [.. arguments]; - } - - internal static string[] BuildListProjectEnvironmentVariablesArguments( - VercelEnvironmentOptionsAnnotation options, - string projectLinkDirectory, - string targetEnvironment) - { - List arguments = []; - - arguments.Add("env"); - arguments.Add("ls"); - arguments.Add(targetEnvironment); - AddOptionalScopeArgument(arguments, options); - arguments.Add("--cwd"); - arguments.Add(projectLinkDirectory); - arguments.Add("--format=json"); - - return [.. arguments]; - } - - internal static string[] BuildAddProjectArguments(VercelEnvironmentOptionsAnnotation options, string projectName) - { - List arguments = []; - - arguments.Add("project"); - arguments.Add("add"); - arguments.Add(projectName); - AddOptionalScopeArgument(arguments, options); - - return [.. arguments]; - } - - internal static string[] BuildAddProjectEnvironmentVariableArguments( - VercelEnvironmentOptionsAnnotation options, - string projectLinkDirectory, - string name, - string targetEnvironment) - { - List arguments = []; - - arguments.Add("env"); - arguments.Add("add"); - arguments.Add(name); - arguments.Add(targetEnvironment); - AddOptionalScopeArgument(arguments, options); - // Vercel env commands must run inside a linked project directory. Use the - // Aspire-owned scratch link instead of the source root so provider metadata - // and pulled env files never mutate the user's checkout. - arguments.Add("--cwd"); - arguments.Add(projectLinkDirectory); - arguments.Add("--yes"); - arguments.Add("--force"); - arguments.Add("--sensitive"); - - return [.. arguments]; - } - - internal static string[] BuildRemoveProjectEnvironmentVariableArguments( - VercelEnvironmentOptionsAnnotation options, - string projectLinkDirectory, - string name, - string targetEnvironment) - { - List arguments = []; - - arguments.Add("env"); - arguments.Add("rm"); - arguments.Add(name); - arguments.Add(targetEnvironment); - AddOptionalScopeArgument(arguments, options); - arguments.Add("--cwd"); - arguments.Add(projectLinkDirectory); - arguments.Add("--yes"); - - return [.. arguments]; - } - - internal static string[] BuildLinkProjectArguments( - VercelEnvironmentOptionsAnnotation options, - string projectLinkDirectory, - string projectNameOrId) - { - List arguments = []; - - arguments.Add("link"); - AddOptionalScopeArgument(arguments, options); - arguments.Add("--cwd"); - arguments.Add(projectLinkDirectory); - arguments.Add("--yes"); - arguments.Add("--project"); - arguments.Add(projectNameOrId); - - return [.. arguments]; - } - - internal static string[] BuildPullProjectSettingsArguments( - VercelEnvironmentOptionsAnnotation options, - string projectLinkDirectory, - string targetEnvironment) - { - List arguments = []; - - arguments.Add("pull"); - AddOptionalScopeArgument(arguments, options); - arguments.Add("--cwd"); - arguments.Add(projectLinkDirectory); - arguments.Add("--yes"); - arguments.Add("--environment"); - arguments.Add(targetEnvironment); - - return [.. arguments]; - } - - internal static string[] BuildDockerLoginArguments(string username) - => ["login", VcrRegistry, "--username", username, "--password-stdin"]; - - private static void AddOptionalProjectArgument(List arguments, string? projectNameOrId) - { - if (!string.IsNullOrWhiteSpace(projectNameOrId)) - { - arguments.Add("--project"); - arguments.Add(projectNameOrId); - } - } - - private static void AddOptionalScopeArgument(List arguments, VercelEnvironmentOptionsAnnotation options) - { - if (!string.IsNullOrWhiteSpace(options.Scope)) - { - arguments.Add("--scope"); - arguments.Add(options.Scope); - } - } - - internal static string[] BuildInspectDeploymentArguments(VercelEnvironmentOptionsAnnotation options, string deploymentUrl) - { - List arguments = []; - - arguments.Add("inspect"); - arguments.Add(deploymentUrl); - AddOptionalScopeArgument(arguments, options); - arguments.Add("--wait"); - arguments.Add("--timeout"); - arguments.Add("120s"); - arguments.Add("--format=json"); - - return [.. arguments]; - } - - internal static string[] BuildValidateScopeArguments(VercelEnvironmentOptionsAnnotation options) - => BuildListProjectsArguments(options); - - internal static string[] BuildListProjectsArguments(VercelEnvironmentOptionsAnnotation options, string? filter = null) - { - List arguments = []; - - arguments.Add("project"); - arguments.Add("ls"); - AddOptionalScopeArgument(arguments, options); - if (!string.IsNullOrWhiteSpace(filter)) - { - arguments.Add("--filter"); - arguments.Add(filter); - } - - arguments.Add("--format=json"); - - return [.. arguments]; - } - private static async Task VerifyDeploymentAsync( PipelineStepContext context, IVercelCliRunner runner, @@ -267,7 +63,7 @@ private static async Task VerifyDeploymentAsync( // A successful `vercel deploy` only means the CLI accepted the submission. // Query the provider before recording state so Aspire does not persist failed // or still-building deployments as successfully applied resources. - string[] arguments = BuildInspectDeploymentArguments(options, deploymentResult.DeploymentUrl); + string[] arguments = VercelCliArguments.BuildInspectDeploymentArguments(options, deploymentResult.DeploymentUrl); var result = await runner.RunAsync(VercelCliFileName, arguments, workingDirectory: null, context.CancellationToken).ConfigureAwait(false); if (!result.Succeeded) @@ -275,10 +71,10 @@ private static async Task VerifyDeploymentAsync( throw CreateCliException($"verify Vercel deployment for resource '{resourceName}'", VercelCliFileName, result); } - var inspection = GetDeploymentInspection(result.StandardOutput); + var inspection = VercelCliOutputParser.GetDeploymentInspection(result.StandardOutput); if (inspection.ReadyState is null) { - throw new DistributedApplicationException($"Vercel inspect output for resource '{resourceName}' did not include a deployment ready state. Output: {GetTrimmedOutput(result.StandardOutput)}"); + throw new DistributedApplicationException($"Vercel inspect output for resource '{resourceName}' did not include a deployment ready state. Output: {VercelCliOutputParser.GetTrimmedOutput(result.StandardOutput)}"); } if (!string.Equals(inspection.ReadyState, "READY", StringComparison.OrdinalIgnoreCase)) @@ -287,202 +83,6 @@ private static async Task VerifyDeploymentAsync( } } - - private static string[] BuildDeployArguments( - VercelEnvironmentOptionsAnnotation options, - string deployDirectory, - string? projectNameOrId, - IReadOnlyList> environmentVariables) - { - List arguments = []; - - arguments.Add("deploy"); - AddOptionalScopeArgument(arguments, options); - arguments.Add("--cwd"); - arguments.Add(deployDirectory); - AddOptionalProjectArgument(arguments, projectNameOrId); - arguments.Add("--prebuilt"); - arguments.Add("--yes"); - - if (options.Production) - { - arguments.Add("--prod"); - } - - if (!string.IsNullOrWhiteSpace(options.Target)) - { - arguments.Add("--target"); - arguments.Add(options.Target); - } - - foreach (var environmentVariable in environmentVariables.OrderBy(static variable => variable.Key, StringComparer.Ordinal)) - { - arguments.Add("--env"); - arguments.Add($"{environmentVariable.Key}={environmentVariable.Value}"); - } - - return [.. arguments]; - } - - private static string BuildDisplayDeployCommand( - VercelEnvironmentOptionsAnnotation options, - string resourceName, - IReadOnlyList> environmentVariables) - { - // The plan should explain the command shape without leaking concrete source roots, - // project names, or environment values that may be machine- or account-specific. - var displayEnvironmentVariables = environmentVariables - .Select(static environmentVariable => new KeyValuePair(environmentVariable.Key, "")) - .ToArray(); - - string displayImage = $"vcr.vercel.com///{VercelContainerServiceName}:"; - string displayDeployDirectory = $"<{resourceName}-build-output>"; - string displayProject = $"<{resourceName}-vercel-project>"; - return $"vercel pull --cwd <{resourceName}-vercel-project-link> --yes --environment {GetVercelProjectEnvironmentName(options)} && aspire build/push {resourceName} -> {displayImage} && docker {string.Join(" ", BuildDockerInspectDigestArguments(displayImage))} && vercel {string.Join(" ", BuildDeployArguments(options, displayDeployDirectory, displayProject, displayEnvironmentVariables))}"; - } - - - internal static string GetDeploymentUrl(string standardOutput) - => GetDeploymentResult(standardOutput).DeploymentUrl; - - internal static VercelDeploymentResult GetDeploymentResult(string standardOutput) - { - // `vercel deploy` output has changed between CLI versions and flags. Prefer structured - // JSON when present, then fall back to the last plain HTTP(S) URL printed by the CLI. - if (TryGetJsonDeploymentResult(standardOutput) is { } jsonDeploymentResult) - { - return jsonDeploymentResult; - } - - // Older CLI versions printed the deployment URL as plain text. Keep the fallback so - // we fail only when no usable URL exists, not because formatting changed slightly. - string[] lines = standardOutput - .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - string? deploymentUrl = lines.LastOrDefault(IsHttpUrl); - if (deploymentUrl is null) - { - throw new DistributedApplicationException($"Vercel deploy output did not contain an HTTP or HTTPS deployment URL. Output: {GetTrimmedOutput(standardOutput)}"); - } - - return new(DeploymentId: null, deploymentUrl); - } - - internal static VercelDeploymentInspection GetDeploymentInspection(string standardOutput) - { - try - { - // Parse the Vercel inspect JSON shapes observed across CLI versions: - // { "readyState": "READY" } - // { "state": "READY" } - // { "deployment": { "readyState": "READY" } } - // { "deployment": { "state": "READY" } } - using var document = JsonDocument.Parse(standardOutput); - var root = document.RootElement; - string? readyState = GetJsonStringProperty(root, "readyState") - ?? GetJsonStringProperty(root, "state") - ?? (root.TryGetProperty("deployment", out var deployment) ? GetJsonStringProperty(deployment, "readyState") : null) - ?? (root.TryGetProperty("deployment", out deployment) ? GetJsonStringProperty(deployment, "state") : null); - - return new(readyState); - } - catch (JsonException ex) - { - throw new DistributedApplicationException("Failed to parse JSON output from 'vercel inspect'.", ex); - } - } - - private static VercelDeploymentResult? TryGetJsonDeploymentResult(string standardOutput) - { - if (!standardOutput.AsSpan().TrimStart().StartsWith("{", StringComparison.Ordinal)) - { - return null; - } - - try - { - using var document = JsonDocument.Parse(standardOutput); - var root = document.RootElement; - - if (TryGetDeploymentResult(root, out var deploymentResult)) - { - return deploymentResult; - } - } - catch (JsonException) - { - // Older Vercel CLI output is plain text; fall back to line-based URL extraction. - } - - return null; - } - - private static bool TryGetDeploymentResult(JsonElement root, [NotNullWhen(true)] out VercelDeploymentResult? deploymentResult) - { - // Parse the Vercel deploy JSON shapes observed from different CLI versions: - // { "deployment": { "url": "https://...", "id": "..." } } - // { "url": "https://...", "id": "..." } - // Callers fall back to line-based extraction when deploy output is plain text. - if (root.TryGetProperty("deployment", out var deployment) - && deployment.TryGetProperty("url", out var nestedUrl) - && TryGetHttpUrl(nestedUrl, out var nestedDeploymentUrl)) - { - string? deploymentId = deployment.TryGetProperty("id", out var nestedId) && nestedId.ValueKind == JsonValueKind.String - ? nestedId.GetString() - : null; - - deploymentResult = new(deploymentId, nestedDeploymentUrl); - return true; - } - - if (root.TryGetProperty("url", out var url) - && TryGetHttpUrl(url, out var rootDeploymentUrl)) - { - string? deploymentId = root.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String - ? id.GetString() - : null; - - deploymentResult = new(deploymentId, rootDeploymentUrl); - return true; - } - - deploymentResult = null; - return false; - } - - private static bool TryGetHttpUrl(JsonElement urlElement, [NotNullWhen(true)] out string? url) - { - url = urlElement.ValueKind == JsonValueKind.String - ? urlElement.GetString() - : null; - - return IsHttpUrl(url); - } - - private static bool IsHttpUrl([NotNullWhen(true)] string? url) - => Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http"; - - internal static bool TryGetVercelCliVersion(string output, [NotNullWhen(true)] out Version? version) - { - // The CLI can print banners/warnings around the version. Extract the first semantic - // x.y.z token instead of requiring a line to be exactly the version string. - var match = Regex.Match(output, @"(?\d+)\.(?\d+)\.(?\d+)(?!\d)", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); - if (!match.Success) - { - version = null; - return false; - } - - version = new( - int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture), - int.Parse(match.Groups["minor"].Value, CultureInfo.InvariantCulture), - int.Parse(match.Groups["patch"].Value, CultureInfo.InvariantCulture)); - return true; - } - - private static string GetTrimmedOutput(string output) - => string.IsNullOrWhiteSpace(output) ? "" : output.Trim(); - private static DistributedApplicationException CreateCliException(string operation, string cliPath, VercelCliResult result) { string output = string.IsNullOrWhiteSpace(result.StandardError) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.ComponentFacade.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.ComponentFacade.cs new file mode 100644 index 000000000..144b6fc25 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.ComponentFacade.cs @@ -0,0 +1,100 @@ +#pragma warning disable ASPIRECOMPUTE003 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting.ApplicationModel; +using System.Diagnostics.CodeAnalysis; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal static partial class VercelDeploymentStep +{ + internal static IEnumerable GetDeploymentEntries(DistributedApplicationModel model, VercelEnvironmentResource environment) + => VercelDeploymentModel.GetEntries(model, environment); + + internal static string[] BuildDeployArguments(VercelEnvironmentOptionsAnnotation options, VercelDeploymentEntry entry) + => VercelCliArguments.BuildDeployArguments(options, entry); + + internal static string[] BuildDockerInspectDigestArguments(string imageReference) + => VercelCliArguments.BuildDockerInspectDigestArguments(imageReference); + + internal static string[] BuildDestroyProjectArguments(VercelEnvironmentOptionsAnnotation options, string projectName) + => VercelCliArguments.BuildDestroyProjectArguments(options, projectName); + + internal static string[] BuildListProjectEnvironmentVariablesArguments( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string targetEnvironment) + => VercelCliArguments.BuildListProjectEnvironmentVariablesArguments(options, projectLinkDirectory, targetEnvironment); + + internal static string[] BuildAddProjectEnvironmentVariableArguments( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string name, + string targetEnvironment) + => VercelCliArguments.BuildAddProjectEnvironmentVariableArguments(options, projectLinkDirectory, name, targetEnvironment); + + internal static string[] BuildRemoveProjectEnvironmentVariableArguments( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string name, + string targetEnvironment) + => VercelCliArguments.BuildRemoveProjectEnvironmentVariableArguments(options, projectLinkDirectory, name, targetEnvironment); + + internal static string[] BuildLinkProjectArguments( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string projectNameOrId) + => VercelCliArguments.BuildLinkProjectArguments(options, projectLinkDirectory, projectNameOrId); + + internal static string[] BuildValidateScopeArguments(VercelEnvironmentOptionsAnnotation options) + => VercelCliArguments.BuildValidateScopeArguments(options); + + internal static string[] BuildListProjectsArguments(VercelEnvironmentOptionsAnnotation options, string? filter = null) + => VercelCliArguments.BuildListProjectsArguments(options, filter); + + internal static string[] BuildInspectDeploymentArguments(VercelEnvironmentOptionsAnnotation options, string deploymentUrl) + => VercelCliArguments.BuildInspectDeploymentArguments(options, deploymentUrl); + + internal static string GetDockerImageDigest(string output) + => VercelDockerImageDigestParser.GetDigest(output); + + internal static VercelOidcClaims DecodeUnvalidatedOidcClaims(string token) + => VercelOidcToken.DecodeUnvalidatedClaims(token); + + internal static Dictionary ParseDotEnvFile(IEnumerable lines) + => VercelDotEnvParser.Parse(lines); + + internal static bool ProjectListContainsProject(string standardOutput, string projectName) + => VercelCliOutputParser.ProjectListContainsProject(standardOutput, projectName); + + internal static bool EnvironmentVariableListContainsName(string standardOutput, string name) + => VercelCliOutputParser.EnvironmentVariableListContainsName(standardOutput, name); + + internal static string GetDeploymentUrl(string standardOutput) + => VercelCliOutputParser.GetDeploymentUrl(standardOutput); + + internal static VercelDeploymentResult GetDeploymentResult(string standardOutput) + => VercelCliOutputParser.GetDeploymentResult(standardOutput); + + internal static VercelDeploymentInspection GetDeploymentInspection(string standardOutput) + => VercelCliOutputParser.GetDeploymentInspection(standardOutput); + + internal static bool TryGetVercelCliVersion(string output, [NotNullWhen(true)] out Version? version) + => VercelCliOutputParser.TryGetCliVersion(output, out version); + + internal static string GetVercelProjectName(VercelDeploymentEntry entry) + => VercelProjectNameResolver.GetProjectName(entry); + + internal static string GetVercelProjectName(IResource resource) + => VercelProjectNameResolver.GetProjectName(resource); + + internal static bool IsValidVercelProjectName(string projectName) + => VercelProjectNameResolver.IsValidProjectName(projectName); + + internal static Task WriteBuildOutputAsync( + VercelDeploymentEntry entry, + VercelPulledProject project, + string imageReference, + CancellationToken cancellationToken) + => VercelBuildOutputWriter.WriteAsync(entry, project, imageReference, cancellationToken); +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Plan.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Plan.cs index 987391329..dcd9cd0ac 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Plan.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Plan.cs @@ -1,25 +1,13 @@ #pragma warning disable ASPIREPIPELINES001 -#pragma warning disable ASPIREPIPELINES002 -#pragma warning disable ASPIREPIPELINES003 #pragma warning disable ASPIREPIPELINES004 -#pragma warning disable ASPIRECOMPUTE003 -#pragma warning disable ASPIREPROBES001 #pragma warning disable CTASPIREVERCEL001 -using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.ApplicationModel.Docker; using Aspire.Hosting.Pipelines; using Aspire.Hosting.Publishing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Net.Sockets; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; +using Aspire.Hosting; namespace CommunityToolkit.Aspire.Hosting.Vercel; @@ -30,7 +18,7 @@ public static async Task WriteDeploymentPlanAsync(PipelineStepContext context, V var outputService = context.Services.GetRequiredService(); string outputDirectory = outputService.GetOutputDirectory(environment); - string planPath = await WriteDeploymentPlanAsync( + string planPath = await VercelDeploymentPlanWriter.WriteAsync( context.ExecutionContext, context.Logger, context.Model, @@ -46,13 +34,7 @@ internal static async Task WriteDeploymentPlanAsync( VercelEnvironmentResource environment, string outputDirectory, CancellationToken cancellationToken) - => await WriteDeploymentPlanAsync( - executionContext: null, - logger: null, - model, - environment, - outputDirectory, - cancellationToken).ConfigureAwait(false); + => await VercelDeploymentPlanWriter.WriteAsync(model, environment, outputDirectory, cancellationToken).ConfigureAwait(false); internal static async Task WriteDeploymentPlanAsync( DistributedApplicationExecutionContext? executionContext, @@ -61,31 +43,7 @@ internal static async Task WriteDeploymentPlanAsync( VercelEnvironmentResource environment, string outputDirectory, CancellationToken cancellationToken) - { - var entries = GetDeploymentEntries(model, environment).ToList(); - ValidateEntries(entries); - - // Publish is a reviewable handoff, not a dry-run deploy. Keep it deterministic: - // show commands, Dockerfile paths, and env var names without resolving secrets or - // depending on mutable Vercel state. - Directory.CreateDirectory(outputDirectory); - var options = environment.GetVercelOptions(); - - var plan = new VercelDeploymentPlan( - environment.Name, - await CreateDeploymentPlanEntriesAsync( - executionContext, - logger, - options, - entries, - cancellationToken).ConfigureAwait(false)); - - string planPath = Path.Combine(outputDirectory, DeploymentPlanFileName); - await using FileStream stream = File.Create(planPath); - await JsonSerializer.SerializeAsync(stream, plan, JsonOptions, cancellationToken).ConfigureAwait(false); - - return planPath; - } + => await VercelDeploymentPlanWriter.WriteAsync(executionContext, logger, model, environment, outputDirectory, cancellationToken).ConfigureAwait(false); internal static async Task BuildDeployArgumentsAsync( DistributedApplicationExecutionContext executionContext, @@ -93,13 +51,7 @@ internal static async Task BuildDeployArgumentsAsync( VercelEnvironmentOptionsAnnotation options, VercelDeploymentEntry entry, CancellationToken cancellationToken) - => await BuildDeployArgumentsAsync( - executionContext, - logger, - options, - entry, - [entry], - cancellationToken).ConfigureAwait(false); + => await VercelDeploymentPlanWriter.BuildDeployArgumentsAsync(executionContext, logger, options, entry, cancellationToken).ConfigureAwait(false); internal static async Task BuildDeployArgumentsAsync( DistributedApplicationExecutionContext executionContext, @@ -108,84 +60,5 @@ internal static async Task BuildDeployArgumentsAsync( VercelDeploymentEntry entry, IReadOnlyList entries, CancellationToken cancellationToken) - { - // Publish plans must be useful without Vercel credentials or secret resolution. - // Secret-bearing values are reduced to names/placeholders here; deploy resolves them - // only when they are sent to Vercel's project env store over stdin. - var entriesByResourceName = GetDeploymentEntriesByResourceName(entries); - var environmentConfiguration = await GetVercelEnvironmentConfigurationAsync( - executionContext, - logger, - options, - entry, - entriesByResourceName, - resolveProjectEnvironmentVariableValues: false, - cancellationToken).ConfigureAwait(false); - - return BuildDeployArguments(options, GetDeployDirectory(entry), GetVercelProjectOption(entry), environmentConfiguration.DeploymentEnvironmentVariables); - } - - private static async Task CreateDeploymentPlanEntriesAsync( - DistributedApplicationExecutionContext? executionContext, - ILogger? logger, - VercelEnvironmentOptionsAnnotation options, - IReadOnlyList entries, - CancellationToken cancellationToken) - { - List planEntries = []; - var entriesByResourceName = GetDeploymentEntriesByResourceName(entries); - - foreach (var entry in entries) - { - // When execution context is available, include the same target-native env names - // deploy will use. Values stay redacted because publish output is committed or - // handed off more often than deploy logs. - var environmentConfiguration = executionContext is null || logger is null - ? VercelEnvironmentConfiguration.Empty - : await GetVercelEnvironmentConfigurationAsync(executionContext, logger, options, entry, entriesByResourceName, resolveProjectEnvironmentVariableValues: false, cancellationToken).ConfigureAwait(false); - - planEntries.Add(new( - entry.Resource.Name, - GetDisplayDockerfilePath(entry), - BuildDisplayDeployCommand(options, entry.Resource.Name, environmentConfiguration.DeploymentEnvironmentVariables), - [.. environmentConfiguration.AllEnvironmentVariableNames.Order(StringComparer.Ordinal)])); - } - - return [.. planEntries]; - } - - private static async Task GetVercelEnvironmentConfigurationAsync( - DistributedApplicationExecutionContext executionContext, - ILogger logger, - VercelEnvironmentOptionsAnnotation options, - VercelDeploymentEntry entry, - IReadOnlyDictionary entriesByResourceName, - bool resolveProjectEnvironmentVariableValues, - CancellationToken cancellationToken) - { - var executionConfiguration = await ExecutionConfigurationBuilder - .Create(entry.Resource) - .WithEnvironmentVariablesConfig() - .WithArgumentsConfig() - .BuildAsync(executionContext, logger, cancellationToken) - .ConfigureAwait(false); - - if (executionConfiguration.Exception is not null) - { - throw new DistributedApplicationException($"Failed to process deployment configuration for resource '{entry.Resource.Name}'.", executionConfiguration.Exception); - } - - ValidateUnsupportedRuntimeConfiguration(entry.Resource, executionConfiguration); - - var environmentVariables = await GetVercelEnvironmentConfigurationAsync( - entry.Resource, - options, - executionConfiguration, - entriesByResourceName, - resolveProjectEnvironmentVariableValues, - cancellationToken).ConfigureAwait(false); - - return environmentVariables; - } - + => await VercelDeploymentPlanWriter.BuildDeployArgumentsAsync(executionContext, logger, options, entry, entries, cancellationToken).ConfigureAwait(false); } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.State.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.State.cs index a67e0df30..3bf0f4ea0 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.State.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.State.cs @@ -1,25 +1,12 @@ #pragma warning disable ASPIREPIPELINES001 #pragma warning disable ASPIREPIPELINES002 -#pragma warning disable ASPIREPIPELINES003 #pragma warning disable ASPIREPIPELINES004 -#pragma warning disable ASPIRECOMPUTE003 -#pragma warning disable ASPIREPROBES001 #pragma warning disable CTASPIREVERCEL001 -using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.ApplicationModel.Docker; using Aspire.Hosting.Pipelines; -using Aspire.Hosting.Publishing; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Net.Sockets; -using System.Text; using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; namespace CommunityToolkit.Aspire.Hosting.Vercel; @@ -29,8 +16,8 @@ public static async Task DestroyAsync(PipelineStepContext context, VercelEnviron { var options = environment.GetVercelOptions(); var stateManager = context.Services.GetRequiredService(); - var stateSection = await stateManager.AcquireSectionAsync(GetStateSectionName(environment), context.CancellationToken).ConfigureAwait(false); - var state = ReadDeploymentState(stateSection); + var stateSection = await stateManager.AcquireSectionAsync(VercelDeploymentStateStore.GetSectionName(environment), context.CancellationToken).ConfigureAwait(false); + var state = VercelDeploymentStateStore.Read(stateSection); if (state is null) { // Keep no-op destroy cheap and offline. If Aspire has no deployment state, @@ -39,7 +26,7 @@ public static async Task DestroyAsync(PipelineStepContext context, VercelEnviron return; } - ValidateDeploymentState(environment, options, state); + VercelDeploymentStateStore.Validate(environment, options, state); // Destroy is state-first rather than model-first. The current AppHost may no // longer contain the resources that created these Vercel projects, but persisted @@ -78,7 +65,7 @@ await RemoveLinkedProjectEnvironmentVariablesAsync( options, environment, deployment, - GetVercelProjectEnvironmentName(state)).ConfigureAwait(false); + VercelProjectEnvironment.GetName(state)).ConfigureAwait(false); } if (projects.Length == 0) @@ -96,7 +83,7 @@ await RemoveLinkedProjectEnvironmentVariablesAsync( } else { - string[] arguments = BuildDestroyProjectArguments(options, projectName); + string[] arguments = VercelCliArguments.BuildDestroyProjectArguments(options, projectName); var result = await runner.RunAsync(VercelCliFileName, arguments, workingDirectory: null, context.CancellationToken, standardInput: "y\n").ConfigureAwait(false); if (!result.Succeeded) @@ -114,7 +101,7 @@ await RemoveLinkedProjectEnvironmentVariablesAsync( } } - state = RemoveManagedProjectFromDeploymentState(state, projectName); + state = VercelDeploymentStateStore.RemoveManagedProject(state, projectName); // Save after each project removal so a later CLI failure leaves retryable state // for projects that still exist instead of forgetting partially cleaned resources. stateSection.SetValue(JsonSerializer.Serialize(state, JsonOptions)); @@ -124,171 +111,6 @@ await RemoveLinkedProjectEnvironmentVariablesAsync( await stateManager.DeleteSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); } - private static async Task ValidateExistingDeploymentStateAsync( - PipelineStepContext context, - VercelEnvironmentResource environment, - VercelEnvironmentOptionsAnnotation options) - { - var stateManager = context.Services.GetRequiredService(); - var stateSection = await stateManager.AcquireSectionAsync(GetStateSectionName(environment), context.CancellationToken).ConfigureAwait(false); - var existingState = ReadDeploymentState(stateSection); - if (existingState is not null) - { - ValidateDeploymentState(environment, options, existingState); - } - } - - private static async Task GetPreviousDeploymentStateEntryAsync( - PipelineStepContext context, - VercelEnvironmentResource environment, - VercelEnvironmentOptionsAnnotation options, - string resourceName, - string projectName) - { - var stateManager = context.Services.GetRequiredService(); - var stateSection = await stateManager.AcquireSectionAsync(GetStateSectionName(environment), context.CancellationToken).ConfigureAwait(false); - var existingState = ReadDeploymentState(stateSection); - if (existingState is null) - { - return null; - } - - ValidateDeploymentState(environment, options, existingState); - var entry = existingState.Deployments.FirstOrDefault(deployment => - string.Equals(deployment.ResourceName, resourceName, StringComparison.Ordinal) - && string.Equals(deployment.ProjectName, projectName, StringComparison.Ordinal)); - - return entry is null - ? null - : new(entry, GetVercelProjectEnvironmentName(existingState)); - } - - private static async Task SaveDeploymentStateEntryAsync( - PipelineStepContext context, - VercelEnvironmentResource environment, - VercelEnvironmentOptionsAnnotation options, - VercelDeploymentStateEntry deployment) - { - var stateManager = context.Services.GetRequiredService(); - var stateSection = await stateManager.AcquireSectionAsync(GetStateSectionName(environment), context.CancellationToken).ConfigureAwait(false); - var existingState = ReadDeploymentState(stateSection); - var state = existingState is null - ? CreateDeploymentState(environment, options, [deployment]) - : MergeDeploymentState(environment, options, existingState, deployment); - - stateSection.SetValue(JsonSerializer.Serialize(state, JsonOptions)); - - await stateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); - } - - private static VercelDeploymentState CreateDeploymentState( - VercelEnvironmentResource environment, - VercelEnvironmentOptionsAnnotation options, - VercelDeploymentStateEntry[] deployments) - => new( - DeploymentStateSchemaVersion, - environment.Name, - NormalizeScope(options.Scope), - NormalizeTarget(options.Target), - options.Production, - deployments); - - private static VercelDeploymentState MergeDeploymentState( - VercelEnvironmentResource environment, - VercelEnvironmentOptionsAnnotation options, - VercelDeploymentState existingState, - VercelDeploymentStateEntry deployment) - { - ValidateDeploymentState(environment, options, existingState); - - return CreateDeploymentState( - environment, - options, - [ - .. existingState.Deployments.Where(existing => - !string.Equals(existing.ResourceName, deployment.ResourceName, StringComparison.Ordinal) - || !string.Equals(existing.ProjectName, deployment.ProjectName, StringComparison.Ordinal)), - deployment - ]); - } - - private static VercelDeploymentState? ReadDeploymentState(DeploymentStateSection stateSection) - { - // DeploymentStateSection storage shape has changed across Aspire builds. Accept the - // known wrappers so destroy can still clean up projects created by an older CLI. - if (stateSection.Data.TryGetPropertyValue("value", out JsonNode? value) - && value is not null) - { - return DeserializeDeploymentState(value); - } - - value = stateSection.Data.FirstOrDefault().Value; - if (value is not null) - { - return DeserializeDeploymentState(value); - } - - if (stateSection.Data.ContainsKey("schemaVersion")) - { - return stateSection.Data.Deserialize(JsonOptions); - } - - return null; - } - - private static VercelDeploymentState? DeserializeDeploymentState(JsonNode value) - { - return value.GetValueKind() == JsonValueKind.String - ? JsonSerializer.Deserialize(value.GetValue(), JsonOptions) - : value.Deserialize(JsonOptions); - } - - private static void ValidateDeploymentState( - VercelEnvironmentResource environment, - VercelEnvironmentOptionsAnnotation options, - VercelDeploymentState state) - { - if (state.SchemaVersion != DeploymentStateSchemaVersion) - { - throw new DistributedApplicationException($"Vercel deployment state for environment '{environment.Name}' uses unsupported schema version '{state.SchemaVersion}'. Redeploy the environment before destroying it."); - } - - if (!string.Equals(state.Environment, environment.Name, StringComparison.Ordinal)) - { - throw new DistributedApplicationException($"Vercel deployment state for environment '{state.Environment}' cannot be used to destroy environment '{environment.Name}'."); - } - - string? configuredScope = NormalizeScope(options.Scope); - if (!string.Equals(state.Scope, configuredScope, StringComparison.Ordinal)) - { - string stateScope = string.IsNullOrWhiteSpace(state.Scope) ? "" : state.Scope; - string requestedScope = string.IsNullOrWhiteSpace(configuredScope) ? "" : configuredScope; - throw new DistributedApplicationException($"Vercel deployment state for environment '{environment.Name}' was created for scope '{stateScope}', but destroy is configured for scope '{requestedScope}'. Use the same Vercel scope that created the deployment state."); - } - } - - private static string? NormalizeScope(string? scope) - => string.IsNullOrWhiteSpace(scope) ? null : scope; - - private static string? NormalizeTarget(string? target) - => string.IsNullOrWhiteSpace(target) ? null : target; - - private static string? GetProductionUrl(VercelEnvironmentOptionsAnnotation options, string projectName) - => options.Production ? $"https://{projectName}.vercel.app" : null; - - private static string GetDeployDirectory(VercelDeploymentEntry entry) - => string.IsNullOrWhiteSpace(entry.DeployDirectory) ? entry.SourceRoot : entry.DeployDirectory; - - private static VercelDeploymentState RemoveManagedProjectFromDeploymentState(VercelDeploymentState state, string projectName) - => state with - { - Deployments = state.Deployments - .Where(deployment => !deployment.ManagedByAspire || !string.Equals(deployment.ProjectName, projectName, StringComparison.Ordinal)) - .ToArray() - }; - - private static string GetStateSectionName(VercelEnvironmentResource environment) => $"{StateSectionNamePrefix}{environment.Name}"; - private static async Task ProjectExistsAsync( PipelineStepContext context, IVercelCliRunner runner, @@ -298,14 +120,14 @@ private static async Task ProjectExistsAsync( // Avoid treating localized or reformatted CLI errors as provider state. The Vercel // CLI exposes project lists as JSON, so destroy checks exact project names before // deleting and again after a failed delete to distinguish races from real failures. - string[] arguments = BuildListProjectsArguments(options, projectName); + string[] arguments = VercelCliArguments.BuildListProjectsArguments(options, projectName); var result = await runner.RunAsync(VercelCliFileName, arguments, workingDirectory: null, context.CancellationToken).ConfigureAwait(false); if (!result.Succeeded) { throw CreateCliException($"list Vercel projects while checking for '{projectName}'", VercelCliFileName, result); } - return ProjectListContainsProject(result.StandardOutput, projectName); + return VercelCliOutputParser.ProjectListContainsProject(result.StandardOutput, projectName); } private static async Task ProjectEnvironmentVariableExistsAsync( @@ -319,14 +141,14 @@ private static async Task ProjectEnvironmentVariableExistsAsync( // `vercel env rm` reports absence as human text, but `vercel env ls --format=json` // returns the linked project's exact keys. Use that provider read for idempotent // stale-secret cleanup instead of parsing failure prose. - string[] arguments = BuildListProjectEnvironmentVariablesArguments(options, projectLinkDirectory, targetEnvironment); + string[] arguments = VercelCliArguments.BuildListProjectEnvironmentVariablesArguments(options, projectLinkDirectory, targetEnvironment); var result = await runner.RunAsync(VercelCliFileName, arguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); if (!result.Succeeded) { throw CreateCliException($"list Vercel project environment variables before removing '{name}'", VercelCliFileName, result); } - return EnvironmentVariableListContainsName(result.StandardOutput, name); + return VercelCliOutputParser.EnvironmentVariableListContainsName(result.StandardOutput, name); } private static async Task ConfigureProjectEnvironmentVariablesAsync( @@ -364,10 +186,10 @@ await RemoveProjectEnvironmentVariablesAsync( return; } - string targetEnvironment = GetVercelProjectEnvironmentName(options); + string targetEnvironment = VercelProjectEnvironment.GetName(options); foreach (var environmentVariable in environmentVariables.OrderBy(static variable => variable.Key, StringComparer.Ordinal)) { - string[] arguments = BuildAddProjectEnvironmentVariableArguments(options, projectLinkDirectory, environmentVariable.Key, targetEnvironment); + string[] arguments = VercelCliArguments.BuildAddProjectEnvironmentVariableArguments(options, projectLinkDirectory, environmentVariable.Key, targetEnvironment); var result = await runner.RunAsync(VercelCliFileName, arguments, projectLinkDirectory, context.CancellationToken, standardInput: environmentVariable.Value).ConfigureAwait(false); if (!result.Succeeded) { @@ -392,7 +214,7 @@ private static async Task RemoveProjectEnvironmentVariablesAsync( continue; } - string[] arguments = BuildRemoveProjectEnvironmentVariableArguments(options, projectLinkDirectory, name, targetEnvironment); + string[] arguments = VercelCliArguments.BuildRemoveProjectEnvironmentVariableArguments(options, projectLinkDirectory, name, targetEnvironment); var result = await runner.RunAsync(VercelCliFileName, arguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); if (!result.Succeeded && await ProjectEnvironmentVariableExistsAsync(context, runner, options, projectLinkDirectory, name, targetEnvironment).ConfigureAwait(false)) @@ -412,12 +234,12 @@ private static async Task RemoveLinkedProjectEnvironmentVariablesAsync( { var outputService = context.Services.GetRequiredService(); string projectLinkDirectory = Path.Combine(outputService.GetTempDirectory(environment), ".vercel-projects", deployment.ProjectName); - DeleteDirectoryIfExists(projectLinkDirectory); + VercelFileSystem.DeleteDirectoryIfExists(projectLinkDirectory); Directory.CreateDirectory(projectLinkDirectory); try { - string[] linkArguments = BuildLinkProjectArguments(options, projectLinkDirectory, deployment.ProjectId ?? deployment.ProjectName); + string[] linkArguments = VercelCliArguments.BuildLinkProjectArguments(options, projectLinkDirectory, deployment.ProjectId ?? deployment.ProjectName); var linkResult = await runner.RunAsync(VercelCliFileName, linkArguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); if (!linkResult.Succeeded) { @@ -435,7 +257,7 @@ await RemoveProjectEnvironmentVariablesAsync( } finally { - DeleteDirectoryIfExists(projectLinkDirectory); + VercelFileSystem.DeleteDirectoryIfExists(projectLinkDirectory); } } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs index 96efa701f..4d871ee09 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs @@ -99,19 +99,19 @@ public static async Task ValidatePrerequisitesAsync(PipelineStepContext context, // step can validate registry metadata: project create/link, Vercel pull, VCR login, // VCR repository ensure, and deployment target annotation setup. var options = environment.GetVercelOptions(); - var entries = GetDeploymentEntries(context.Model, environment).ToList(); - ValidateEntries(entries); + var entries = VercelDeploymentModel.GetEntries(context.Model, environment).ToList(); + VercelDeploymentModel.ValidateEntries(entries); foreach (var entry in entries) { - await ValidateVercelJsonAsync(entry.Resource, entry.SourceRoot, context.CancellationToken).ConfigureAwait(false); + await VercelDeploymentModel.ValidateVercelJsonAsync(entry.Resource, entry.SourceRoot, context.CancellationToken).ConfigureAwait(false); } await ValidateCliPrerequisitesAsync(context, environment).ConfigureAwait(false); await ValidateDockerDigestInspectionPrerequisitesAsync(context).ConfigureAwait(false); var runner = context.Services.GetRequiredService(); var registryClient = context.Services.GetRequiredService(); - await ValidateExistingDeploymentStateAsync(context, environment, options).ConfigureAwait(false); - var entriesByResourceName = GetDeploymentEntriesByResourceName(entries); + await VercelDeploymentStateStore.ValidateExistingAsync(context, environment, options).ConfigureAwait(false); + var entriesByResourceName = VercelDeploymentModel.GetEntriesByResourceName(entries); foreach (var entry in entries) { @@ -140,9 +140,9 @@ public static async Task DeployAsync(PipelineStepContext context, VercelEnvironm { var options = environment.GetVercelOptions(); var runner = context.Services.GetRequiredService(); - var entries = GetDeploymentEntries(context.Model, environment).ToList(); + var entries = VercelDeploymentModel.GetEntries(context.Model, environment).ToList(); - ValidateEntries(entries); + VercelDeploymentModel.ValidateEntries(entries); // Normal pipeline execution runs the prereq step first. Keep this fallback so the // deploy delegate remains directly unit-testable and safe if invoked by a custom runner. if (entries.Any(entry => GetPreparedDeployment(entry.Resource) is null)) @@ -188,7 +188,7 @@ private static async Task DeployEntryAsync( preparedDeployment.ProjectContext, image).ConfigureAwait(false); - string? productionUrl = GetProductionUrl(options, preparedDeployment.ProjectLink.ProjectName); + string? productionUrl = VercelDeploymentStateStore.GetProductionUrl(options, preparedDeployment.ProjectLink.ProjectName); var stateEntry = CreateSuccessfulDeploymentStateEntry( preparedDeployment.Entry, preparedDeployment.ProjectLink, @@ -201,7 +201,7 @@ private static async Task DeployEntryAsync( // Persist each verified resource independently. A later resource can fail after // Vercel has already created earlier projects, and destroy must still know which // managed projects are safe to remove or retry. - await SaveDeploymentStateEntryAsync(context, environment, options, stateEntry).ConfigureAwait(false); + await VercelDeploymentStateStore.SaveEntryAsync(context, environment, options, stateEntry).ConfigureAwait(false); AddDeploymentSummary(context, entry.Resource.Name, deploymentResult, productionUrl); } @@ -224,7 +224,7 @@ private static async Task EnsureManagedProjectAndSaveInitialStateAsync( // Record the managed project as soon as Vercel accepts it. Build, env // configuration, or deploy can still fail afterward, and destroy must be able // to clean up that partially-created provider object. - await SaveDeploymentStateEntryAsync(context, environment, options, new( + await VercelDeploymentStateStore.SaveEntryAsync(context, environment, options, new( preparedEntry.Resource.Name, projectLink.ProjectName, projectLink.ProjectId, @@ -233,7 +233,7 @@ private static async Task EnsureManagedProjectAndSaveInitialStateAsync( sourceRoot, ManagedByAspire: true) { - ProductionUrl = GetProductionUrl(options, projectLink.ProjectName), + ProductionUrl = VercelDeploymentStateStore.GetProductionUrl(options, projectLink.ProjectName), ProjectEnvironmentVariables = previousDeployment?.Entry.ProjectEnvironmentVariables ?? [] }).ConfigureAwait(false); } @@ -244,7 +244,7 @@ public static void ConfigurePipeline(PipelineConfigurationContext context, Verce // support. Vercel prereq must happen before push-prereq/build/push so the resource has // a VCR container registry annotation and linux/amd64 build target; Vercel deploy must // then wait for the resource-specific push step. - var entries = GetDeploymentEntries(context.Model, environment).ToArray(); + var entries = VercelDeploymentModel.GetEntries(context.Model, environment).ToArray(); if (entries.Length == 0) { return; @@ -323,9 +323,9 @@ private static async Task PrepareResourceForBuiltInImagePushAsync( IReadOnlyDictionary entriesByResourceName, VercelDeploymentEntry entry) { - var preparedEntry = await PrepareDeploymentEntryAsync(context, entry).ConfigureAwait(false); - var projectLink = GetVercelProjectLink(preparedEntry); - var previousDeployment = await GetPreviousDeploymentStateEntryAsync( + var preparedEntry = await VercelDeploymentModel.PrepareEntryAsync(context, entry).ConfigureAwait(false); + var projectLink = VercelProjectNameResolver.GetProjectLink(preparedEntry); + var previousDeployment = await VercelDeploymentStateStore.GetPreviousAsync( context, environment, options, @@ -333,7 +333,7 @@ private static async Task PrepareResourceForBuiltInImagePushAsync( projectLink.ProjectName).ConfigureAwait(false); // A checked-in .vercel/project.json means the user linked an existing provider project, // so deploy can target it but destroy must not claim ownership of it. - bool managedByAspire = !HasVercelProjectLinkFile(entry.SourceRoot); + bool managedByAspire = !VercelProjectNameResolver.HasProjectLinkFile(entry.SourceRoot); // Managed projects are recorded before the image build begins. If Docker build, // project-env configuration, or VCR push fails later, destroy/retry still has enough @@ -378,7 +378,7 @@ internal static async Task PreparePulledProjectConte { // Use Aspire's unprocessed values so endpoint references and secrets keep their // graph meaning until Vercel-specific deployment translation happens. - var environmentConfiguration = await GetVercelEnvironmentConfigurationAsync( + var environmentConfiguration = await VercelDeploymentPlanWriter.GetEnvironmentConfigurationAsync( context.ExecutionContext, context.Logger, options, @@ -404,7 +404,7 @@ await ConfigureProjectEnvironmentVariablesAsync( // for that linked project. Keep that link/pull in scratch space so the source // root never receives provider metadata or pulled env files. var pulledProject = await PullProjectSettingsAsync(context, runner, options, preparedEntry, projectLinkDirectory).ConfigureAwait(false); - var oidcClaims = DecodeUnvalidatedOidcClaims(pulledProject.OidcToken); + var oidcClaims = VercelOidcToken.DecodeUnvalidatedClaims(pulledProject.OidcToken); return new(environmentConfiguration, pulledProject, oidcClaims); } @@ -413,7 +413,7 @@ await ConfigureProjectEnvironmentVariablesAsync( // `vercel pull` can write provider-owned env/config files into the linked // directory. They may contain project secrets unrelated to the AppHost, so the // scratch link is never copied, logged, stored in state, or left on disk. - DeleteDirectoryIfExists(projectLinkDirectory); + VercelFileSystem.DeleteDirectoryIfExists(projectLinkDirectory); } } @@ -428,12 +428,12 @@ private static async Task DeployPrebuiltOutputAsync( // The generated Build Output API tree is the provider-owned deploy artifact. // It points at the immutable VCR digest and is written to temp/output storage so // `vercel deploy --prebuilt` uploads metadata only, not a staged source tree. - await WriteBuildOutputAsync(entry, projectContext.PulledProject, image.Reference, context.CancellationToken).ConfigureAwait(false); + await VercelBuildOutputWriter.WriteAsync(entry, projectContext.PulledProject, image.Reference, context.CancellationToken).ConfigureAwait(false); - string[] deployArguments = BuildDeployArguments( + string[] deployArguments = VercelCliArguments.BuildDeployArguments( options, entry.DeployDirectory, - GetVercelProjectOption(entry), + VercelProjectNameResolver.GetProjectOption(entry), projectContext.EnvironmentConfiguration.DeploymentEnvironmentVariables); var result = await runner.RunAsync(VercelCliFileName, deployArguments, entry.DeployDirectory, context.CancellationToken).ConfigureAwait(false); @@ -442,7 +442,7 @@ private static async Task DeployPrebuiltOutputAsync( throw CreateCliException($"deploy prebuilt resource '{entry.Resource.Name}' to Vercel", VercelCliFileName, result); } - var deploymentResult = GetDeploymentResult(result.StandardOutput); + var deploymentResult = VercelCliOutputParser.GetDeploymentResult(result.StandardOutput); await VerifyDeploymentAsync(context, runner, options, entry.Resource.Name, deploymentResult).ConfigureAwait(false); return deploymentResult; @@ -537,14 +537,14 @@ await LoginToVcrAsync( preparedDeployment.ProjectContext.OidcClaims, context.CancellationToken).ConfigureAwait(false); - string[] arguments = BuildDockerInspectDigestArguments(preparedDeployment.TaggedImageReference); + string[] arguments = VercelCliArguments.BuildDockerInspectDigestArguments(preparedDeployment.TaggedImageReference); var result = await runner.RunAsync(DockerCliFileName, arguments, workingDirectory: null, context.CancellationToken).ConfigureAwait(false); if (!result.Succeeded) { throw CreateCliException($"resolve pushed VCR image digest for resource '{preparedDeployment.Entry.Resource.Name}'", DockerCliFileName, result); } - string digest = GetDockerImageDigest(result.StandardOutput); + string digest = VercelDockerImageDigestParser.GetDigest(result.StandardOutput); string digestReference = preparedDeployment.TaggedImageReference[..preparedDeployment.TaggedImageReference.LastIndexOf(':')] + $"@{digest}"; return new(digestReference, digest); } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDockerImageDigestParser.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDockerImageDigestParser.cs new file mode 100644 index 000000000..a1c673dc7 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDockerImageDigestParser.cs @@ -0,0 +1,85 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.RegularExpressions; +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal static class VercelDockerImageDigestParser +{ + public static string GetDigest(string output) + { + string trimmed = output.Trim(); + if (trimmed.StartsWith("\"", StringComparison.Ordinal)) + { + try + { + // `docker buildx imagetools inspect --format '{{json .Digest}}'` style output + // is a JSON string. Older experiments used this shape before Vercel required + // selecting the concrete linux/amd64 manifest from the OCI index. + string? digest = JsonSerializer.Deserialize(trimmed); + if (IsSha256Digest(digest)) + { + return digest!; + } + } + catch (JsonException ex) + { + throw new DistributedApplicationException("Docker returned invalid JSON while resolving the pushed VCR image digest.", ex); + } + } + + if (trimmed.StartsWith("{", StringComparison.Ordinal)) + { + try + { + // Current path uses `--format '{{json .Manifest}}'`. Docker may return an OCI + // image index with a `manifests[]` array, or a single manifest object. Vercel's + // Container Images docs describe VCR-backed OCI images; live smoke tests rejected + // index digests, so prefer the linux/amd64 child. + // See https://vercel.com/docs/functions/container-images. + using var document = JsonDocument.Parse(trimmed); + var root = document.RootElement; + + if (root.TryGetProperty("manifests", out var manifests) && manifests.ValueKind == JsonValueKind.Array) + { + foreach (var manifest in manifests.EnumerateArray()) + { + if (manifest.TryGetProperty("platform", out var platform) + && platform.TryGetProperty("os", out var osElement) + && platform.TryGetProperty("architecture", out var architectureElement) + && string.Equals(osElement.GetString(), "linux", StringComparison.OrdinalIgnoreCase) + && string.Equals(architectureElement.GetString(), "amd64", StringComparison.OrdinalIgnoreCase) + && VercelJson.TryGetString(manifest, "digest", out var platformDigest) + && IsSha256Digest(platformDigest)) + { + return platformDigest!; + } + } + + throw new DistributedApplicationException("Docker did not return a linux/amd64 manifest digest for the pushed VCR image. Vercel requires linux/amd64 container images."); + } + + if (VercelJson.TryGetString(root, "digest", out var digest) && IsSha256Digest(digest)) + { + return digest!; + } + } + catch (JsonException ex) + { + throw new DistributedApplicationException("Docker returned invalid JSON while resolving the pushed VCR image digest.", ex); + } + } + + var match = Regex.Match(trimmed, @"sha256:[a-fA-F0-9]{64}", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); + if (match.Success) + { + return match.Value; + } + + throw new DistributedApplicationException($"Docker did not return a valid sha256 image digest. Output: {VercelCliOutputParser.GetTrimmedOutput(output)}"); + } + + private static bool IsSha256Digest([NotNullWhen(true)] string? value) + => value is not null && Regex.IsMatch(value, "^sha256:[a-f0-9]{64}$", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDotEnvParser.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDotEnvParser.cs new file mode 100644 index 000000000..146f38c18 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDotEnvParser.cs @@ -0,0 +1,49 @@ +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal static class VercelDotEnvParser +{ + public static Dictionary Parse(IEnumerable lines) + { + // Vercel writes dotenv files such as `.vercel/.env.production.local` during pull. + // We only need the VERCEL_OIDC_TOKEN line. This intentionally supports the subset the + // CLI emits: comments/blank lines, KEY=value, single/double quoted values, and common + // backslash escapes. It is not a general dotenv evaluator with interpolation. + Dictionary values = new(StringComparer.Ordinal); + + foreach (string rawLine in lines) + { + string line = rawLine.Trim(); + if (line.Length == 0 || line[0] == '#') + { + continue; + } + + int separator = line.IndexOf('='); + if (separator <= 0) + { + continue; + } + + string key = line[..separator].Trim(); + string value = line[(separator + 1)..].Trim(); + values[key] = UnquoteValue(value); + } + + return values; + } + + private static string UnquoteValue(string value) + { + if (value.Length >= 2 + && ((value[0] == '"' && value[^1] == '"') + || (value[0] == '\'' && value[^1] == '\''))) + { + value = value[1..^1]; + } + + return value.Replace("\\n", "\n", StringComparison.Ordinal) + .Replace("\\r", "\r", StringComparison.Ordinal) + .Replace("\\\"", "\"", StringComparison.Ordinal) + .Replace("\\\\", "\\", StringComparison.Ordinal); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Environment.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentMapper.cs similarity index 86% rename from src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Environment.cs rename to src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentMapper.cs index 3d8d981c5..6f72d0963 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Environment.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentMapper.cs @@ -1,31 +1,16 @@ -#pragma warning disable ASPIREPIPELINES001 -#pragma warning disable ASPIREPIPELINES002 -#pragma warning disable ASPIREPIPELINES003 -#pragma warning disable ASPIREPIPELINES004 #pragma warning disable ASPIRECOMPUTE003 -#pragma warning disable ASPIREPROBES001 -#pragma warning disable CTASPIREVERCEL001 -using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.ApplicationModel.Docker; -using Aspire.Hosting.Pipelines; using Aspire.Hosting.Publishing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using Aspire.Hosting; using System.Diagnostics.CodeAnalysis; using System.Globalization; -using System.Net.Sockets; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; namespace CommunityToolkit.Aspire.Hosting.Vercel; -internal static partial class VercelDeploymentStep +internal static class VercelEnvironmentMapper { - private static async Task GetVercelEnvironmentConfigurationAsync( + public static async Task GetConfigurationAsync( IResource resource, VercelEnvironmentOptionsAnnotation options, IExecutionConfigurationResult executionConfiguration, @@ -69,7 +54,7 @@ private static async Task GetVercelEnvironmentCo if (containsSecret) { value = resolveProjectEnvironmentVariableValues - ? await GetVercelProjectEnvironmentVariableValueAsync( + ? await GetProjectEnvironmentVariableValueAsync( resource, options, entriesByResourceName, @@ -78,7 +63,7 @@ private static async Task GetVercelEnvironmentCo cancellationToken).ConfigureAwait(false) : ""; } - else if (TryGetVercelEnvironmentVariableValue(resource, options, entriesByResourceName, unprocessedValue, out string? vercelValue)) + else if (TryGetEnvironmentVariableValue(resource, options, entriesByResourceName, unprocessedValue, out string? vercelValue)) { value = vercelValue; } @@ -96,7 +81,29 @@ private static async Task GetVercelEnvironmentCo return new(deploymentEnvironmentVariables, projectEnvironmentVariables); } - private static async ValueTask GetVercelProjectEnvironmentVariableValueAsync( + public static void ValidateUnsupportedRuntimeConfiguration( + IResource resource, + IExecutionConfigurationResult executionConfiguration) + { + // These Aspire concepts have no faithful Vercel Dockerfile-deploy equivalent in this + // preview. Rejecting them is safer than silently dropping entrypoint, args, or build + // values that would change the workload's deployed behavior. Aspire's built-in + // image build/push pipeline owns build-time Docker options; this validation only + // rejects runtime concepts the Vercel Build Output API path cannot preserve. + if (resource is ContainerResource { Entrypoint: not null }) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures a container entrypoint, but Vercel Dockerfile deployments use the CMD/ENTRYPOINT from Aspire's publish output. Configure the workload's publish behavior or Vercel project settings instead."); + } + + if (executionConfiguration.ArgumentsWithUnprocessed.Any()) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures Aspire command-line arguments, but Vercel Dockerfile deployments cannot override Docker CMD/ENTRYPOINT. Configure the workload's publish behavior or express the values as environment variables."); + } + } + + private static async ValueTask GetProjectEnvironmentVariableValueAsync( IResource resource, VercelEnvironmentOptionsAnnotation options, IReadOnlyDictionary entriesByResourceName, @@ -122,7 +129,7 @@ private static async ValueTask GetVercelProjectEnvironmentVariableValueA case IResourceBuilder connectionStringBuilder: return await GetValueProviderValueAsync(connectionStringBuilder.Resource.ConnectionStringExpression, $"connection string for resource '{connectionStringBuilder.Resource.Name}'", cancellationToken).ConfigureAwait(false); case ReferenceExpression referenceExpression: - return await GetVercelProjectReferenceExpressionValueAsync(resource, options, entriesByResourceName, referenceExpression, cancellationToken).ConfigureAwait(false); + return await GetProjectReferenceExpressionValueAsync(resource, options, entriesByResourceName, referenceExpression, cancellationToken).ConfigureAwait(false); case IValueProvider valueProvider: return await GetValueProviderValueAsync(valueProvider, "environment variable value", cancellationToken).ConfigureAwait(false); default: @@ -130,7 +137,7 @@ private static async ValueTask GetVercelProjectEnvironmentVariableValueA } } - private static async ValueTask GetVercelProjectReferenceExpressionValueAsync( + private static async ValueTask GetProjectReferenceExpressionValueAsync( IResource resource, VercelEnvironmentOptionsAnnotation options, IReadOnlyDictionary entriesByResourceName, @@ -150,8 +157,8 @@ private static async ValueTask GetVercelProjectReferenceExpressionValueA { // Secret-bearing project env vars may combine endpoint URLs with secret // parameters because the final value is sent through Vercel's secret path. - EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference) => GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReference.Property(EndpointProperty.Url)), - EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint) => GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReferenceExpression), + EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference) => GetEndpointPropertyValue(resource, options, entriesByResourceName, endpointReference.Property(EndpointProperty.Url)), + EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint) => GetEndpointPropertyValue(resource, options, entriesByResourceName, endpointReferenceExpression), ParameterResource parameter => await GetParameterValueAsync(parameter, cancellationToken).ConfigureAwait(false), IResourceWithConnectionString connectionStringResource => await GetValueProviderValueAsync(connectionStringResource.ConnectionStringExpression, $"connection string for resource '{connectionStringResource.Name}'", cancellationToken).ConfigureAwait(false), _ => await GetValueProviderValueAsync(valueProvider, "reference expression value", cancellationToken).ConfigureAwait(false) @@ -192,7 +199,7 @@ private static async ValueTask GetValueProviderValueAsync(IValueProvider } } - private static bool TryGetVercelEnvironmentVariableValue( + private static bool TryGetEnvironmentVariableValue( IResource resource, VercelEnvironmentOptionsAnnotation options, IReadOnlyDictionary entriesByResourceName, @@ -204,13 +211,13 @@ private static bool TryGetVercelEnvironmentVariableValue( switch (value) { case EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference): - vercelValue = GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReference.Property(EndpointProperty.Url)); + vercelValue = GetEndpointPropertyValue(resource, options, entriesByResourceName, endpointReference.Property(EndpointProperty.Url)); return true; case EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint): - vercelValue = GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReferenceExpression); + vercelValue = GetEndpointPropertyValue(resource, options, entriesByResourceName, endpointReferenceExpression); return true; case ReferenceExpression referenceExpression when ContainsCrossResourceEndpointReference(resource, referenceExpression): - vercelValue = GetVercelReferenceExpressionValue(resource, options, entriesByResourceName, referenceExpression); + vercelValue = GetReferenceExpressionValue(resource, options, entriesByResourceName, referenceExpression); return true; default: vercelValue = null; @@ -218,7 +225,7 @@ private static bool TryGetVercelEnvironmentVariableValue( } } - private static string GetVercelReferenceExpressionValue( + private static string GetReferenceExpressionValue( IResource resource, VercelEnvironmentOptionsAnnotation options, IReadOnlyDictionary entriesByResourceName, @@ -235,8 +242,8 @@ private static string GetVercelReferenceExpressionValue( IValueProvider valueProvider = referenceExpression.ValueProviders[i]; arguments[i] = valueProvider switch { - EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference) => GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReference.Property(EndpointProperty.Url)), - EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint) => GetVercelEndpointPropertyValue(resource, options, entriesByResourceName, endpointReferenceExpression), + EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference) => GetEndpointPropertyValue(resource, options, entriesByResourceName, endpointReference.Property(EndpointProperty.Url)), + EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint) => GetEndpointPropertyValue(resource, options, entriesByResourceName, endpointReferenceExpression), // Mixed expressions can hide provider-specific ordering or secret semantics. // Keep this path to deterministic endpoint-only production URLs. _ => throw new DistributedApplicationException("Vercel endpoint reference expressions cannot be combined with parameters, secrets, or other value providers. Configure a concrete Vercel project environment variable instead.") @@ -251,7 +258,7 @@ EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndp return string.Format(CultureInfo.InvariantCulture, referenceExpression.Format, arguments); } - private static string GetVercelEndpointPropertyValue( + private static string GetEndpointPropertyValue( IResource resource, VercelEnvironmentOptionsAnnotation options, IReadOnlyDictionary entriesByResourceName, @@ -275,7 +282,7 @@ private static string GetVercelEndpointPropertyValue( $"Resource '{resource.Name}' references endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}', but Vercel endpoint references can only target external HTTP or HTTPS endpoints. Configure an external endpoint or remove the reference."); } - if (!IsHttpEndpoint(endpoint)) + if (!VercelDeploymentModel.IsHttpEndpoint(endpoint)) { throw new DistributedApplicationException( $"Resource '{resource.Name}' references endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}' with scheme '{endpoint.UriScheme}', but Vercel endpoint references support only HTTP or HTTPS endpoints."); @@ -287,7 +294,7 @@ private static string GetVercelEndpointPropertyValue( $"Resource '{resource.Name}' references endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}', but the referenced resource does not target this Vercel environment. Vercel endpoint references can only target workloads deployed to the same Vercel environment."); } - string host = $"{GetVercelProjectName(referencedEntry)}.vercel.app"; + string host = $"{VercelProjectNameResolver.GetProjectName(referencedEntry)}.vercel.app"; const int port = 443; return endpointReferenceExpression.Property switch @@ -325,28 +332,6 @@ private static void ValidateEnvironmentVariableName(IResource resource, string n } } - private static void ValidateUnsupportedRuntimeConfiguration( - IResource resource, - IExecutionConfigurationResult executionConfiguration) - { - // These Aspire concepts have no faithful Vercel Dockerfile-deploy equivalent in this - // preview. Rejecting them is safer than silently dropping entrypoint, args, or build - // values that would change the workload's deployed behavior. Aspire's built-in - // image build/push pipeline owns build-time Docker options; this validation only - // rejects runtime concepts the Vercel Build Output API path cannot preserve. - if (resource is ContainerResource { Entrypoint: not null }) - { - throw new DistributedApplicationException( - $"Resource '{resource.Name}' configures a container entrypoint, but Vercel Dockerfile deployments use the CMD/ENTRYPOINT from Aspire's publish output. Configure the workload's publish behavior or Vercel project settings instead."); - } - - if (executionConfiguration.ArgumentsWithUnprocessed.Any()) - { - throw new DistributedApplicationException( - $"Resource '{resource.Name}' configures Aspire command-line arguments, but Vercel Dockerfile deployments cannot override Docker CMD/ENTRYPOINT. Configure the workload's publish behavior or express the values as environment variables."); - } - } - private static bool ContainsSecretReference(object? value) { // Connection strings are treated as secret-bearing even when the underlying provider @@ -404,13 +389,4 @@ private static bool IsSameResource(IResource resource, IResource otherResource) // can recreate resource references across an RPC boundary, but resource name is the // app-model identity used by deployment target maps. => string.Equals(resource.Name, otherResource.Name, StringComparison.Ordinal); - - private static bool IsHttpEndpoint(EndpointAnnotation endpoint) - // URI scheme alone is not enough: Aspire can model a TCP transport with an HTTP - // URI scheme. Vercel's container ingress here is HTTP-family traffic over TCP. - => endpoint.Protocol == ProtocolType.Tcp - && (string.Equals(endpoint.UriScheme, "http", StringComparison.OrdinalIgnoreCase) - || string.Equals(endpoint.UriScheme, "https", StringComparison.OrdinalIgnoreCase)) - && (string.Equals(endpoint.Transport, "http", StringComparison.OrdinalIgnoreCase) - || string.Equals(endpoint.Transport, "http2", StringComparison.OrdinalIgnoreCase)); } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs index d3399ea4c..8e9ca64ec 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs @@ -43,7 +43,7 @@ public ReferenceExpression GetHostAddressExpression(EndpointReference endpointRe // Same-deploy references need an address that exists before `vercel deploy` // finishes. Production project aliases are deterministic; preview URLs are not. - string projectName = VercelDeploymentStep.GetVercelProjectName(endpointReference.Resource); + string projectName = VercelProjectNameResolver.GetProjectName(endpointReference.Resource); return ReferenceExpression.Create($"{projectName}.vercel.app"); } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelFileSystem.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelFileSystem.cs new file mode 100644 index 000000000..64c5d0dbf --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelFileSystem.cs @@ -0,0 +1,20 @@ +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal static class VercelFileSystem +{ + public static void DeleteFileIfExists(string path) + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + + public static void DeleteDirectoryIfExists(string path) + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelJson.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelJson.cs new file mode 100644 index 000000000..5b7b0934a --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelJson.cs @@ -0,0 +1,37 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal static class VercelJson +{ + public static string? GetStringProperty(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out var property) + && property.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(property.GetString()) + ? property.GetString() + : null; + + public static bool TryGetString(JsonElement element, string propertyName, [NotNullWhen(true)] out string? value) + { + value = GetStringProperty(element, propertyName); + return value is not null; + } + + public static JsonElement.ArrayEnumerator EnumerateArrayOrNamedArray(JsonElement root, string propertyName) + { + if (root.ValueKind == JsonValueKind.Array) + { + return root.EnumerateArray(); + } + + if (root.ValueKind == JsonValueKind.Object + && root.TryGetProperty(propertyName, out var array) + && array.ValueKind == JsonValueKind.Array) + { + return array.EnumerateArray(); + } + + throw new JsonException($"Expected JSON array or object property '{propertyName}'."); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelOidcToken.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelOidcToken.cs new file mode 100644 index 000000000..4b3973e06 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelOidcToken.cs @@ -0,0 +1,42 @@ +using System.Text.Json; +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal static class VercelOidcToken +{ + public static VercelOidcClaims DecodeUnvalidatedClaims(string token) + { + string[] parts = token.Split('.'); + if (parts.Length != 3) + { + throw new DistributedApplicationException("The Vercel OIDC token is not a valid compact JWT."); + } + + try + { + // This is an unvalidated decode of the Vercel-issued token from `vercel pull`. + // Docker/Vercel validate the token when it is used; here we only need routing + // metadata such as owner_id/project to construct the VCR login and repository. + byte[] payloadBytes = Convert.FromBase64String(PadBase64Url(parts[1])); + using var document = JsonDocument.Parse(payloadBytes); + var root = document.RootElement; + + return new( + VercelJson.GetStringProperty(root, "owner_id"), + VercelJson.GetStringProperty(root, "owner"), + VercelJson.GetStringProperty(root, "project"), + VercelJson.GetStringProperty(root, "project_id")); + } + catch (Exception ex) when (ex is FormatException or JsonException) + { + throw new DistributedApplicationException("The Vercel OIDC token payload could not be decoded.", ex); + } + } + + private static string PadBase64Url(string value) + { + string padded = value.Replace('-', '+').Replace('_', '/'); + return padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '='); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectEnvironment.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectEnvironment.cs new file mode 100644 index 000000000..ff54fc2bf --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectEnvironment.cs @@ -0,0 +1,24 @@ +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal static class VercelProjectEnvironment +{ + public static string GetName(VercelEnvironmentOptionsAnnotation options) + { + if (options.Production) + { + return "production"; + } + + return string.IsNullOrWhiteSpace(options.Target) ? "preview" : options.Target; + } + + public static string GetName(VercelDeploymentState state) + { + if (state.Production) + { + return "production"; + } + + return string.IsNullOrWhiteSpace(state.Target) ? "preview" : state.Target; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs new file mode 100644 index 000000000..6c46ad9ba --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs @@ -0,0 +1,170 @@ +#pragma warning disable ASPIRECOMPUTE003 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.ApplicationModel.Docker; +using Aspire.Hosting; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Text.Json; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal static class VercelProjectNameResolver +{ + private const int VercelProjectNameMaxLength = 100; + + public static string GetProjectName(VercelDeploymentEntry entry) + => GetProjectLink(entry).ProjectName; + + public static string GetProjectName(IResource resource) + { + if (resource.TryGetLastAnnotation(out var dockerfile)) + { + return GetProjectName(new VercelDeploymentEntry(resource, dockerfile.ContextPath, dockerfile.DockerfilePath, dockerfile)); + } + + if (resource is ProjectResource project) + { + string projectPath = project.GetProjectMetadata().ProjectPath; + string sourceRoot = Path.GetDirectoryName(projectPath) + ?? throw new DistributedApplicationException($"Project resource '{resource.Name}' has project path '{projectPath}' without a containing directory."); + return GetProjectName(new VercelDeploymentEntry(resource, sourceRoot)); + } + + throw new DistributedApplicationException($"Resource '{resource.Name}' targets Vercel but is not an Aspire image build resource. Use a .NET project, a workload integration that publishes Dockerfile metadata, call PublishAsDockerFile, or configure the resource with WithDockerfile, WithDockerfileFactory, or WithDockerfileBuilder."); + } + + public static VercelProjectLink GetProjectLink(VercelDeploymentEntry entry) + { + if (TryReadProjectLink(entry.SourceRoot, out var projectLink)) + { + return projectLink; + } + + return new(GetManagedProjectName(entry), ProjectId: null); + } + + public static string GetProjectOption(VercelDeploymentEntry entry) + { + var projectLink = GetProjectLink(entry); + return string.IsNullOrWhiteSpace(projectLink.ProjectId) + ? projectLink.ProjectName + : projectLink.ProjectId; + } + + public static bool HasProjectLinkFile(string sourceRoot) + => File.Exists(GetProjectJsonPath(sourceRoot)); + + public static bool IsValidProjectName(string projectName) + { + if (string.IsNullOrWhiteSpace(projectName) + || projectName.Length > VercelProjectNameMaxLength + || !IsLowercaseAsciiLetterOrDigit(projectName[0]) + || !IsLowercaseAsciiLetterOrDigit(projectName[^1])) + { + return false; + } + + return projectName.All(static character => + IsLowercaseAsciiLetterOrDigit(character) + || character == '-'); + } + + private static string GetManagedProjectName(VercelDeploymentEntry entry) + { + if (entry.Resource.TryGetLastAnnotation(out var options)) + { + return options.ProjectName; + } + + // The production endpoint contract is project-name based, so managed names must + // be stable and Vercel-valid before deploy starts. + string sourceRoot = Path.TrimEndingDirectorySeparator(entry.SourceRoot); + string sourceRootName = Path.GetFileName(sourceRoot); + + if (TryCreateProjectName(sourceRootName, out string? projectName) + || TryCreateProjectName(entry.Resource.Name, out projectName)) + { + return projectName; + } + + throw new DistributedApplicationException($"Could not infer a valid Vercel project name for resource '{entry.Resource.Name}' from source root '{entry.SourceRoot}'. Rename the source directory or link the source root to an existing Vercel project."); + } + + private static bool TryCreateProjectName(string? value, [NotNullWhen(true)] out string? projectName) + { + if (string.IsNullOrWhiteSpace(value)) + { + projectName = null; + return false; + } + + var builder = new StringBuilder(value.Length); + bool previousWasSeparator = false; + + foreach (char character in value) + { + if (IsAsciiLetterOrDigit(character)) + { + builder.Append(char.ToLowerInvariant(character)); + previousWasSeparator = false; + } + else if (!previousWasSeparator && builder.Length > 0) + { + builder.Append('-'); + previousWasSeparator = true; + } + } + + projectName = builder + .ToString() + .Trim('-'); + + if (projectName.Length > VercelProjectNameMaxLength) + { + projectName = projectName[..VercelProjectNameMaxLength].Trim('-'); + } + + if (projectName.Length == 0) + { + projectName = null; + return false; + } + + return true; + } + + private static bool TryReadProjectLink(string sourceRoot, [NotNullWhen(true)] out VercelProjectLink? projectLink) + { + string projectJsonPath = GetProjectJsonPath(sourceRoot); + + if (File.Exists(projectJsonPath)) + { + // Vercel CLI writes linked project identity as: + // .vercel/project.json: { "projectId": "...", "orgId": "...", "projectName": "..." } + // Treat it as user/provider ownership metadata rather than regenerating a managed + // name. Destroy preserves these linked projects and only removes tracked env vars. + using var document = JsonDocument.Parse(File.ReadAllText(projectJsonPath)); + string? projectName = VercelJson.GetStringProperty(document.RootElement, "projectName"); + + if (!string.IsNullOrWhiteSpace(projectName)) + { + projectLink = new(projectName, VercelJson.GetStringProperty(document.RootElement, "projectId")); + return true; + } + } + + projectLink = null; + return false; + } + + private static string GetProjectJsonPath(string sourceRoot) + => Path.Combine(sourceRoot, ".vercel", "project.json"); + + private static bool IsAsciiLetterOrDigit(char character) + => character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9'; + + private static bool IsLowercaseAsciiLetterOrDigit(char character) + => character is >= 'a' and <= 'z' or >= '0' and <= '9'; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectSettingsReader.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectSettingsReader.cs new file mode 100644 index 000000000..76653855a --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectSettingsReader.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal static class VercelProjectSettingsReader +{ + public static VercelPulledProjectSettings Read(string projectJsonPath, string projectJsonContent) + { + try + { + // `vercel pull` writes `.vercel/project.json`. Only project identity fields are + // needed: they select the linked provider project and are safe to persist in state. + using var document = JsonDocument.Parse(projectJsonContent); + var root = document.RootElement; + + string projectName = root.TryGetProperty("projectName", out var projectNameElement) && projectNameElement.ValueKind == JsonValueKind.String + ? projectNameElement.GetString() ?? string.Empty + : string.Empty; + string? projectId = root.TryGetProperty("projectId", out var projectIdElement) && projectIdElement.ValueKind == JsonValueKind.String + ? projectIdElement.GetString() + : null; + string? orgId = root.TryGetProperty("orgId", out var orgIdElement) && orgIdElement.ValueKind == JsonValueKind.String + ? orgIdElement.GetString() + : null; + + return new(projectName, projectId, orgId); + } + catch (JsonException ex) + { + throw new DistributedApplicationException($"Vercel project settings file '{projectJsonPath}' is invalid JSON.", ex); + } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceBuilderExtensions.cs index b2682042f..8d93f952e 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceBuilderExtensions.cs @@ -30,7 +30,7 @@ public static IResourceBuilder WithVercelProjectName( ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(projectName); - if (!VercelDeploymentStep.IsValidVercelProjectName(projectName)) + if (!VercelProjectNameResolver.IsValidProjectName(projectName)) { throw new ArgumentException( $"Vercel project name '{projectName}' is invalid. Use lowercase letters, digits, and hyphens; start and end with a letter or digit; and keep the name at most 100 characters.", From 7c6a783cb3d9b0d2e67bf310cd2427d02fe463b3 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Fri, 3 Jul 2026 00:11:18 -0700 Subject: [PATCH 25/33] Clean up Vercel deployment components Remove the compatibility helper facade so tests exercise the extracted components directly. Centralize shared Vercel protocol constants, remove trivial wrappers, and keep state serialization in the state store. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../VercelBuildOutputWriter.cs | 13 +- .../VercelCliArguments.cs | 9 +- .../VercelConstants.cs | 19 ++ .../VercelDeploymentModel.cs | 10 +- .../VercelDeploymentPaths.cs | 7 - .../VercelDeploymentPlanWriter.cs | 4 +- .../VercelDeploymentStateStore.cs | 14 +- .../VercelDeploymentStep.BuildOutput.cs | 25 ++- .../VercelDeploymentStep.ComponentFacade.cs | 100 ---------- .../VercelDeploymentStep.State.cs | 12 +- .../VercelDeploymentStep.Types.cs | 5 +- .../VercelDeploymentStep.cs | 36 +--- .../VercelFileSystem.cs | 20 -- .../VercelProjectNameResolver.cs | 10 +- .../VercelEnvironmentTests.cs | 182 +++++++++--------- 15 files changed, 175 insertions(+), 291 deletions(-) create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelConstants.cs delete mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPaths.cs delete mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.ComponentFacade.cs delete mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelFileSystem.cs diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelBuildOutputWriter.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelBuildOutputWriter.cs index e10711297..5019e39c3 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelBuildOutputWriter.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelBuildOutputWriter.cs @@ -5,11 +5,6 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; internal static class VercelBuildOutputWriter { - private const int VercelBuildOutputApiVersion = 3; - private const string VercelDirectoryName = ".vercel"; - private const string VercelOutputDirectoryName = "output"; - private const string VercelProjectFileName = "project.json"; - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true @@ -30,16 +25,16 @@ public static async Task WriteAsync( // { "runtime": "container", "handler": "@sha256:..." } // There is intentionally no user source copy here; Aspire's build/push pipeline has // already built the image, and Vercel deploy uploads only metadata that points at it. - string vercelDirectory = Path.Combine(entry.DeployDirectory, VercelDirectoryName); - string outputDirectory = Path.Combine(vercelDirectory, VercelOutputDirectoryName); + string vercelDirectory = Path.Combine(entry.EffectiveDeployDirectory, VercelConstants.DirectoryName); + string outputDirectory = Path.Combine(vercelDirectory, VercelConstants.OutputDirectoryName); string functionDirectory = Path.Combine(outputDirectory, "functions", "index.func"); Directory.CreateDirectory(functionDirectory); - await File.WriteAllTextAsync(Path.Combine(vercelDirectory, VercelProjectFileName), project.ProjectJsonContent, cancellationToken).ConfigureAwait(false); + await File.WriteAllTextAsync(Path.Combine(vercelDirectory, VercelConstants.ProjectFileName), project.ProjectJsonContent, cancellationToken).ConfigureAwait(false); var outputConfig = new JsonObject { - ["version"] = VercelBuildOutputApiVersion, + ["version"] = VercelConstants.BuildOutputApiVersion, ["routes"] = new JsonArray { new JsonObject diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliArguments.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliArguments.cs index 1d5585afc..7278c5b8a 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliArguments.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliArguments.cs @@ -2,11 +2,8 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; internal static class VercelCliArguments { - private const string VcrRegistry = "vcr.vercel.com"; - private const string VercelContainerServiceName = "app"; - public static string[] BuildDeployArguments(VercelEnvironmentOptionsAnnotation options, VercelDeploymentEntry entry) - => BuildDeployArguments(options, VercelDeploymentPaths.GetDeployDirectory(entry), VercelProjectNameResolver.GetProjectOption(entry), environmentVariables: []); + => BuildDeployArguments(options, entry.EffectiveDeployDirectory, VercelProjectNameResolver.GetProjectOption(entry), environmentVariables: []); public static string[] BuildDockerInspectDigestArguments(string imageReference) => ["buildx", "imagetools", "inspect", "--format", "{{json .Manifest}}", imageReference]; @@ -135,7 +132,7 @@ public static string[] BuildPullProjectSettingsArguments( } public static string[] BuildDockerLoginArguments(string username) - => ["login", VcrRegistry, "--username", username, "--password-stdin"]; + => ["login", VercelConstants.VcrRegistry, "--username", username, "--password-stdin"]; public static string[] BuildInspectDeploymentArguments(VercelEnvironmentOptionsAnnotation options, string deploymentUrl) { @@ -220,7 +217,7 @@ public static string BuildDisplayDeployCommand( .Select(static environmentVariable => new KeyValuePair(environmentVariable.Key, "")) .ToArray(); - string displayImage = $"vcr.vercel.com///{VercelContainerServiceName}:"; + string displayImage = $"{VercelConstants.VcrRegistry}///{VercelConstants.ContainerServiceName}:"; string displayDeployDirectory = $"<{resourceName}-build-output>"; string displayProject = $"<{resourceName}-vercel-project>"; return $"vercel pull --cwd <{resourceName}-vercel-project-link> --yes --environment {VercelProjectEnvironment.GetName(options)} && aspire build/push {resourceName} -> {displayImage} && docker {string.Join(" ", BuildDockerInspectDigestArguments(displayImage))} && vercel {string.Join(" ", BuildDeployArguments(options, displayDeployDirectory, displayProject, displayEnvironmentVariables))}"; diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelConstants.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelConstants.cs new file mode 100644 index 000000000..108e42be1 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelConstants.cs @@ -0,0 +1,19 @@ +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +internal static class VercelConstants +{ + internal const string DeploymentPlanFileName = "vercel-deployments.json"; + internal const string StateSectionNamePrefix = "communitytoolkit.vercel."; + internal const int DeploymentStateSchemaVersion = 1; + internal const int ProjectNameMaxLength = 100; + internal const string CliFileName = "vercel"; + internal const string DockerCliFileName = "docker"; + internal const string VcrRegistry = "vcr.vercel.com"; + internal const string JsonFileName = "vercel.json"; + internal const string ProjectFileName = "project.json"; + internal const string DirectoryName = ".vercel"; + internal const string OutputDirectoryName = "output"; + internal const string OidcTokenEnvironmentVariable = "VERCEL_OIDC_TOKEN"; + internal const string ContainerServiceName = "app"; + internal const int BuildOutputApiVersion = 3; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs index d14d67b29..4fdbcc079 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs @@ -18,8 +18,6 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; internal static class VercelDeploymentModel { - private const string VercelJsonFileName = "vercel.json"; - private static readonly string[] VercelJsonBuildOutputUnsupportedKeys = [ "build", @@ -136,7 +134,7 @@ public static async Task ValidateVercelJsonAsync( string sourceRoot, CancellationToken cancellationToken) { - string vercelJsonPath = Path.Combine(sourceRoot, VercelJsonFileName); + string vercelJsonPath = Path.Combine(sourceRoot, VercelConstants.JsonFileName); if (!File.Exists(vercelJsonPath)) { return; @@ -146,18 +144,18 @@ public static async Task ValidateVercelJsonAsync( try { root = JsonNode.Parse(await File.ReadAllTextAsync(vercelJsonPath, cancellationToken).ConfigureAwait(false)) as JsonObject - ?? throw new DistributedApplicationException($"Resource '{resource.Name}' source root contains '{VercelJsonFileName}', but it is not a JSON object."); + ?? throw new DistributedApplicationException($"Resource '{resource.Name}' source root contains '{VercelConstants.JsonFileName}', but it is not a JSON object."); } catch (JsonException ex) { - throw new DistributedApplicationException($"Resource '{resource.Name}' source root contains invalid '{VercelJsonFileName}'.", ex); + throw new DistributedApplicationException($"Resource '{resource.Name}' source root contains invalid '{VercelConstants.JsonFileName}'.", ex); } var unsupportedKey = VercelJsonBuildOutputUnsupportedKeys.FirstOrDefault(root.ContainsKey); if (unsupportedKey is not null) { throw new DistributedApplicationException( - $"Resource '{resource.Name}' source root contains '{VercelJsonFileName}' with top-level '{unsupportedKey}', but the Aspire Vercel integration owns the generated Build Output API container function and catch-all routing configuration. Move that setting into the Dockerfile, AppHost environment variables, or Vercel project settings before deploying with the Aspire Vercel integration."); + $"Resource '{resource.Name}' source root contains '{VercelConstants.JsonFileName}' with top-level '{unsupportedKey}', but the Aspire Vercel integration owns the generated Build Output API container function and catch-all routing configuration. Move that setting into the Dockerfile, AppHost environment variables, or Vercel project settings before deploying with the Aspire Vercel integration."); } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPaths.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPaths.cs deleted file mode 100644 index c1caf1475..000000000 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPaths.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace CommunityToolkit.Aspire.Hosting.Vercel; - -internal static class VercelDeploymentPaths -{ - public static string GetDeployDirectory(VercelDeploymentEntry entry) - => string.IsNullOrWhiteSpace(entry.DeployDirectory) ? entry.SourceRoot : entry.DeployDirectory; -} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPlanWriter.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPlanWriter.cs index c06a1df09..658f2ed4c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPlanWriter.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPlanWriter.cs @@ -55,7 +55,7 @@ await CreateEntriesAsync( entries, cancellationToken).ConfigureAwait(false)); - string planPath = Path.Combine(outputDirectory, VercelDeploymentStep.DeploymentPlanFileName); + string planPath = Path.Combine(outputDirectory, VercelConstants.DeploymentPlanFileName); await using FileStream stream = File.Create(planPath); await JsonSerializer.SerializeAsync(stream, plan, JsonOptions, cancellationToken).ConfigureAwait(false); @@ -97,7 +97,7 @@ public static async Task BuildDeployArgumentsAsync( resolveProjectEnvironmentVariableValues: false, cancellationToken).ConfigureAwait(false); - return VercelCliArguments.BuildDeployArguments(options, VercelDeploymentPaths.GetDeployDirectory(entry), VercelProjectNameResolver.GetProjectOption(entry), environmentConfiguration.DeploymentEnvironmentVariables); + return VercelCliArguments.BuildDeployArguments(options, entry.EffectiveDeployDirectory, VercelProjectNameResolver.GetProjectOption(entry), environmentConfiguration.DeploymentEnvironmentVariables); } public static async Task GetEnvironmentConfigurationAsync( diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStateStore.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStateStore.cs index b1db3e9b0..caefddf09 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStateStore.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStateStore.cs @@ -13,9 +13,6 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; internal static class VercelDeploymentStateStore { - private const string StateSectionNamePrefix = "communitytoolkit.vercel."; - private const int DeploymentStateSchemaVersion = 1; - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true @@ -73,7 +70,7 @@ public static async Task SaveEntryAsync( ? Create(environment, options, [deployment]) : Merge(environment, options, existingState, deployment); - stateSection.SetValue(JsonSerializer.Serialize(state, JsonOptions)); + stateSection.SetValue(Serialize(state)); await stateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); } @@ -107,7 +104,7 @@ public static void Validate( VercelEnvironmentOptionsAnnotation options, VercelDeploymentState state) { - if (state.SchemaVersion != DeploymentStateSchemaVersion) + if (state.SchemaVersion != VercelConstants.DeploymentStateSchemaVersion) { throw new DistributedApplicationException($"Vercel deployment state for environment '{environment.Name}' uses unsupported schema version '{state.SchemaVersion}'. Redeploy the environment before destroying it."); } @@ -134,7 +131,7 @@ public static VercelDeploymentState RemoveManagedProject(VercelDeploymentState s .ToArray() }; - public static string GetSectionName(VercelEnvironmentResource environment) => $"{StateSectionNamePrefix}{environment.Name}"; + public static string GetSectionName(VercelEnvironmentResource environment) => $"{VercelConstants.StateSectionNamePrefix}{environment.Name}"; public static string? GetProductionUrl(VercelEnvironmentOptionsAnnotation options, string projectName) => options.Production ? $"https://{projectName}.vercel.app" : null; @@ -144,7 +141,7 @@ private static VercelDeploymentState Create( VercelEnvironmentOptionsAnnotation options, VercelDeploymentStateEntry[] deployments) => new( - DeploymentStateSchemaVersion, + VercelConstants.DeploymentStateSchemaVersion, environment.Name, NormalizeScope(options.Scope), NormalizeTarget(options.Target), @@ -177,6 +174,9 @@ .. existingState.Deployments.Where(existing => : value.Deserialize(JsonOptions); } + public static string Serialize(VercelDeploymentState state) + => JsonSerializer.Serialize(state, JsonOptions); + private static string? NormalizeScope(string? scope) => string.IsNullOrWhiteSpace(scope) ? null : scope; diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.BuildOutput.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.BuildOutput.cs index 7a03e662b..8f3ee2e8d 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.BuildOutput.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.BuildOutput.cs @@ -17,7 +17,10 @@ private static async Task PrepareProjectEnvironmentDirectoryAsync( { var outputService = context.Services.GetRequiredService(); string projectLinkDirectory = Path.Combine(outputService.GetTempDirectory(entry.Resource), ".vercel-project"); - VercelFileSystem.DeleteDirectoryIfExists(projectLinkDirectory); + if (Directory.Exists(projectLinkDirectory)) + { + Directory.Delete(projectLinkDirectory, recursive: true); + } Directory.CreateDirectory(projectLinkDirectory); // `vercel env add` is project-scoped but intentionally does not accept --project. @@ -52,8 +55,8 @@ private static async Task PullProjectSettingsAsync( throw CreateCliException($"pull Vercel project settings for resource '{entry.Resource.Name}'", VercelCliFileName, result); } - string vercelDirectory = Path.Combine(projectLinkDirectory, VercelDirectoryName); - string projectJsonPath = Path.Combine(vercelDirectory, VercelProjectFileName); + string vercelDirectory = Path.Combine(projectLinkDirectory, VercelConstants.DirectoryName); + string projectJsonPath = Path.Combine(vercelDirectory, VercelConstants.ProjectFileName); string environmentPath = Path.Combine(vercelDirectory, $".env.{targetEnvironment}.local"); if (!File.Exists(projectJsonPath)) @@ -67,10 +70,10 @@ private static async Task PullProjectSettingsAsync( } var environmentVariables = VercelDotEnvParser.Parse(await File.ReadAllLinesAsync(environmentPath, context.CancellationToken).ConfigureAwait(false)); - if (!environmentVariables.TryGetValue(VercelOidcTokenEnvironmentVariable, out string? oidcToken) + if (!environmentVariables.TryGetValue(VercelConstants.OidcTokenEnvironmentVariable, out string? oidcToken) || string.IsNullOrWhiteSpace(oidcToken)) { - throw new DistributedApplicationException($"Vercel pull did not provide {VercelOidcTokenEnvironmentVariable}, which is required to authenticate local Docker builds to VCR."); + throw new DistributedApplicationException($"Vercel pull did not provide {VercelConstants.OidcTokenEnvironmentVariable}, which is required to authenticate local Docker builds to VCR."); } string projectJsonContent = await File.ReadAllTextAsync(projectJsonPath, context.CancellationToken).ConfigureAwait(false); @@ -79,8 +82,16 @@ private static async Task PullProjectSettingsAsync( // `vercel pull` materializes project secrets next to the scratch link so local // builders can read them. This integration only needs the short-lived OIDC token // and project metadata; delete the env files before creating deploy artifacts. - VercelFileSystem.DeleteFileIfExists(environmentPath); - VercelFileSystem.DeleteFileIfExists(Path.Combine(projectLinkDirectory, ".env.local")); + if (File.Exists(environmentPath)) + { + File.Delete(environmentPath); + } + + string localEnvironmentPath = Path.Combine(projectLinkDirectory, ".env.local"); + if (File.Exists(localEnvironmentPath)) + { + File.Delete(localEnvironmentPath); + } return new(project.ProjectName, project.ProjectId, project.OrgId, projectJsonContent, oidcToken); } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.ComponentFacade.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.ComponentFacade.cs deleted file mode 100644 index 144b6fc25..000000000 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.ComponentFacade.cs +++ /dev/null @@ -1,100 +0,0 @@ -#pragma warning disable ASPIRECOMPUTE003 -#pragma warning disable CTASPIREVERCEL001 - -using Aspire.Hosting.ApplicationModel; -using System.Diagnostics.CodeAnalysis; - -namespace CommunityToolkit.Aspire.Hosting.Vercel; - -internal static partial class VercelDeploymentStep -{ - internal static IEnumerable GetDeploymentEntries(DistributedApplicationModel model, VercelEnvironmentResource environment) - => VercelDeploymentModel.GetEntries(model, environment); - - internal static string[] BuildDeployArguments(VercelEnvironmentOptionsAnnotation options, VercelDeploymentEntry entry) - => VercelCliArguments.BuildDeployArguments(options, entry); - - internal static string[] BuildDockerInspectDigestArguments(string imageReference) - => VercelCliArguments.BuildDockerInspectDigestArguments(imageReference); - - internal static string[] BuildDestroyProjectArguments(VercelEnvironmentOptionsAnnotation options, string projectName) - => VercelCliArguments.BuildDestroyProjectArguments(options, projectName); - - internal static string[] BuildListProjectEnvironmentVariablesArguments( - VercelEnvironmentOptionsAnnotation options, - string projectLinkDirectory, - string targetEnvironment) - => VercelCliArguments.BuildListProjectEnvironmentVariablesArguments(options, projectLinkDirectory, targetEnvironment); - - internal static string[] BuildAddProjectEnvironmentVariableArguments( - VercelEnvironmentOptionsAnnotation options, - string projectLinkDirectory, - string name, - string targetEnvironment) - => VercelCliArguments.BuildAddProjectEnvironmentVariableArguments(options, projectLinkDirectory, name, targetEnvironment); - - internal static string[] BuildRemoveProjectEnvironmentVariableArguments( - VercelEnvironmentOptionsAnnotation options, - string projectLinkDirectory, - string name, - string targetEnvironment) - => VercelCliArguments.BuildRemoveProjectEnvironmentVariableArguments(options, projectLinkDirectory, name, targetEnvironment); - - internal static string[] BuildLinkProjectArguments( - VercelEnvironmentOptionsAnnotation options, - string projectLinkDirectory, - string projectNameOrId) - => VercelCliArguments.BuildLinkProjectArguments(options, projectLinkDirectory, projectNameOrId); - - internal static string[] BuildValidateScopeArguments(VercelEnvironmentOptionsAnnotation options) - => VercelCliArguments.BuildValidateScopeArguments(options); - - internal static string[] BuildListProjectsArguments(VercelEnvironmentOptionsAnnotation options, string? filter = null) - => VercelCliArguments.BuildListProjectsArguments(options, filter); - - internal static string[] BuildInspectDeploymentArguments(VercelEnvironmentOptionsAnnotation options, string deploymentUrl) - => VercelCliArguments.BuildInspectDeploymentArguments(options, deploymentUrl); - - internal static string GetDockerImageDigest(string output) - => VercelDockerImageDigestParser.GetDigest(output); - - internal static VercelOidcClaims DecodeUnvalidatedOidcClaims(string token) - => VercelOidcToken.DecodeUnvalidatedClaims(token); - - internal static Dictionary ParseDotEnvFile(IEnumerable lines) - => VercelDotEnvParser.Parse(lines); - - internal static bool ProjectListContainsProject(string standardOutput, string projectName) - => VercelCliOutputParser.ProjectListContainsProject(standardOutput, projectName); - - internal static bool EnvironmentVariableListContainsName(string standardOutput, string name) - => VercelCliOutputParser.EnvironmentVariableListContainsName(standardOutput, name); - - internal static string GetDeploymentUrl(string standardOutput) - => VercelCliOutputParser.GetDeploymentUrl(standardOutput); - - internal static VercelDeploymentResult GetDeploymentResult(string standardOutput) - => VercelCliOutputParser.GetDeploymentResult(standardOutput); - - internal static VercelDeploymentInspection GetDeploymentInspection(string standardOutput) - => VercelCliOutputParser.GetDeploymentInspection(standardOutput); - - internal static bool TryGetVercelCliVersion(string output, [NotNullWhen(true)] out Version? version) - => VercelCliOutputParser.TryGetCliVersion(output, out version); - - internal static string GetVercelProjectName(VercelDeploymentEntry entry) - => VercelProjectNameResolver.GetProjectName(entry); - - internal static string GetVercelProjectName(IResource resource) - => VercelProjectNameResolver.GetProjectName(resource); - - internal static bool IsValidVercelProjectName(string projectName) - => VercelProjectNameResolver.IsValidProjectName(projectName); - - internal static Task WriteBuildOutputAsync( - VercelDeploymentEntry entry, - VercelPulledProject project, - string imageReference, - CancellationToken cancellationToken) - => VercelBuildOutputWriter.WriteAsync(entry, project, imageReference, cancellationToken); -} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.State.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.State.cs index 3bf0f4ea0..1f28f762f 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.State.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.State.cs @@ -104,7 +104,7 @@ await RemoveLinkedProjectEnvironmentVariablesAsync( state = VercelDeploymentStateStore.RemoveManagedProject(state, projectName); // Save after each project removal so a later CLI failure leaves retryable state // for projects that still exist instead of forgetting partially cleaned resources. - stateSection.SetValue(JsonSerializer.Serialize(state, JsonOptions)); + stateSection.SetValue(VercelDeploymentStateStore.Serialize(state)); await stateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); } @@ -234,7 +234,10 @@ private static async Task RemoveLinkedProjectEnvironmentVariablesAsync( { var outputService = context.Services.GetRequiredService(); string projectLinkDirectory = Path.Combine(outputService.GetTempDirectory(environment), ".vercel-projects", deployment.ProjectName); - VercelFileSystem.DeleteDirectoryIfExists(projectLinkDirectory); + if (Directory.Exists(projectLinkDirectory)) + { + Directory.Delete(projectLinkDirectory, recursive: true); + } Directory.CreateDirectory(projectLinkDirectory); try @@ -257,7 +260,10 @@ await RemoveProjectEnvironmentVariablesAsync( } finally { - VercelFileSystem.DeleteDirectoryIfExists(projectLinkDirectory); + if (Directory.Exists(projectLinkDirectory)) + { + Directory.Delete(projectLinkDirectory, recursive: true); + } } } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Types.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Types.cs index 3eb4fa18a..96c4066a5 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Types.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Types.cs @@ -13,7 +13,10 @@ internal sealed record VercelDeploymentEntry( string? DockerfilePath = null, DockerfileBuildAnnotation? Dockerfile = null, string TempDirectory = "", - string DeployDirectory = ""); + string DeployDirectory = "") +{ + public string EffectiveDeployDirectory => string.IsNullOrWhiteSpace(DeployDirectory) ? SourceRoot : DeployDirectory; +} internal sealed record VercelDeploymentPlan(string Environment, VercelDeploymentPlanEntry[] Deployments); diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs index 4d871ee09..78ae36a02 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs @@ -17,8 +17,6 @@ using System.Globalization; using System.Net.Sockets; using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; using System.Text.RegularExpressions; namespace CommunityToolkit.Aspire.Hosting.Vercel; @@ -45,22 +43,12 @@ internal static partial class VercelDeploymentStep public const string DeployStepNamePrefix = "vercel-deploy-prebuilt-"; public const string DestroyPrereqStepNamePrefix = "vercel-prepare-destroy-"; public const string DestroyStepNamePrefix = "vercel-destroy-"; - public const string DeploymentPlanFileName = "vercel-deployments.json"; - - private const string StateSectionNamePrefix = "communitytoolkit.vercel."; - private const int DeploymentStateSchemaVersion = 1; - private const int VercelProjectNameMaxLength = 100; - private const string VercelCliFileName = "vercel"; - internal const string DockerCliFileName = "docker"; - private const string VcrRegistry = "vcr.vercel.com"; - private const string VercelJsonFileName = "vercel.json"; - private const string VercelProjectFileName = "project.json"; - private const string VercelDirectoryName = ".vercel"; - private const string VercelOutputDirectoryName = "output"; - private const string VercelOidcTokenEnvironmentVariable = "VERCEL_OIDC_TOKEN"; - private const string VercelContainerServiceName = "app"; + + private const string VercelCliFileName = VercelConstants.CliFileName; + internal const string DockerCliFileName = VercelConstants.DockerCliFileName; + private const string VcrRegistry = VercelConstants.VcrRegistry; + private const string VercelContainerServiceName = VercelConstants.ContainerServiceName; private const string PushPrereqStepName = "push-prereq"; - private const int VercelBuildOutputApiVersion = 3; private static readonly Version MinimumVercelCliVersion = new(54, 18, 6); // These keys either bypass the generated Build Output API contract or configure // routing/build/runtime/env behavior that Aspire must own so endpoint refs and @@ -85,13 +73,6 @@ internal static partial class VercelDeploymentStep "routes", "services" ]; - - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) - { - WriteIndented = true - }; - - public static async Task ValidatePrerequisitesAsync(PipelineStepContext context, VercelEnvironmentResource environment) { // This is the deploy prerequisite step, not merely static validation. It intentionally @@ -413,7 +394,10 @@ await ConfigureProjectEnvironmentVariablesAsync( // `vercel pull` can write provider-owned env/config files into the linked // directory. They may contain project secrets unrelated to the AppHost, so the // scratch link is never copied, logged, stored in state, or left on disk. - VercelFileSystem.DeleteDirectoryIfExists(projectLinkDirectory); + if (Directory.Exists(projectLinkDirectory)) + { + Directory.Delete(projectLinkDirectory, recursive: true); + } } } @@ -570,7 +554,7 @@ private static VercelDeploymentStateEntry CreateSuccessfulDeploymentStateEntry( { ProductionUrl = productionUrl, VcrImageDigest = image.Digest, - BuildOutputApiVersion = VercelBuildOutputApiVersion, + BuildOutputApiVersion = VercelConstants.BuildOutputApiVersion, ProjectEnvironmentVariables = [.. projectContext.EnvironmentConfiguration.ProjectEnvironmentVariables .Select(static variable => variable.Key) .Order(StringComparer.Ordinal)] diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelFileSystem.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelFileSystem.cs deleted file mode 100644 index 64c5d0dbf..000000000 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelFileSystem.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace CommunityToolkit.Aspire.Hosting.Vercel; - -internal static class VercelFileSystem -{ - public static void DeleteFileIfExists(string path) - { - if (File.Exists(path)) - { - File.Delete(path); - } - } - - public static void DeleteDirectoryIfExists(string path) - { - if (Directory.Exists(path)) - { - Directory.Delete(path, recursive: true); - } - } -} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs index 6c46ad9ba..ce09cb8f1 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs @@ -12,8 +12,6 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; internal static class VercelProjectNameResolver { - private const int VercelProjectNameMaxLength = 100; - public static string GetProjectName(VercelDeploymentEntry entry) => GetProjectLink(entry).ProjectName; @@ -59,7 +57,7 @@ public static bool HasProjectLinkFile(string sourceRoot) public static bool IsValidProjectName(string projectName) { if (string.IsNullOrWhiteSpace(projectName) - || projectName.Length > VercelProjectNameMaxLength + || projectName.Length > VercelConstants.ProjectNameMaxLength || !IsLowercaseAsciiLetterOrDigit(projectName[0]) || !IsLowercaseAsciiLetterOrDigit(projectName[^1])) { @@ -121,9 +119,9 @@ private static bool TryCreateProjectName(string? value, [NotNullWhen(true)] out .ToString() .Trim('-'); - if (projectName.Length > VercelProjectNameMaxLength) + if (projectName.Length > VercelConstants.ProjectNameMaxLength) { - projectName = projectName[..VercelProjectNameMaxLength].Trim('-'); + projectName = projectName[..VercelConstants.ProjectNameMaxLength].Trim('-'); } if (projectName.Length == 0) @@ -160,7 +158,7 @@ private static bool TryReadProjectLink(string sourceRoot, [NotNullWhen(true)] ou } private static string GetProjectJsonPath(string sourceRoot) - => Path.Combine(sourceRoot, ".vercel", "project.json"); + => Path.Combine(sourceRoot, VercelConstants.DirectoryName, VercelConstants.ProjectFileName); private static bool IsAsciiLetterOrDigit(char character) => character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9'; diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs index 98d4962f6..f6e44fe2b 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs @@ -83,7 +83,7 @@ public void PublishModeAddsVercelEnvironmentAndDiscoversDockerfileResource() Assert.True(api.TryGetLastAnnotation(out var dockerfile)); Assert.Equal(sourceRoot.Path, dockerfile.ContextPath); Assert.Equal(Path.Combine(sourceRoot.Path, "Dockerfile"), dockerfile.DockerfilePath); - var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); Assert.Same(api, entry.Resource); var options = environment.GetVercelOptions(); @@ -228,7 +228,7 @@ COPY index.html /usr/share/nginx/html/index.html var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); var api = Assert.Single(model.Resources.OfType()); - Assert.NotNull(Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)).Dockerfile!.DockerfileFactory); + Assert.NotNull(Assert.Single(VercelDeploymentModel.GetEntries(model, environment)).Dockerfile!.DockerfileFactory); var steps = await CreateConfiguredPipelineStepsAsync(builder, app); var buildStep = Assert.Single(steps, step => step.Name == "build-api"); @@ -279,7 +279,7 @@ public async Task VercelPipelineStepActionsCanBeUnitTested() await deployStep.Action(context); await destroyStep.Action(context); - Assert.True(File.Exists(Path.Combine(outputRoot.Path, VercelDeploymentStep.DeploymentPlanFileName))); + Assert.True(File.Exists(Path.Combine(outputRoot.Path, VercelConstants.DeploymentPlanFileName))); Assert.Contains(runner.Invocations, static invocation => invocation.FileName == "docker" && invocation.Arguments.Contains("imagetools")); Assert.Contains(runner.Invocations, static invocation => invocation.FileName == "vercel" && invocation.Arguments.Contains("deploy")); Assert.Contains(runner.Invocations, static invocation => invocation.FileName == "vercel" && invocation.Arguments is ["project", "remove", ..]); @@ -443,7 +443,7 @@ public void PublishProjectAsDockerFileIsDiscoveredInPublishMode() Assert.True(api.TryGetLastAnnotation(out var dockerfile)); Assert.Equal(sourceRoot.Path, dockerfile.ContextPath); Assert.Equal(Path.Combine(sourceRoot.Path, "Dockerfile"), dockerfile.DockerfilePath); - Assert.Same(api, Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)).Resource); + Assert.Same(api, Assert.Single(VercelDeploymentModel.GetEntries(model, environment)).Resource); } [Fact] @@ -463,7 +463,7 @@ public void ProjectResourceIsDiscoveredInPublishMode() var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); Assert.Same(projectResource, entry.Resource); Assert.Equal(sourceRoot.Path, entry.SourceRoot); Assert.Null(entry.Dockerfile); @@ -485,7 +485,7 @@ public void PublishExecutableAsDockerFileIsDiscoveredInPublishMode() var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); Assert.IsAssignableFrom(entry.Resource); Assert.Equal(sourceRoot.Path, entry.SourceRoot); Assert.Equal(Path.Combine(sourceRoot.Path, "Dockerfile"), entry.DockerfilePath); @@ -512,7 +512,7 @@ COPY server.mjs . var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); Assert.IsAssignableFrom(entry.Resource); Assert.Equal(sourceRoot.Path, entry.SourceRoot); Assert.NotNull(entry.Dockerfile!.DockerfileFactory); @@ -533,7 +533,7 @@ public void DockerfileContainerIsDiscoveredInPublishMode() var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); Assert.Equal(sourceRoot.Path, entry.SourceRoot); Assert.Equal(Path.Combine(sourceRoot.Path, "Dockerfile.custom"), entry.DockerfilePath); } @@ -561,8 +561,8 @@ public void ExplicitComputeEnvironmentSelectsVercelResourceWhenMultipleExist() var vercelEnvironment = Assert.Single(model.Resources.OfType(), resource => resource.Name == "vercel"); var otherEnvironment = Assert.Single(model.Resources.OfType(), resource => resource.Name == "other"); - var vercelEntry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, vercelEnvironment)); - var otherEntry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, otherEnvironment)); + var vercelEntry = Assert.Single(VercelDeploymentModel.GetEntries(model, vercelEnvironment)); + var otherEntry = Assert.Single(VercelDeploymentModel.GetEntries(model, otherEnvironment)); Assert.Equal("api", vercelEntry.Resource.Name); Assert.Equal("worker", otherEntry.Resource.Name); @@ -583,7 +583,7 @@ public void UntargetedResourcesAreNotImplicitlySelectedWhenMultipleComputeEnviro var model = app.Services.GetRequiredService(); var environments = model.Resources.OfType().ToArray(); - Assert.All(environments, environment => Assert.Empty(VercelDeploymentStep.GetDeploymentEntries(model, environment))); + Assert.All(environments, environment => Assert.Empty(VercelDeploymentModel.GetEntries(model, environment))); } [Fact] @@ -604,7 +604,7 @@ public async Task WriteDeploymentPlanWritesExpectedJson() string planPath = await VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken); - Assert.Equal(Path.Combine(outputRoot.Path, VercelDeploymentStep.DeploymentPlanFileName), planPath); + Assert.Equal(Path.Combine(outputRoot.Path, VercelConstants.DeploymentPlanFileName), planPath); using var document = JsonDocument.Parse(File.ReadAllText(planPath)); var root = document.RootElement; Assert.Equal("vercel", root.GetProperty("environment").GetString()); @@ -641,7 +641,7 @@ public async Task WriteDeploymentPlanPipelineStepWritesSummary() var summary = Assert.Single(context.Summary.Items); Assert.Equal("Vercel deployment plan", summary.Key); - Assert.Equal(Path.Combine(outputRoot.Path, VercelDeploymentStep.DeploymentPlanFileName), summary.Value); + Assert.Equal(Path.Combine(outputRoot.Path, VercelConstants.DeploymentPlanFileName), summary.Value); } [Fact] @@ -1132,7 +1132,7 @@ public void BuildDeployArgumentsIncludesConfiguredOptions() }; var entry = CreateDeploymentEntry("/repo/src/api"); - string[] arguments = VercelDeploymentStep.BuildDeployArguments(options, entry); + string[] arguments = VercelCliArguments.BuildDeployArguments(options, entry); Assert.Equal(["deploy", "--scope", "team", "--cwd", "/repo/src/api", "--project", "api", "--prebuilt", "--yes", "--prod"], arguments); } @@ -1140,7 +1140,7 @@ public void BuildDeployArgumentsIncludesConfiguredOptions() [Fact] public void BuildDockerInspectDigestArgumentsUsesBuildxImagetools() { - string[] arguments = VercelDeploymentStep.BuildDockerInspectDigestArguments("vcr.vercel.com/team/project/app:tag"); + string[] arguments = VercelCliArguments.BuildDockerInspectDigestArguments("vcr.vercel.com/team/project/app:tag"); Assert.Equal(["buildx", "imagetools", "inspect", "--format", "{{json .Manifest}}", "vcr.vercel.com/team/project/app:tag"], arguments); } @@ -1148,7 +1148,7 @@ public void BuildDockerInspectDigestArgumentsUsesBuildxImagetools() [Fact] public void GetDockerImageDigestParsesJsonString() { - string digest = VercelDeploymentStep.GetDockerImageDigest($"\"{FakeVercelCliRunner.FakeImageDigest}\""); + string digest = VercelDockerImageDigestParser.GetDigest($"\"{FakeVercelCliRunner.FakeImageDigest}\""); Assert.Equal(FakeVercelCliRunner.FakeImageDigest, digest); } @@ -1156,7 +1156,7 @@ public void GetDockerImageDigestParsesJsonString() [Fact] public void GetDockerImageDigestSelectsLinuxAmd64ManifestDigest() { - string digest = VercelDeploymentStep.GetDockerImageDigest(FakeVercelCliRunner.FakeImageManifestJson); + string digest = VercelDockerImageDigestParser.GetDigest(FakeVercelCliRunner.FakeImageManifestJson); Assert.Equal(FakeVercelCliRunner.FakeImageDigest, digest); } @@ -1180,7 +1180,7 @@ public void GetDockerImageDigestThrowsWhenManifestDoesNotContainLinuxAmd64Digest """; var exception = Assert.Throws(() => - VercelDeploymentStep.GetDockerImageDigest(manifestJson)); + VercelDockerImageDigestParser.GetDigest(manifestJson)); Assert.Contains("linux/amd64 manifest digest", exception.Message); } @@ -1197,7 +1197,7 @@ public void DecodeUnvalidatedOidcClaimsParsesCompactJwtPayload() } """); - var claims = VercelDeploymentStep.DecodeUnvalidatedOidcClaims(token); + var claims = VercelOidcToken.DecodeUnvalidatedClaims(token); Assert.Equal("team_123", claims.OwnerId); Assert.Equal("test-team", claims.Owner); @@ -1209,7 +1209,7 @@ public void DecodeUnvalidatedOidcClaimsParsesCompactJwtPayload() public void DecodeUnvalidatedOidcClaimsRejectsNonCompactJwt() { var exception = Assert.Throws(() => - VercelDeploymentStep.DecodeUnvalidatedOidcClaims("not-a-jwt")); + VercelOidcToken.DecodeUnvalidatedClaims("not-a-jwt")); Assert.Contains("compact JWT", exception.Message); } @@ -1217,7 +1217,7 @@ public void DecodeUnvalidatedOidcClaimsRejectsNonCompactJwt() [Fact] public void ParseDotEnvFileParsesVercelPullOutputSubset() { - var values = VercelDeploymentStep.ParseDotEnvFile( + var values = VercelDotEnvParser.Parse( [ "", "# comment", @@ -1238,8 +1238,8 @@ public void ParseDotEnvFileParsesVercelPullOutputSubset() [Fact] public void ProjectListContainsProjectUsesExactJsonProjectName() { - Assert.True(VercelDeploymentStep.ProjectListContainsProject(ProjectListJson("api", "worker"), "api")); - Assert.False(VercelDeploymentStep.ProjectListContainsProject(ProjectListJson("api-preview"), "api")); + Assert.True(VercelCliOutputParser.ProjectListContainsProject(ProjectListJson("api", "worker"), "api")); + Assert.False(VercelCliOutputParser.ProjectListContainsProject(ProjectListJson("api-preview"), "api")); } [Fact] @@ -1261,9 +1261,9 @@ public void EnvironmentVariableListContainsNameUsesExactJsonKeyAndSkipsBranchSco } """; - Assert.False(VercelDeploymentStep.EnvironmentVariableListContainsName(output, "API_KEY")); - Assert.True(VercelDeploymentStep.EnvironmentVariableListContainsName(output, "OLD_KEY")); - Assert.False(VercelDeploymentStep.EnvironmentVariableListContainsName(output, "KEY")); + Assert.False(VercelCliOutputParser.EnvironmentVariableListContainsName(output, "API_KEY")); + Assert.True(VercelCliOutputParser.EnvironmentVariableListContainsName(output, "OLD_KEY")); + Assert.False(VercelCliOutputParser.EnvironmentVariableListContainsName(output, "KEY")); } [Fact] @@ -1274,10 +1274,10 @@ public void BuildProjectEnvironmentVariableArgumentsUseLinkedScratchDirectory() Scope = "team" }; - string[] linkArguments = VercelDeploymentStep.BuildLinkProjectArguments(options, "/tmp/vercel-link", "api-project"); - string[] envArguments = VercelDeploymentStep.BuildAddProjectEnvironmentVariableArguments(options, "/tmp/vercel-link", "API_KEY", "production"); - string[] removeEnvArguments = VercelDeploymentStep.BuildRemoveProjectEnvironmentVariableArguments(options, "/tmp/vercel-link", "API_KEY", "production"); - string[] listEnvArguments = VercelDeploymentStep.BuildListProjectEnvironmentVariablesArguments(options, "/tmp/vercel-link", "production"); + string[] linkArguments = VercelCliArguments.BuildLinkProjectArguments(options, "/tmp/vercel-link", "api-project"); + string[] envArguments = VercelCliArguments.BuildAddProjectEnvironmentVariableArguments(options, "/tmp/vercel-link", "API_KEY", "production"); + string[] removeEnvArguments = VercelCliArguments.BuildRemoveProjectEnvironmentVariableArguments(options, "/tmp/vercel-link", "API_KEY", "production"); + string[] listEnvArguments = VercelCliArguments.BuildListProjectEnvironmentVariablesArguments(options, "/tmp/vercel-link", "production"); Assert.Equal(["link", "--scope", "team", "--cwd", "/tmp/vercel-link", "--yes", "--project", "api-project"], linkArguments); Assert.Equal(["env", "add", "API_KEY", "production", "--scope", "team", "--cwd", "/tmp/vercel-link", "--yes", "--force", "--sensitive"], envArguments); @@ -1295,7 +1295,7 @@ public void BuildDeployArgumentsIncludesTarget() }; var entry = CreateDeploymentEntry("/repo/src/api"); - string[] arguments = VercelDeploymentStep.BuildDeployArguments(options, entry); + string[] arguments = VercelCliArguments.BuildDeployArguments(options, entry); Assert.Equal(["deploy", "--cwd", "/repo/src/api", "--project", "api", "--prebuilt", "--yes", "--target", "preview"], arguments); } @@ -1308,7 +1308,7 @@ public void BuildDestroyProjectArgumentsIncludesConfiguredOptions() Scope = "team" }; - string[] arguments = VercelDeploymentStep.BuildDestroyProjectArguments(options, "api"); + string[] arguments = VercelCliArguments.BuildDestroyProjectArguments(options, "api"); Assert.Equal(["project", "remove", "api", "--scope", "team"], arguments); } @@ -1321,7 +1321,7 @@ public void BuildValidateScopeArgumentsIncludesConfiguredOptions() Scope = "team" }; - string[] arguments = VercelDeploymentStep.BuildValidateScopeArguments(options); + string[] arguments = VercelCliArguments.BuildValidateScopeArguments(options); Assert.Equal(["project", "ls", "--scope", "team", "--format=json"], arguments); } @@ -1334,7 +1334,7 @@ public void BuildListProjectsArgumentsIncludesFilter() Scope = "team" }; - string[] arguments = VercelDeploymentStep.BuildListProjectsArguments(options, "api"); + string[] arguments = VercelCliArguments.BuildListProjectsArguments(options, "api"); Assert.Equal(["project", "ls", "--scope", "team", "--filter", "api", "--format=json"], arguments); } @@ -1347,7 +1347,7 @@ public void BuildInspectDeploymentArgumentsIncludesConfiguredOptions() Scope = "team" }; - string[] arguments = VercelDeploymentStep.BuildInspectDeploymentArguments(options, "https://api.vercel.app"); + string[] arguments = VercelCliArguments.BuildInspectDeploymentArguments(options, "https://api.vercel.app"); Assert.Equal(["inspect", "https://api.vercel.app", "--scope", "team", "--wait", "--timeout", "120s", "--format=json"], arguments); } @@ -1367,9 +1367,9 @@ public async Task BuildDeployArgumentsProcessesEnvironmentVariables() using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); - string[] arguments = await VercelDeploymentStep.BuildDeployArgumentsAsync( + string[] arguments = await VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( builder.ExecutionContext, NullLogger.Instance, environment.GetVercelOptions(), @@ -1396,9 +1396,9 @@ public async Task BuildDeployArgumentsAllowsNonSecretParameterEnvironmentVariabl using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); - string[] arguments = await VercelDeploymentStep.BuildDeployArgumentsAsync( + string[] arguments = await VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( builder.ExecutionContext, NullLogger.Instance, environment.GetVercelOptions(), @@ -1424,10 +1424,10 @@ public async Task BuildDeployArgumentsThrowsForContainerEntrypoint() using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); var exception = await Assert.ThrowsAsync(() => - VercelDeploymentStep.BuildDeployArgumentsAsync( + VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( builder.ExecutionContext, NullLogger.Instance, environment.GetVercelOptions(), @@ -1453,9 +1453,9 @@ public async Task BuildDeployArgumentsDoesNotPassSecretEnvironmentVariablesOnCom using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); - string[] arguments = await VercelDeploymentStep.BuildDeployArgumentsAsync( + string[] arguments = await VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( builder.ExecutionContext, NullLogger.Instance, environment.GetVercelOptions(), @@ -1501,8 +1501,8 @@ public async Task DeployAsyncConfiguresSecretEnvironmentVariablesBeforeDeploy() using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); - string projectName = VercelDeploymentStep.GetVercelProjectName(entry); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); + string projectName = VercelProjectNameResolver.GetProjectName(entry); var context = CreatePipelineStepContext(builder, app); await VercelDeploymentStep.DeployAsync(context, environment); @@ -1561,8 +1561,8 @@ public async Task DeployAsyncRemovesStaleProjectEnvironmentVariablesFromPrevious using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); - string projectName = VercelDeploymentStep.GetVercelProjectName(entry); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); + string projectName = VercelProjectNameResolver.GetProjectName(entry); stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( SchemaVersion: 1, Environment: "vercel", @@ -1618,8 +1618,8 @@ public async Task ValidatePrerequisitesPreservesPreviousProjectEnvironmentVariab using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); - string projectName = VercelDeploymentStep.GetVercelProjectName(entry); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); + string projectName = VercelProjectNameResolver.GetProjectName(entry); stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( SchemaVersion: 1, Environment: "vercel", @@ -1668,9 +1668,9 @@ public async Task BuildDeployArgumentsDoesNotPassConnectionStringEnvironmentVari using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); - string[] arguments = await VercelDeploymentStep.BuildDeployArgumentsAsync( + string[] arguments = await VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( builder.ExecutionContext, NullLogger.Instance, environment.GetVercelOptions(), @@ -1695,10 +1695,10 @@ public async Task BuildDeployArgumentsThrowsForInvalidEnvironmentVariableNames() using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); var exception = await Assert.ThrowsAsync(() => - VercelDeploymentStep.BuildDeployArgumentsAsync( + VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( builder.ExecutionContext, NullLogger.Instance, environment.GetVercelOptions(), @@ -1735,10 +1735,10 @@ public async Task BuildDeployArgumentsUsesProductionUrlForEndpointEnvironmentVar using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entries = VercelDeploymentStep.GetDeploymentEntries(model, environment).ToArray(); + var entries = VercelDeploymentModel.GetEntries(model, environment).ToArray(); var entry = Assert.Single(entries, entry => entry.Resource.Name == "api"); - string[] arguments = await VercelDeploymentStep.BuildDeployArgumentsAsync( + string[] arguments = await VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( builder.ExecutionContext, NullLogger.Instance, environment.GetVercelOptions(), @@ -1775,10 +1775,10 @@ public async Task BuildDeployArgumentsUsesProductionUrlForServiceDiscoveryRefere using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entries = VercelDeploymentStep.GetDeploymentEntries(model, environment).ToArray(); + var entries = VercelDeploymentModel.GetEntries(model, environment).ToArray(); var entry = Assert.Single(entries, entry => entry.Resource.Name == "api"); - string[] arguments = await VercelDeploymentStep.BuildDeployArgumentsAsync( + string[] arguments = await VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( builder.ExecutionContext, NullLogger.Instance, environment.GetVercelOptions(), @@ -1816,10 +1816,10 @@ public async Task BuildDeployArgumentsUsesConfiguredProjectNameForEndpointRefere using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entries = VercelDeploymentStep.GetDeploymentEntries(model, environment).ToArray(); + var entries = VercelDeploymentModel.GetEntries(model, environment).ToArray(); var entry = Assert.Single(entries, entry => entry.Resource.Name == "api"); - string[] arguments = await VercelDeploymentStep.BuildDeployArgumentsAsync( + string[] arguments = await VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( builder.ExecutionContext, NullLogger.Instance, environment.GetVercelOptions(), @@ -1856,11 +1856,11 @@ public async Task BuildDeployArgumentsThrowsForEndpointReferencesWithoutProducti using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entries = VercelDeploymentStep.GetDeploymentEntries(model, environment).ToArray(); + var entries = VercelDeploymentModel.GetEntries(model, environment).ToArray(); var entry = Assert.Single(entries, entry => entry.Resource.Name == "api"); var exception = await Assert.ThrowsAsync(() => - VercelDeploymentStep.BuildDeployArgumentsAsync( + VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( builder.ExecutionContext, NullLogger.Instance, environment.GetVercelOptions(), @@ -1899,11 +1899,11 @@ public async Task BuildDeployArgumentsThrowsForEndpointReferencesToDifferentVerc using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType(), environment => environment.Name == "vercel"); - var entries = VercelDeploymentStep.GetDeploymentEntries(model, environment).ToArray(); + var entries = VercelDeploymentModel.GetEntries(model, environment).ToArray(); var entry = Assert.Single(entries, entry => entry.Resource.Name == "api"); var exception = await Assert.ThrowsAsync(() => - VercelDeploymentStep.BuildDeployArgumentsAsync( + VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( builder.ExecutionContext, NullLogger.Instance, environment.GetVercelOptions(), @@ -1940,11 +1940,11 @@ public async Task BuildDeployArgumentsThrowsForInternalEndpointReferences() using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entries = VercelDeploymentStep.GetDeploymentEntries(model, environment).ToArray(); + var entries = VercelDeploymentModel.GetEntries(model, environment).ToArray(); var entry = Assert.Single(entries, entry => entry.Resource.Name == "api"); var exception = await Assert.ThrowsAsync(() => - VercelDeploymentStep.BuildDeployArgumentsAsync( + VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( builder.ExecutionContext, NullLogger.Instance, environment.GetVercelOptions(), @@ -1981,11 +1981,11 @@ public async Task BuildDeployArgumentsThrowsForTargetPortEndpointReferenceWithou using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entries = VercelDeploymentStep.GetDeploymentEntries(model, environment).ToArray(); + var entries = VercelDeploymentModel.GetEntries(model, environment).ToArray(); var entry = Assert.Single(entries, entry => entry.Resource.Name == "api"); var exception = await Assert.ThrowsAsync(() => - VercelDeploymentStep.BuildDeployArgumentsAsync( + VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( builder.ExecutionContext, NullLogger.Instance, environment.GetVercelOptions(), @@ -2011,10 +2011,10 @@ public async Task BuildDeployArgumentsThrowsForCommandLineArguments() using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); var exception = await Assert.ThrowsAsync(() => - VercelDeploymentStep.BuildDeployArgumentsAsync( + VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( builder.ExecutionContext, NullLogger.Instance, environment.GetVercelOptions(), @@ -2039,9 +2039,9 @@ public async Task BuildDeployArgumentsAllowsDockerBuildArguments() using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); - string[] arguments = await VercelDeploymentStep.BuildDeployArgumentsAsync( + string[] arguments = await VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( builder.ExecutionContext, NullLogger.Instance, environment.GetVercelOptions(), @@ -2126,7 +2126,7 @@ public async Task DeployAsyncRunsVercelCliAndSavesDeploymentState() var digestInvocation = Assert.Single(runner.Invocations, invocation => invocation.FileName == "docker" && invocation.Arguments.Contains("imagetools")); var invocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("deploy")); string expectedDeployDirectory = Path.Combine(tempRoot.Path, "api", "vercel-build-output"); - Assert.Equal(VercelDeploymentStep.BuildDockerInspectDigestArguments(digestInvocation.Arguments[^1]), digestInvocation.Arguments); + Assert.Equal(VercelCliArguments.BuildDockerInspectDigestArguments(digestInvocation.Arguments[^1]), digestInvocation.Arguments); Assert.StartsWith("vcr.vercel.com/test-team/test-project/app:", digestInvocation.Arguments[^1], StringComparison.Ordinal); Assert.Equal("vercel", invocation.FileName); Assert.Equal(expectedDeployDirectory, invocation.WorkingDirectory); @@ -3272,7 +3272,7 @@ public async Task VercelCliRunnerThrowsWhenProcessCannotStart() [Fact] public void GetDeploymentUrlReturnsPlainUrl() { - string url = VercelDeploymentStep.GetDeploymentUrl(""" + string url = VercelCliOutputParser.GetDeploymentUrl(""" Vercel CLI 54.18.6 https://example.vercel.app """); @@ -3283,7 +3283,7 @@ Vercel CLI 54.18.6 [Fact] public void GetDeploymentUrlReturnsJsonDeploymentUrl() { - string url = VercelDeploymentStep.GetDeploymentUrl(""" + string url = VercelCliOutputParser.GetDeploymentUrl(""" { "status": "ok", "deployment": { @@ -3300,7 +3300,7 @@ public void GetDeploymentUrlReturnsJsonDeploymentUrl() [Fact] public void GetDeploymentResultReturnsRootJsonDeploymentUrl() { - var result = VercelDeploymentStep.GetDeploymentResult(""" + var result = VercelCliOutputParser.GetDeploymentResult(""" { "id": "dpl_root", "url": "https://root-json.vercel.app" @@ -3314,7 +3314,7 @@ public void GetDeploymentResultReturnsRootJsonDeploymentUrl() [Fact] public void GetDeploymentResultFallsBackWhenJsonIsInvalid() { - var result = VercelDeploymentStep.GetDeploymentResult(""" + var result = VercelCliOutputParser.GetDeploymentResult(""" { "deployment": } @@ -3329,7 +3329,7 @@ public void GetDeploymentResultFallsBackWhenJsonIsInvalid() public void GetDeploymentResultThrowsWhenOutputHasNoUrl() { var exception = Assert.Throws(() => - VercelDeploymentStep.GetDeploymentResult(""" + VercelCliOutputParser.GetDeploymentResult(""" { "deployment": { "id": "dpl_no_url" @@ -3343,7 +3343,7 @@ public void GetDeploymentResultThrowsWhenOutputHasNoUrl() [Fact] public void GetDeploymentResultReturnsJsonDeploymentId() { - var result = VercelDeploymentStep.GetDeploymentResult(""" + var result = VercelCliOutputParser.GetDeploymentResult(""" { "status": "ok", "deployment": { @@ -3361,17 +3361,17 @@ public void GetDeploymentResultReturnsJsonDeploymentId() [Fact] public void GetDeploymentInspectionParsesObservedJsonShapes() { - Assert.Equal("READY", VercelDeploymentStep.GetDeploymentInspection("""{ "readyState": "READY" }""").ReadyState); - Assert.Equal("READY", VercelDeploymentStep.GetDeploymentInspection("""{ "state": "READY" }""").ReadyState); - Assert.Equal("READY", VercelDeploymentStep.GetDeploymentInspection("""{ "deployment": { "readyState": "READY" } }""").ReadyState); - Assert.Equal("READY", VercelDeploymentStep.GetDeploymentInspection("""{ "deployment": { "state": "READY" } }""").ReadyState); + Assert.Equal("READY", VercelCliOutputParser.GetDeploymentInspection("""{ "readyState": "READY" }""").ReadyState); + Assert.Equal("READY", VercelCliOutputParser.GetDeploymentInspection("""{ "state": "READY" }""").ReadyState); + Assert.Equal("READY", VercelCliOutputParser.GetDeploymentInspection("""{ "deployment": { "readyState": "READY" } }""").ReadyState); + Assert.Equal("READY", VercelCliOutputParser.GetDeploymentInspection("""{ "deployment": { "state": "READY" } }""").ReadyState); } [Fact] public void GetDeploymentInspectionThrowsForInvalidJson() { var exception = Assert.Throws(() => - VercelDeploymentStep.GetDeploymentInspection("""{ "deployment": """)); + VercelCliOutputParser.GetDeploymentInspection("""{ "deployment": """)); Assert.Contains("vercel inspect", exception.Message); } @@ -3386,7 +3386,7 @@ public void GetVercelProjectNameReadsVercelProjectLink() var entry = CreateDeploymentEntry(sourceRoot.Path); entry.Resource.Annotations.Add(new VercelProjectOptionsAnnotation("configured-project")); - string projectName = VercelDeploymentStep.GetVercelProjectName(entry); + string projectName = VercelProjectNameResolver.GetProjectName(entry); Assert.Equal("linked-project", projectName); } @@ -3397,7 +3397,7 @@ public void GetVercelProjectNameFallsBackToSourceRootName() using var sourceRoot = TemporaryDirectory.Create("fallback-project"); var entry = CreateDeploymentEntry(sourceRoot.Path); - string projectName = VercelDeploymentStep.GetVercelProjectName(entry); + string projectName = VercelProjectNameResolver.GetProjectName(entry); Assert.Equal("fallback-project", projectName); } @@ -3633,8 +3633,8 @@ public async Task PreparePulledProjectContextDeletesScratchLinkDirectory() var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); var context = CreatePipelineStepContext(builder, app); - var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)); - var entriesByResourceName = VercelDeploymentStep.GetDeploymentEntries(model, environment) + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); + var entriesByResourceName = VercelDeploymentModel.GetEntries(model, environment) .ToDictionary(static deployment => deployment.Resource.Name, StringComparer.Ordinal); var projectContext = await VercelDeploymentStep.PreparePulledProjectContextAsync( @@ -3645,7 +3645,7 @@ public async Task PreparePulledProjectContextDeletesScratchLinkDirectory() entriesByResourceName); string expectedProjectLinkDirectory = Path.Combine(tempRoot.Path, "api", ".vercel-project"); - string expectedProjectName = VercelDeploymentStep.GetVercelProjectName(entry); + string expectedProjectName = VercelProjectNameResolver.GetProjectName(entry); Assert.False(Directory.Exists(expectedProjectLinkDirectory)); Assert.Equal(expectedProjectName, projectContext.PulledProject.ProjectName); Assert.Equal("prj_test", projectContext.PulledProject.ProjectId); @@ -3677,7 +3677,7 @@ public async Task WriteBuildOutputAsyncWritesProviderArtifactShape() using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var entry = Assert.Single(VercelDeploymentStep.GetDeploymentEntries(model, environment)) with + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)) with { DeployDirectory = deployRoot.Path }; @@ -3694,7 +3694,7 @@ public async Task WriteBuildOutputAsyncWritesProviderArtifactShape() """, OidcToken: "unused"); - await VercelDeploymentStep.WriteBuildOutputAsync( + await VercelBuildOutputWriter.WriteAsync( entry, project, $"vcr.vercel.com/test-team/test-project/app@{FakeVercelCliRunner.FakeImageDigest}", @@ -4017,7 +4017,7 @@ private bool ShouldAutoVercelVersion() } return next.Succeeded - && !VercelDeploymentStep.TryGetVercelCliVersion(output, out _) + && !VercelCliOutputParser.TryGetCliVersion(output, out _) && !output.TrimStart().StartsWith("vercel ", StringComparison.OrdinalIgnoreCase); } From 361f4e56fd2d0fe11363080335e74ddd326e83c7 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Fri, 3 Jul 2026 00:59:19 -0700 Subject: [PATCH 26/33] Use typed Vercel JSON models Replace the loose VercelJson helper with typed DTOs for Vercel CLI, project settings, OIDC token, and Docker manifest JSON shapes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../VercelCliOutputParser.cs | 136 +++++++++++++----- .../VercelDeploymentStep.cs | 24 +--- .../VercelDockerImageDigestParser.cs | 52 +++++-- .../VercelJson.cs | 37 ----- .../VercelOidcToken.cs | 27 +++- .../VercelProjectNameResolver.cs | 18 ++- .../VercelProjectSettingsReader.cs | 28 ++-- 7 files changed, 184 insertions(+), 138 deletions(-) delete mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelJson.cs diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliOutputParser.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliOutputParser.cs index 287826f43..b6adec9d0 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliOutputParser.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliOutputParser.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Text.Json; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; using Aspire.Hosting; @@ -43,12 +44,11 @@ public static VercelDeploymentInspection GetDeploymentInspection(string standard // { "state": "READY" } // { "deployment": { "readyState": "READY" } } // { "deployment": { "state": "READY" } } - using var document = JsonDocument.Parse(standardOutput); - var root = document.RootElement; - string? readyState = VercelJson.GetStringProperty(root, "readyState") - ?? VercelJson.GetStringProperty(root, "state") - ?? (root.TryGetProperty("deployment", out var deployment) ? VercelJson.GetStringProperty(deployment, "readyState") : null) - ?? (root.TryGetProperty("deployment", out deployment) ? VercelJson.GetStringProperty(deployment, "state") : null); + var output = JsonSerializer.Deserialize(standardOutput); + string? readyState = output?.ReadyState + ?? output?.State + ?? output?.Deployment?.ReadyState + ?? output?.Deployment?.State; return new(readyState); } @@ -80,10 +80,9 @@ public static bool ProjectListContainsProject(string standardOutput, string proj { try { - using var document = JsonDocument.Parse(standardOutput); - foreach (var project in VercelJson.EnumerateArrayOrNamedArray(document.RootElement, "projects")) + foreach (var project in DeserializeArrayOrNamedArray(standardOutput, "projects")) { - if (string.Equals(VercelJson.GetStringProperty(project, "name"), projectName, StringComparison.Ordinal)) + if (string.Equals(project.Name, projectName, StringComparison.Ordinal)) { return true; } @@ -101,11 +100,10 @@ public static bool EnvironmentVariableListContainsName(string standardOutput, st { try { - using var document = JsonDocument.Parse(standardOutput); - foreach (var environmentVariable in VercelJson.EnumerateArrayOrNamedArray(document.RootElement, "envs")) + foreach (var environmentVariable in DeserializeArrayOrNamedArray(standardOutput, "envs")) { - if (string.Equals(VercelJson.GetStringProperty(environmentVariable, "key"), name, StringComparison.Ordinal) - && string.IsNullOrWhiteSpace(VercelJson.GetStringProperty(environmentVariable, "gitBranch"))) + if (string.Equals(environmentVariable.Key, name, StringComparison.Ordinal) + && string.IsNullOrWhiteSpace(environmentVariable.GitBranch)) { return true; } @@ -131,10 +129,8 @@ public static string GetTrimmedOutput(string output) try { - using var document = JsonDocument.Parse(standardOutput); - var root = document.RootElement; - - if (TryGetDeploymentResult(root, out var deploymentResult)) + var output = JsonSerializer.Deserialize(standardOutput); + if (output is not null && TryGetDeploymentResult(output, out var deploymentResult)) { return deploymentResult; } @@ -147,32 +143,21 @@ public static string GetTrimmedOutput(string output) return null; } - private static bool TryGetDeploymentResult(JsonElement root, [NotNullWhen(true)] out VercelDeploymentResult? deploymentResult) + private static bool TryGetDeploymentResult(VercelDeployOutput output, [NotNullWhen(true)] out VercelDeploymentResult? deploymentResult) { // Parse the Vercel deploy JSON shapes observed from different CLI versions: // { "deployment": { "url": "https://...", "id": "..." } } // { "url": "https://...", "id": "..." } // Callers fall back to line-based extraction when deploy output is plain text. - if (root.TryGetProperty("deployment", out var deployment) - && deployment.TryGetProperty("url", out var nestedUrl) - && TryGetHttpUrl(nestedUrl, out var nestedDeploymentUrl)) + if (TryGetHttpUrl(output.Deployment?.Url, out var nestedDeploymentUrl)) { - string? deploymentId = deployment.TryGetProperty("id", out var nestedId) && nestedId.ValueKind == JsonValueKind.String - ? nestedId.GetString() - : null; - - deploymentResult = new(deploymentId, nestedDeploymentUrl); + deploymentResult = new(output.Deployment?.Id, nestedDeploymentUrl); return true; } - if (root.TryGetProperty("url", out var url) - && TryGetHttpUrl(url, out var rootDeploymentUrl)) + if (TryGetHttpUrl(output.Url, out var rootDeploymentUrl)) { - string? deploymentId = root.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String - ? id.GetString() - : null; - - deploymentResult = new(deploymentId, rootDeploymentUrl); + deploymentResult = new(output.Id, rootDeploymentUrl); return true; } @@ -180,15 +165,88 @@ private static bool TryGetDeploymentResult(JsonElement root, [NotNullWhen(true)] return false; } - private static bool TryGetHttpUrl(JsonElement urlElement, [NotNullWhen(true)] out string? url) + private static bool TryGetHttpUrl(string? url, [NotNullWhen(true)] out string? deploymentUrl) { - url = urlElement.ValueKind == JsonValueKind.String - ? urlElement.GetString() - : null; - - return IsHttpUrl(url); + deploymentUrl = url; + return IsHttpUrl(deploymentUrl); } private static bool IsHttpUrl([NotNullWhen(true)] string? url) => Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http"; + + private static T[] DeserializeArrayOrNamedArray(string json, string propertyName) + { + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + if (root.ValueKind == JsonValueKind.Array) + { + return root.Deserialize() ?? []; + } + + if (root.ValueKind == JsonValueKind.Object + && root.TryGetProperty(propertyName, out var array) + && array.ValueKind == JsonValueKind.Array) + { + return array.Deserialize() ?? []; + } + + throw new JsonException($"Expected JSON array or object property '{propertyName}'."); + } + + private sealed class VercelDeployOutput + { + [JsonPropertyName("deployment")] + public VercelDeployDeployment? Deployment { get; init; } + + [JsonPropertyName("url")] + public string? Url { get; init; } + + [JsonPropertyName("id")] + public string? Id { get; init; } + } + + private sealed class VercelDeployDeployment + { + [JsonPropertyName("url")] + public string? Url { get; init; } + + [JsonPropertyName("id")] + public string? Id { get; init; } + } + + private sealed class VercelInspectOutput + { + [JsonPropertyName("readyState")] + public string? ReadyState { get; init; } + + [JsonPropertyName("state")] + public string? State { get; init; } + + [JsonPropertyName("deployment")] + public VercelInspectDeployment? Deployment { get; init; } + } + + private sealed class VercelInspectDeployment + { + [JsonPropertyName("readyState")] + public string? ReadyState { get; init; } + + [JsonPropertyName("state")] + public string? State { get; init; } + } + + private sealed class VercelListedProject + { + [JsonPropertyName("name")] + public string? Name { get; init; } + } + + private sealed class VercelListedEnvironmentVariable + { + [JsonPropertyName("key")] + public string? Key { get; init; } + + [JsonPropertyName("gitBranch")] + public string? GitBranch { get; init; } + } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs index 78ae36a02..058ee8575 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs @@ -50,29 +50,7 @@ internal static partial class VercelDeploymentStep private const string VercelContainerServiceName = VercelConstants.ContainerServiceName; private const string PushPrereqStepName = "push-prereq"; private static readonly Version MinimumVercelCliVersion = new(54, 18, 6); - // These keys either bypass the generated Build Output API contract or configure - // routing/build/runtime/env behavior that Aspire must own so endpoint refs and - // secret handling stay coherent. - private static readonly string[] VercelJsonBuildOutputUnsupportedKeys = - [ - "build", - "builds", - "buildCommand", - "devCommand", - "env", - "experimentalServices", - "experimentalServicesV2", - "framework", - "functions", - "headers", - "ignoreCommand", - "installCommand", - "outputDirectory", - "redirects", - "rewrites", - "routes", - "services" - ]; + public static async Task ValidatePrerequisitesAsync(PipelineStepContext context, VercelEnvironmentResource environment) { // This is the deploy prerequisite step, not merely static validation. It intentionally diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDockerImageDigestParser.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDockerImageDigestParser.cs index a1c673dc7..716726825 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDockerImageDigestParser.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDockerImageDigestParser.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; using Aspire.Hosting; @@ -38,31 +39,25 @@ public static string GetDigest(string output) // Container Images docs describe VCR-backed OCI images; live smoke tests rejected // index digests, so prefer the linux/amd64 child. // See https://vercel.com/docs/functions/container-images. - using var document = JsonDocument.Parse(trimmed); - var root = document.RootElement; - - if (root.TryGetProperty("manifests", out var manifests) && manifests.ValueKind == JsonValueKind.Array) + var manifestOutput = JsonSerializer.Deserialize(trimmed); + if (manifestOutput?.Manifests is { Length: > 0 } manifests) { - foreach (var manifest in manifests.EnumerateArray()) + foreach (var manifest in manifests) { - if (manifest.TryGetProperty("platform", out var platform) - && platform.TryGetProperty("os", out var osElement) - && platform.TryGetProperty("architecture", out var architectureElement) - && string.Equals(osElement.GetString(), "linux", StringComparison.OrdinalIgnoreCase) - && string.Equals(architectureElement.GetString(), "amd64", StringComparison.OrdinalIgnoreCase) - && VercelJson.TryGetString(manifest, "digest", out var platformDigest) - && IsSha256Digest(platformDigest)) + if (string.Equals(manifest.Platform?.Os, "linux", StringComparison.OrdinalIgnoreCase) + && string.Equals(manifest.Platform?.Architecture, "amd64", StringComparison.OrdinalIgnoreCase) + && IsSha256Digest(manifest.Digest)) { - return platformDigest!; + return manifest.Digest!; } } throw new DistributedApplicationException("Docker did not return a linux/amd64 manifest digest for the pushed VCR image. Vercel requires linux/amd64 container images."); } - if (VercelJson.TryGetString(root, "digest", out var digest) && IsSha256Digest(digest)) + if (IsSha256Digest(manifestOutput?.Digest)) { - return digest!; + return manifestOutput!.Digest!; } } catch (JsonException ex) @@ -82,4 +77,31 @@ public static string GetDigest(string output) private static bool IsSha256Digest([NotNullWhen(true)] string? value) => value is not null && Regex.IsMatch(value, "^sha256:[a-f0-9]{64}$", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); + + private sealed class DockerManifestOutput + { + [JsonPropertyName("manifests")] + public DockerManifest[]? Manifests { get; init; } + + [JsonPropertyName("digest")] + public string? Digest { get; init; } + } + + private sealed class DockerManifest + { + [JsonPropertyName("digest")] + public string? Digest { get; init; } + + [JsonPropertyName("platform")] + public DockerManifestPlatform? Platform { get; init; } + } + + private sealed class DockerManifestPlatform + { + [JsonPropertyName("os")] + public string? Os { get; init; } + + [JsonPropertyName("architecture")] + public string? Architecture { get; init; } + } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelJson.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelJson.cs deleted file mode 100644 index 5b7b0934a..000000000 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelJson.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; - -namespace CommunityToolkit.Aspire.Hosting.Vercel; - -internal static class VercelJson -{ - public static string? GetStringProperty(JsonElement element, string propertyName) - => element.TryGetProperty(propertyName, out var property) - && property.ValueKind == JsonValueKind.String - && !string.IsNullOrWhiteSpace(property.GetString()) - ? property.GetString() - : null; - - public static bool TryGetString(JsonElement element, string propertyName, [NotNullWhen(true)] out string? value) - { - value = GetStringProperty(element, propertyName); - return value is not null; - } - - public static JsonElement.ArrayEnumerator EnumerateArrayOrNamedArray(JsonElement root, string propertyName) - { - if (root.ValueKind == JsonValueKind.Array) - { - return root.EnumerateArray(); - } - - if (root.ValueKind == JsonValueKind.Object - && root.TryGetProperty(propertyName, out var array) - && array.ValueKind == JsonValueKind.Array) - { - return array.EnumerateArray(); - } - - throw new JsonException($"Expected JSON array or object property '{propertyName}'."); - } -} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelOidcToken.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelOidcToken.cs index 4b3973e06..af6a89a06 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelOidcToken.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelOidcToken.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Serialization; using Aspire.Hosting; namespace CommunityToolkit.Aspire.Hosting.Vercel; @@ -19,14 +20,13 @@ public static VercelOidcClaims DecodeUnvalidatedClaims(string token) // Docker/Vercel validate the token when it is used; here we only need routing // metadata such as owner_id/project to construct the VCR login and repository. byte[] payloadBytes = Convert.FromBase64String(PadBase64Url(parts[1])); - using var document = JsonDocument.Parse(payloadBytes); - var root = document.RootElement; + var claims = JsonSerializer.Deserialize(payloadBytes); return new( - VercelJson.GetStringProperty(root, "owner_id"), - VercelJson.GetStringProperty(root, "owner"), - VercelJson.GetStringProperty(root, "project"), - VercelJson.GetStringProperty(root, "project_id")); + claims?.OwnerId, + claims?.Owner, + claims?.Project, + claims?.ProjectId); } catch (Exception ex) when (ex is FormatException or JsonException) { @@ -39,4 +39,19 @@ private static string PadBase64Url(string value) string padded = value.Replace('-', '+').Replace('_', '/'); return padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '='); } + + private sealed class VercelOidcTokenClaimsJson + { + [JsonPropertyName("owner_id")] + public string? OwnerId { get; init; } + + [JsonPropertyName("owner")] + public string? Owner { get; init; } + + [JsonPropertyName("project")] + public string? Project { get; init; } + + [JsonPropertyName("project_id")] + public string? ProjectId { get; init; } + } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs index ce09cb8f1..1176cba0c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs @@ -7,6 +7,7 @@ using System.Diagnostics.CodeAnalysis; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; namespace CommunityToolkit.Aspire.Hosting.Vercel; @@ -143,12 +144,10 @@ private static bool TryReadProjectLink(string sourceRoot, [NotNullWhen(true)] ou // .vercel/project.json: { "projectId": "...", "orgId": "...", "projectName": "..." } // Treat it as user/provider ownership metadata rather than regenerating a managed // name. Destroy preserves these linked projects and only removes tracked env vars. - using var document = JsonDocument.Parse(File.ReadAllText(projectJsonPath)); - string? projectName = VercelJson.GetStringProperty(document.RootElement, "projectName"); - - if (!string.IsNullOrWhiteSpace(projectName)) + var project = JsonSerializer.Deserialize(File.ReadAllText(projectJsonPath)); + if (project is { ProjectName: { } projectName } && !string.IsNullOrWhiteSpace(projectName)) { - projectLink = new(projectName, VercelJson.GetStringProperty(document.RootElement, "projectId")); + projectLink = new(projectName, project.ProjectId); return true; } } @@ -165,4 +164,13 @@ private static bool IsAsciiLetterOrDigit(char character) private static bool IsLowercaseAsciiLetterOrDigit(char character) => character is >= 'a' and <= 'z' or >= '0' and <= '9'; + + private sealed class VercelLinkedProjectJson + { + [JsonPropertyName("projectName")] + public string? ProjectName { get; init; } + + [JsonPropertyName("projectId")] + public string? ProjectId { get; init; } + } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectSettingsReader.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectSettingsReader.cs index 76653855a..392e8f237 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectSettingsReader.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectSettingsReader.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Serialization; using Aspire.Hosting; namespace CommunityToolkit.Aspire.Hosting.Vercel; @@ -11,24 +12,25 @@ public static VercelPulledProjectSettings Read(string projectJsonPath, string pr { // `vercel pull` writes `.vercel/project.json`. Only project identity fields are // needed: they select the linked provider project and are safe to persist in state. - using var document = JsonDocument.Parse(projectJsonContent); - var root = document.RootElement; + var settings = JsonSerializer.Deserialize(projectJsonContent); - string projectName = root.TryGetProperty("projectName", out var projectNameElement) && projectNameElement.ValueKind == JsonValueKind.String - ? projectNameElement.GetString() ?? string.Empty - : string.Empty; - string? projectId = root.TryGetProperty("projectId", out var projectIdElement) && projectIdElement.ValueKind == JsonValueKind.String - ? projectIdElement.GetString() - : null; - string? orgId = root.TryGetProperty("orgId", out var orgIdElement) && orgIdElement.ValueKind == JsonValueKind.String - ? orgIdElement.GetString() - : null; - - return new(projectName, projectId, orgId); + return new(settings?.ProjectName ?? string.Empty, settings?.ProjectId, settings?.OrgId); } catch (JsonException ex) { throw new DistributedApplicationException($"Vercel project settings file '{projectJsonPath}' is invalid JSON.", ex); } } + + private sealed class VercelProjectSettingsJson + { + [JsonPropertyName("projectName")] + public string? ProjectName { get; init; } + + [JsonPropertyName("projectId")] + public string? ProjectId { get; init; } + + [JsonPropertyName("orgId")] + public string? OrgId { get; init; } + } } From a4f9f1b85c58f045c7187347628132ce655a642d Mon Sep 17 00:00:00 2001 From: David Fowler Date: Fri, 3 Jul 2026 10:26:44 -0700 Subject: [PATCH 27/33] Document Vercel provider formats Add focused comments and Vercel documentation links for CLI JSON shapes, Build Output API deployment, project linking, generated URLs, VCR auth, and secret environment handling. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../VercelCliArguments.cs | 23 +++++++++++++++++++ .../VercelCliOutputParser.cs | 6 +++++ .../VercelContainerRegistryClient.cs | 1 + .../VercelDeploymentModel.cs | 4 ++++ .../VercelDeploymentStateStore.cs | 2 ++ .../VercelDotEnvParser.cs | 1 + .../VercelEnvironmentMapper.cs | 4 ++++ .../VercelEnvironmentResource.cs | 2 ++ ...celEnvironmentResourceBuilderExtensions.cs | 13 +++++++++-- .../VercelOidcToken.cs | 1 + .../VercelProjectEnvironment.cs | 4 ++++ .../VercelProjectNameResolver.cs | 2 ++ .../VercelProjectSettingsReader.cs | 1 + 13 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliArguments.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliArguments.cs index 7278c5b8a..7e494fcd5 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliArguments.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliArguments.cs @@ -6,6 +6,9 @@ public static string[] BuildDeployArguments(VercelEnvironmentOptionsAnnotation o => BuildDeployArguments(options, entry.EffectiveDeployDirectory, VercelProjectNameResolver.GetProjectOption(entry), environmentVariables: []); public static string[] BuildDockerInspectDigestArguments(string imageReference) + // Inspect the pushed tag through Docker buildx so deploy can pin the Vercel Build + // Output API container handler to the linux/amd64 manifest digest Vercel accepts. + // See https://vercel.com/docs/functions/container-images. => ["buildx", "imagetools", "inspect", "--format", "{{json .Manifest}}", imageReference]; public static string[] BuildDestroyProjectArguments(VercelEnvironmentOptionsAnnotation options, string projectName) @@ -31,6 +34,9 @@ public static string[] BuildListProjectEnvironmentVariablesArguments( arguments.Add("ls"); arguments.Add(targetEnvironment); AddOptionalScopeArgument(arguments, options); + // Environment variables are project-scoped in the Vercel CLI. The linked scratch + // directory selects the project; JSON output avoids parsing localized table text. + // See https://vercel.com/docs/cli/env. arguments.Add("--cwd"); arguments.Add(projectLinkDirectory); arguments.Add("--format=json"); @@ -66,6 +72,9 @@ public static string[] BuildAddProjectEnvironmentVariableArguments( // Vercel env commands must run inside a linked project directory. Use the // Aspire-owned scratch link instead of the source root so provider metadata // and pulled env files never mutate the user's checkout. + // --sensitive ensures the value is stored in Vercel's project env store instead + // of being visible in generated Build Output or deploy command arguments. + // See https://vercel.com/docs/cli/env and https://vercel.com/docs/environment-variables/sensitive-environment-variables. arguments.Add("--cwd"); arguments.Add(projectLinkDirectory); arguments.Add("--yes"); @@ -104,6 +113,10 @@ public static string[] BuildLinkProjectArguments( arguments.Add("link"); AddOptionalScopeArgument(arguments, options); + // Link writes .vercel/project.json in the selected working directory. Always link a + // scratch directory; checked-in .vercel/project.json in the source root is read-only + // user intent, not something deploy should mutate. + // See https://vercel.com/docs/cli/link. arguments.Add("--cwd"); arguments.Add(projectLinkDirectory); arguments.Add("--yes"); @@ -122,6 +135,10 @@ public static string[] BuildPullProjectSettingsArguments( arguments.Add("pull"); AddOptionalScopeArgument(arguments, options); + // Pull materializes .vercel/project.json plus .vercel/.env..local. + // The integration reads the project identity and VCR OIDC token, then deletes the + // pulled env files before writing deploy artifacts. + // See https://vercel.com/docs/cli/pull. arguments.Add("--cwd"); arguments.Add(projectLinkDirectory); arguments.Add("--yes"); @@ -141,6 +158,9 @@ public static string[] BuildInspectDeploymentArguments(VercelEnvironmentOptionsA arguments.Add("inspect"); arguments.Add(deploymentUrl); AddOptionalScopeArgument(arguments, options); + // Deploy returning successfully only confirms submission. Ask inspect to wait for the + // provider's final JSON state before deployment state is persisted. + // See https://vercel.com/docs/cli/inspect. arguments.Add("--wait"); arguments.Add("--timeout"); arguments.Add("120s"); @@ -183,6 +203,9 @@ public static string[] BuildDeployArguments( arguments.Add("--cwd"); arguments.Add(deployDirectory); AddOptionalProjectArgument(arguments, projectNameOrId); + // --prebuilt tells the CLI to upload the generated .vercel/output tree rather than + // running a source build. That tree is produced by VercelBuildOutputWriter. + // See https://vercel.com/docs/cli/deploy and https://vercel.com/docs/build-output-api. arguments.Add("--prebuilt"); arguments.Add("--yes"); diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliOutputParser.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliOutputParser.cs index b6adec9d0..3beef8147 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliOutputParser.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliOutputParser.cs @@ -100,6 +100,9 @@ public static bool EnvironmentVariableListContainsName(string standardOutput, st { try { + // Vercel env JSON includes gitBranch for branch-scoped values. Aspire-managed + // project env vars are target-wide, so branch-scoped matches must not block add/rm. + // See https://vercel.com/docs/cli/env. foreach (var environmentVariable in DeserializeArrayOrNamedArray(standardOutput, "envs")) { if (string.Equals(environmentVariable.Key, name, StringComparison.Ordinal) @@ -176,6 +179,9 @@ private static bool IsHttpUrl([NotNullWhen(true)] string? url) private static T[] DeserializeArrayOrNamedArray(string json, string propertyName) { + // Vercel CLI JSON has used both bare arrays and named-array wrappers + // ({ "projects": [...] }, { "envs": [...] }) across commands/versions. + // Keep this format tolerance localized to provider-output parsing. using var document = JsonDocument.Parse(json); var root = document.RootElement; if (root.ValueKind == JsonValueKind.Array) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelContainerRegistryClient.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelContainerRegistryClient.cs index b3f663acb..c310bdd7c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelContainerRegistryClient.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelContainerRegistryClient.cs @@ -20,6 +20,7 @@ public async Task EnsureRepositoryAsync(string token, VercelOidcClaims claims, s // VCR repositories are project-scoped under owner/project. The integration currently // creates the leaf repository ("app") only; any future nested repository name should // be provisioned by the provider or a separate explicit API before this method runs. + // See https://vercel.com/docs/container-registry. if (repository.Contains('/', StringComparison.Ordinal)) { return; diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs index 4fdbcc079..bd0c99ccb 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs @@ -134,6 +134,9 @@ public static async Task ValidateVercelJsonAsync( string sourceRoot, CancellationToken cancellationToken) { + // User vercel.json can coexist only when it does not claim the same Build Output API + // routes/functions/services surface this integration generates under .vercel/output. + // See https://vercel.com/docs/project-configuration and https://vercel.com/docs/build-output-api. string vercelJsonPath = Path.Combine(sourceRoot, VercelConstants.JsonFileName); if (!File.Exists(vercelJsonPath)) { @@ -276,6 +279,7 @@ private static void ValidateEndpointModel(VercelDeploymentEntry entry) // single public Vercel container ingress. // Vercel's Dockerfile preview exposes one public platform ingress; it has no // Aspire-modeled private service network for internal endpoints. + // See https://vercel.com/docs/functions/container-images. var internalEndpoint = endpoints.FirstOrDefault(static endpoint => !endpoint.IsExternal); if (internalEndpoint is not null) { diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStateStore.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStateStore.cs index caefddf09..bb639083b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStateStore.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStateStore.cs @@ -79,6 +79,8 @@ public static async Task SaveEntryAsync( { // DeploymentStateSection storage shape has changed across Aspire builds. Accept the // known wrappers so destroy can still clean up projects created by an older CLI. + // The Vercel state payload itself is versioned below; this tolerance is only for the + // Aspire storage envelope, not for provider object ownership. if (stateSection.Data.TryGetPropertyValue("value", out JsonNode? value) && value is not null) { diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDotEnvParser.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDotEnvParser.cs index 146f38c18..fce841b66 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDotEnvParser.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDotEnvParser.cs @@ -8,6 +8,7 @@ public static Dictionary Parse(IEnumerable lines) // We only need the VERCEL_OIDC_TOKEN line. This intentionally supports the subset the // CLI emits: comments/blank lines, KEY=value, single/double quoted values, and common // backslash escapes. It is not a general dotenv evaluator with interpolation. + // See https://vercel.com/docs/cli/pull. Dictionary values = new(StringComparer.Ordinal); foreach (string rawLine in lines) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentMapper.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentMapper.cs index 6f72d0963..e006f1b71 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentMapper.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentMapper.cs @@ -50,6 +50,8 @@ public static async Task GetConfigurationAsync( // Non-secrets can ride on `vercel deploy --env`; secret-bearing values must use // Vercel project environment variables so values never appear in CLI arguments. + // See https://vercel.com/docs/cli/deploy and + // https://vercel.com/docs/environment-variables/sensitive-environment-variables. bool containsSecret = ContainsSecretReference(unprocessedValue); if (containsSecret) { @@ -114,6 +116,7 @@ private static async ValueTask GetProjectEnvironmentVariableValueAsync( // This path resolves values for `vercel env add`, not `vercel deploy --env`. // Values can be secret-bearing because they are sent on stdin to Vercel's secret // store; they must not be copied into publish plans, command lines, or state. + // See https://vercel.com/docs/cli/env. switch (value) { case null: @@ -268,6 +271,7 @@ private static string GetEndpointPropertyValue( // post-deploy outputs, internal endpoints have no public Vercel edge address, and // cross-environment references lack a stable same-deploy alias. Fail before values // are written to Vercel env vars. + // See https://vercel.com/docs/deployments/generated-urls. if (!options.Production) { throw new DistributedApplicationException( diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs index 8e9ca64ec..57fa6e067 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs @@ -43,6 +43,7 @@ public ReferenceExpression GetHostAddressExpression(EndpointReference endpointRe // Same-deploy references need an address that exists before `vercel deploy` // finishes. Production project aliases are deterministic; preview URLs are not. + // See https://vercel.com/docs/deployments/generated-urls. string projectName = VercelProjectNameResolver.GetProjectName(endpointReference.Resource); return ReferenceExpression.Create($"{projectName}.vercel.app"); } @@ -62,6 +63,7 @@ public ReferenceExpression GetEndpointPropertyExpression(EndpointReferenceExpres // These expressions model the caller-visible Vercel edge endpoint, not the // container listener. Vercel terminates TLS and forwards to the runtime's $PORT, // so service references use HTTPS/443 while TargetPort remains container metadata. + // See https://vercel.com/docs/functions/container-images. return property switch { // Vercel terminates public HTTPS at the platform edge, so callers always use diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs index bac5761c9..5ade7db3d 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs @@ -23,6 +23,7 @@ public static class VercelEnvironmentResourceBuilderExtensions /// /// The Vercel environment is not added to the local run model. During publish and deploy it validates image-build resources and, /// during deploy, invokes the Vercel CLI using the current login or VERCEL_TOKEN. + /// See Vercel CLI documentation. /// [AspireExport] public static IResourceBuilder AddVercelEnvironment( @@ -111,7 +112,11 @@ public static IResourceBuilder WithVercelScope( /// /// The Vercel environment builder. /// The Vercel environment builder. - /// This adds --prod to Vercel CLI deployments and clears any custom target configured with . + /// + /// This adds --prod to Vercel CLI deployments and clears any custom target configured with . + /// Production deployments have deterministic https://{project}.vercel.app aliases that Aspire endpoint references can use. + /// See Vercel deploy CLI documentation. + /// [AspireExport] public static IResourceBuilder WithVercelProductionDeployments( this IResourceBuilder builder) @@ -127,7 +132,11 @@ public static IResourceBuilder WithVercelProductionDe /// The Vercel environment builder. /// The target value passed to vercel deploy --target, such as preview or a custom environment. /// The Vercel environment builder. - /// This clears production deployment mode configured with . + /// + /// This clears production deployment mode configured with . + /// Preview and custom-target URLs are assigned after deployment, so Aspire endpoint references are unavailable for these targets. + /// See Vercel generated URL documentation. + /// [AspireExport] public static IResourceBuilder WithVercelTarget( this IResourceBuilder builder, diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelOidcToken.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelOidcToken.cs index af6a89a06..642c522fe 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelOidcToken.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelOidcToken.cs @@ -19,6 +19,7 @@ public static VercelOidcClaims DecodeUnvalidatedClaims(string token) // This is an unvalidated decode of the Vercel-issued token from `vercel pull`. // Docker/Vercel validate the token when it is used; here we only need routing // metadata such as owner_id/project to construct the VCR login and repository. + // See https://vercel.com/docs/container-registry. byte[] payloadBytes = Convert.FromBase64String(PadBase64Url(parts[1])); var claims = JsonSerializer.Deserialize(payloadBytes); diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectEnvironment.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectEnvironment.cs index ff54fc2bf..1857e8e9e 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectEnvironment.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectEnvironment.cs @@ -4,6 +4,10 @@ internal static class VercelProjectEnvironment { public static string GetName(VercelEnvironmentOptionsAnnotation options) { + // Vercel CLI env commands use "production" and "preview" environment names, while + // deploy uses --prod or --target. Keep this translation in one place so state cleanup + // removes variables from the same Vercel environment deploy configured. + // See https://vercel.com/docs/cli/env and https://vercel.com/docs/cli/deploy. if (options.Production) { return "production"; diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs index 1176cba0c..15db5f896 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs @@ -79,6 +79,7 @@ private static string GetManagedProjectName(VercelDeploymentEntry entry) // The production endpoint contract is project-name based, so managed names must // be stable and Vercel-valid before deploy starts. + // See https://vercel.com/docs/projects/overview. string sourceRoot = Path.TrimEndingDirectorySeparator(entry.SourceRoot); string sourceRootName = Path.GetFileName(sourceRoot); @@ -144,6 +145,7 @@ private static bool TryReadProjectLink(string sourceRoot, [NotNullWhen(true)] ou // .vercel/project.json: { "projectId": "...", "orgId": "...", "projectName": "..." } // Treat it as user/provider ownership metadata rather than regenerating a managed // name. Destroy preserves these linked projects and only removes tracked env vars. + // See https://vercel.com/docs/cli/link. var project = JsonSerializer.Deserialize(File.ReadAllText(projectJsonPath)); if (project is { ProjectName: { } projectName } && !string.IsNullOrWhiteSpace(projectName)) { diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectSettingsReader.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectSettingsReader.cs index 392e8f237..be5305b27 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectSettingsReader.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectSettingsReader.cs @@ -12,6 +12,7 @@ public static VercelPulledProjectSettings Read(string projectJsonPath, string pr { // `vercel pull` writes `.vercel/project.json`. Only project identity fields are // needed: they select the linked provider project and are safe to persist in state. + // See https://vercel.com/docs/cli/pull. var settings = JsonSerializer.Deserialize(projectJsonContent); return new(settings?.ProjectName ?? string.Empty, settings?.ProjectId, settings?.OrgId); From 9eb44e9087ac549784b288b7b1360dc8c0823264 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Fri, 3 Jul 2026 10:51:54 -0700 Subject: [PATCH 28/33] Document Vercel component purposes Add class-level purpose comments across the Vercel deployment components so future maintainers can quickly see each unit's responsibility and why it exists. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../VercelBuildOutputWriter.cs | 4 ++ .../VercelCliArguments.cs | 4 ++ .../VercelCliOutputParser.cs | 4 ++ .../VercelCliRunner.cs | 12 ++++ .../VercelConstants.cs | 4 ++ .../VercelContainerRegistryClient.cs | 8 +++ .../VercelDeploymentModel.cs | 4 ++ .../VercelDeploymentPlanWriter.cs | 4 ++ .../VercelDeploymentStateStore.cs | 4 ++ .../VercelDeploymentStep.BuildOutput.cs | 4 ++ .../VercelDeploymentStep.Cli.cs | 4 ++ .../VercelDeploymentStep.Plan.cs | 4 ++ .../VercelDeploymentStep.State.cs | 4 ++ .../VercelDeploymentStep.Types.cs | 59 +++++++++++++++++++ .../VercelDeploymentStep.cs | 4 ++ .../VercelDockerImageDigestParser.cs | 4 ++ .../VercelDotEnvParser.cs | 4 ++ .../VercelEnvironmentMapper.cs | 4 ++ .../VercelEnvironmentOptionsAnnotation.cs | 4 ++ .../VercelEnvironmentResource.cs | 4 +- ...celEnvironmentResourceBuilderExtensions.cs | 3 +- .../VercelOidcToken.cs | 4 ++ .../VercelPipelineFinalizerAnnotation.cs | 4 ++ .../VercelProjectEnvironment.cs | 4 ++ .../VercelProjectNameResolver.cs | 4 ++ .../VercelProjectOptionsAnnotation.cs | 4 ++ .../VercelProjectSettingsReader.cs | 4 ++ .../VercelResourceBuilderExtensions.cs | 3 +- .../VercelResourceContainerImageManager.cs | 4 ++ .../VercelResourceExtensions.cs | 4 ++ 30 files changed, 182 insertions(+), 3 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelBuildOutputWriter.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelBuildOutputWriter.cs index 5019e39c3..2fdf0a7d7 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelBuildOutputWriter.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelBuildOutputWriter.cs @@ -3,6 +3,10 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Writes the minimal Vercel Build Output API directory that points Vercel at an Aspire-built +/// container image instead of asking Vercel to build source code. +/// internal static class VercelBuildOutputWriter { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliArguments.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliArguments.cs index 7e494fcd5..526f1ac64 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliArguments.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliArguments.cs @@ -1,5 +1,9 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Builds Vercel and Docker command argument arrays in one place so deploy logic can stay +/// provider-oriented while tests assert exact CLI boundaries without shell quoting concerns. +/// internal static class VercelCliArguments { public static string[] BuildDeployArguments(VercelEnvironmentOptionsAnnotation options, VercelDeploymentEntry entry) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliOutputParser.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliOutputParser.cs index 3beef8147..099d09494 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliOutputParser.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliOutputParser.cs @@ -7,6 +7,10 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Parses the structured and legacy text shapes emitted by Vercel CLI commands so the rest of +/// the deploy pipeline deals in typed results instead of CLI-specific output formats. +/// internal static class VercelCliOutputParser { public static string GetDeploymentUrl(string standardOutput) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs index 02190e7c3..83019578a 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs @@ -5,11 +5,19 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Runs external command-line tools for the Vercel integration. Keeping this behind an interface +/// lets tests validate deploy behavior without invoking the real Vercel or Docker CLIs. +/// internal interface IVercelCliRunner { Task RunAsync(string fileName, IReadOnlyList arguments, string? workingDirectory, CancellationToken cancellationToken, string? standardInput = null); } +/// +/// Process-based implementation of that preserves argument and +/// stdin boundaries for cross-platform quoting and secret handling. +/// internal sealed class VercelCliRunner : IVercelCliRunner { public async Task RunAsync(string fileName, IReadOnlyList arguments, string? workingDirectory, CancellationToken cancellationToken, string? standardInput = null) @@ -73,6 +81,10 @@ public async Task RunAsync(string fileName, IReadOnlyList +/// Captures a completed CLI invocation so callers can decide whether to parse stdout, +/// surface stderr, or combine both for diagnostics. +/// internal sealed record VercelCliResult(int ExitCode, string StandardOutput, string StandardError) { public bool Succeeded => ExitCode == 0; diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelConstants.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelConstants.cs index 108e42be1..4eedcac5f 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelConstants.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelConstants.cs @@ -1,5 +1,9 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Centralizes Vercel protocol names and generated-file constants so Build Output, VCR, state, +/// and CLI components stay aligned as provider contracts evolve. +/// internal static class VercelConstants { internal const string DeploymentPlanFileName = "vercel-deployments.json"; diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelContainerRegistryClient.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelContainerRegistryClient.cs index c310bdd7c..499b7243b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelContainerRegistryClient.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelContainerRegistryClient.cs @@ -6,11 +6,19 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Ensures project-scoped Vercel Container Registry repositories exist before Aspire pushes +/// workload images to VCR. +/// internal interface IVercelContainerRegistryClient { Task EnsureRepositoryAsync(string token, VercelOidcClaims claims, string repository, CancellationToken cancellationToken); } +/// +/// Calls the Vercel Container Registry API to converge the repository that Aspire's built-in +/// image push step will target. +/// internal sealed class VercelContainerRegistryClient : IVercelContainerRegistryClient { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs index bd0c99ccb..3496a0a65 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs @@ -16,6 +16,10 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Projects Aspire compute resources into the subset of the app model that Vercel Dockerfile +/// hosting can represent, and rejects unsupported constructs before provider mutation starts. +/// internal static class VercelDeploymentModel { private static readonly string[] VercelJsonBuildOutputUnsupportedKeys = diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPlanWriter.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPlanWriter.cs index 658f2ed4c..7457b8bcf 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPlanWriter.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPlanWriter.cs @@ -9,6 +9,10 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Produces the publish-time Vercel deployment plan: a deterministic, reviewable artifact that +/// shows commands and environment variable names without resolving secrets or mutating Vercel. +/// internal static class VercelDeploymentPlanWriter { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStateStore.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStateStore.cs index bb639083b..ad84a6d66 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStateStore.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStateStore.cs @@ -11,6 +11,10 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Owns the Aspire deployment-state section for Vercel so deploy and destroy know which Vercel +/// projects and environment variables this integration created, even if the AppHost changes later. +/// internal static class VercelDeploymentStateStore { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.BuildOutput.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.BuildOutput.cs index 8f3ee2e8d..8882fe518 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.BuildOutput.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.BuildOutput.cs @@ -7,6 +7,10 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Vercel deployment-step helpers for preparing linked project directories, pulling provider +/// metadata, and writing Build Output API files before invoking vercel deploy --prebuilt. +/// internal static partial class VercelDeploymentStep { private static async Task PrepareProjectEnvironmentDirectoryAsync( diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Cli.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Cli.cs index 4914c618b..aeb45be90 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Cli.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Cli.cs @@ -8,6 +8,10 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Vercel deployment-step helpers for CLI prerequisite validation, deployment verification, +/// and shared error shaping around external tool failures. +/// internal static partial class VercelDeploymentStep { public static async Task ValidateCliPrerequisitesAsync(PipelineStepContext context, VercelEnvironmentResource environment) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Plan.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Plan.cs index dcd9cd0ac..6115a1a72 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Plan.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Plan.cs @@ -11,6 +11,10 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Vercel deployment-step helpers that bridge Aspire pipeline callbacks to the testable +/// publish-plan writer. +/// internal static partial class VercelDeploymentStep { public static async Task WriteDeploymentPlanAsync(PipelineStepContext context, VercelEnvironmentResource environment) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.State.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.State.cs index 1f28f762f..8e11462c7 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.State.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.State.cs @@ -10,6 +10,10 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Vercel deployment-step helpers for state-driven destroy. Cleanup uses persisted ownership +/// instead of the current model so removed resources can still be deleted safely. +/// internal static partial class VercelDeploymentStep { public static async Task DestroyAsync(PipelineStepContext context, VercelEnvironmentResource environment) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Types.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Types.cs index 96c4066a5..3832ec101 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Types.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Types.cs @@ -7,6 +7,10 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Captures the Aspire workload and source/build metadata needed to prepare one Vercel project +/// without repeatedly walking the app model. +/// internal sealed record VercelDeploymentEntry( IResource Resource, string SourceRoot, @@ -18,10 +22,20 @@ internal sealed record VercelDeploymentEntry( public string EffectiveDeployDirectory => string.IsNullOrWhiteSpace(DeployDirectory) ? SourceRoot : DeployDirectory; } +/// +/// Serializable publish output that lets users review which workloads would be deployed to Vercel. +/// internal sealed record VercelDeploymentPlan(string Environment, VercelDeploymentPlanEntry[] Deployments); +/// +/// One workload row in the publish plan, including the Dockerfile source and deploy command shape. +/// internal sealed record VercelDeploymentPlanEntry(string ResourceName, string DockerfilePath, string DeployCommand, string[] EnvironmentVariables); +/// +/// Splits Aspire environment values into Vercel deployment arguments and project environment +/// variables because secrets must be configured through provider storage instead of CLI args. +/// internal sealed record VercelEnvironmentConfiguration( IReadOnlyList> DeploymentEnvironmentVariables, IReadOnlyList> ProjectEnvironmentVariables) @@ -33,12 +47,27 @@ internal sealed record VercelEnvironmentConfiguration( .Concat(ProjectEnvironmentVariables.Select(static variable => variable.Key)); } +/// +/// Typed result from vercel deploy; the URL is used for verification and the optional ID +/// is persisted when the CLI provides it. +/// internal sealed record VercelDeploymentResult(string? DeploymentId, string DeploymentUrl); +/// +/// Typed result from vercel inspect containing only the provider readiness state deploy needs. +/// internal sealed record VercelDeploymentInspection(string? ReadyState); +/// +/// Combines a persisted deployment entry with the Vercel environment name that originally +/// configured it, so redeploy and cleanup target the same provider scope. +/// internal sealed record PreviousVercelDeployment(VercelDeploymentStateEntry Entry, string ProjectEnvironment); +/// +/// Persisted ownership record for a Vercel environment. Destroy uses this state instead of the +/// current AppHost so projects can be cleaned up after resources are renamed or removed. +/// internal sealed record VercelDeploymentState( int SchemaVersion, string Environment, @@ -47,6 +76,9 @@ internal sealed record VercelDeploymentState( bool Production, VercelDeploymentStateEntry[] Deployments); +/// +/// Persisted ownership details for one Vercel project/deployment created or updated by Aspire. +/// internal sealed record VercelDeploymentStateEntry( string ResourceName, string ProjectName, @@ -65,8 +97,15 @@ internal sealed record VercelDeploymentStateEntry( public string[] ProjectEnvironmentVariables { get; init; } = []; } +/// +/// Immutable VCR image reference paired with the digest Vercel Build Output API should deploy. +/// internal sealed record VercelImageReference(string Reference, string Digest); +/// +/// Resource annotation produced during Vercel prerequisite work and consumed by Aspire's image +/// push decorator and deploy step to keep project, token, and image-tag context together. +/// internal sealed record VercelPreparedDeploymentAnnotation( VercelDeploymentEntry Entry, VercelProjectLink ProjectLink, @@ -76,12 +115,22 @@ internal sealed record VercelPreparedDeploymentAnnotation( string RemoteImageTag, string TaggedImageReference) : IResourceAnnotation; +/// +/// Marker annotation used to attach Vercel push options only after project preparation has +/// produced provider-specific VCR image metadata. +/// internal sealed class VercelImagePushOptionsCallbackAnnotation : IResourceAnnotation { } +/// +/// Resolved Vercel project identity, either from a checked-in link file or an Aspire-managed name. +/// internal sealed record VercelProjectLink(string ProjectName, string? ProjectId); +/// +/// Project metadata and OIDC token materialized by vercel pull for one deployment. +/// internal sealed record VercelPulledProject( string ProjectName, string? ProjectId, @@ -89,11 +138,21 @@ internal sealed record VercelPulledProject( string ProjectJsonContent, string OidcToken); +/// +/// Complete provider context needed after project preparation: environment variables, pulled +/// project settings, and decoded claims for VCR operations. +/// internal sealed record VercelPulledProjectContext( VercelEnvironmentConfiguration EnvironmentConfiguration, VercelPulledProject PulledProject, VercelOidcClaims OidcClaims); +/// +/// Safe subset of .vercel/project.json identity fields that can be stored in state. +/// internal sealed record VercelPulledProjectSettings(string ProjectName, string? ProjectId, string? OrgId); +/// +/// Vercel OIDC claims used for VCR routing and repository creation, not for local auth decisions. +/// internal sealed record VercelOidcClaims(string? OwnerId, string? Owner, string? Project, string? ProjectId); diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs index 058ee8575..9835f93db 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs @@ -21,6 +21,10 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Coordinates the Aspire publish, deploy, and destroy pipeline steps for Vercel while +/// delegating provider formats and app-model rules to focused testable helpers. +/// internal static partial class VercelDeploymentStep { // E2E contract: diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDockerImageDigestParser.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDockerImageDigestParser.cs index 716726825..47165506a 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDockerImageDigestParser.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDockerImageDigestParser.cs @@ -6,6 +6,10 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Extracts the Vercel-compatible image manifest digest from Docker inspect output so Build +/// Output API deployments reference immutable VCR images instead of mutable tags or OCI indexes. +/// internal static class VercelDockerImageDigestParser { public static string GetDigest(string output) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDotEnvParser.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDotEnvParser.cs index fce841b66..52867776f 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDotEnvParser.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDotEnvParser.cs @@ -1,5 +1,9 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Parses the limited dotenv syntax emitted by vercel pull so deploy can read the +/// short-lived OIDC token without taking a dependency on user-authored dotenv semantics. +/// internal static class VercelDotEnvParser { public static Dictionary Parse(IEnumerable lines) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentMapper.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentMapper.cs index e006f1b71..1afe3a75c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentMapper.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentMapper.cs @@ -8,6 +8,10 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Converts Aspire execution environment values into Vercel deploy-time and project-scoped +/// environment variables, preserving secret handling and rejecting unsupported references early. +/// internal static class VercelEnvironmentMapper { public static async Task GetConfigurationAsync( diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentOptionsAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentOptionsAnnotation.cs index 55f6c999a..374796f77 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentOptionsAnnotation.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentOptionsAnnotation.cs @@ -2,6 +2,10 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Stores Vercel environment-level options on the Aspire resource model so pipeline steps can +/// consistently apply scope, production, and custom-target behavior. +/// internal sealed record VercelEnvironmentOptionsAnnotation : IResourceAnnotation { public string? Scope { get; init; } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs index 57fa6e067..b0bc6dfa7 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs @@ -8,7 +8,9 @@ namespace Aspire.Hosting.ApplicationModel; /// -/// Represents a Vercel deployment target for Aspire workloads that publish as Dockerfile builds. +/// Represents the Aspire compute environment for Vercel Dockerfile deployments. The resource +/// exists to attach publish/deploy pipeline steps and to model Vercel production aliases for +/// endpoint references. /// /// The Aspire resource name for the Vercel environment. [Experimental("CTASPIREVERCEL001")] diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs index 5ade7db3d..de861ac4f 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs @@ -9,7 +9,8 @@ namespace Aspire.Hosting; /// -/// Provides extension methods for deploying Aspire workloads to Vercel Dockerfile hosting. +/// Provides the public AppHost entry points for adding a Vercel compute environment and choosing +/// how it maps to Vercel scopes, production deployments, and custom targets. /// [Experimental("CTASPIREVERCEL001")] public static class VercelEnvironmentResourceBuilderExtensions diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelOidcToken.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelOidcToken.cs index 642c522fe..ae6aa176b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelOidcToken.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelOidcToken.cs @@ -4,6 +4,10 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Decodes provider-issued OIDC token claims from vercel pull so the integration can +/// route VCR login and repository creation without treating the token as an authentication result. +/// internal static class VercelOidcToken { public static VercelOidcClaims DecodeUnvalidatedClaims(string token) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelPipelineFinalizerAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelPipelineFinalizerAnnotation.cs index e31292b6b..e675b9abc 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelPipelineFinalizerAnnotation.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelPipelineFinalizerAnnotation.cs @@ -2,4 +2,8 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Marks resources that need a late pipeline normalization pass after Vercel project options +/// are attached to individual workload resources. +/// internal sealed class VercelPipelineFinalizerAnnotation : IResourceAnnotation; diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectEnvironment.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectEnvironment.cs index 1857e8e9e..92d450287 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectEnvironment.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectEnvironment.cs @@ -1,5 +1,9 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Translates Aspire's Vercel deployment options into the environment names expected by Vercel +/// project environment-variable commands and persisted state cleanup. +/// internal static class VercelProjectEnvironment { public static string GetName(VercelEnvironmentOptionsAnnotation options) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs index 15db5f896..6d48f5d48 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs @@ -11,6 +11,10 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Resolves the Vercel project identity for a workload, preferring existing linked project +/// metadata and otherwise creating stable Aspire-managed names for repeatable deploy/destroy. +/// internal static class VercelProjectNameResolver { public static string GetProjectName(VercelDeploymentEntry entry) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectOptionsAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectOptionsAnnotation.cs index 0a561c5da..cc9a5e144 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectOptionsAnnotation.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectOptionsAnnotation.cs @@ -2,4 +2,8 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Stores the user-specified Vercel project name for Aspire-managed projects when no +/// checked-in .vercel/project.json link owns the project identity. +/// internal sealed record VercelProjectOptionsAnnotation(string ProjectName) : IResourceAnnotation; diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectSettingsReader.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectSettingsReader.cs index be5305b27..66bf024e8 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectSettingsReader.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectSettingsReader.cs @@ -4,6 +4,10 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Reads the project identity generated by vercel pull so deploy artifacts and state +/// reference the provider project that Vercel CLI linked. +/// internal static class VercelProjectSettingsReader { public static VercelPulledProjectSettings Read(string projectJsonPath, string projectJsonContent) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceBuilderExtensions.cs index 8d93f952e..3cfaf4663 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceBuilderExtensions.cs @@ -5,7 +5,8 @@ namespace Aspire.Hosting; /// -/// Provides extension methods for configuring Aspire workloads deployed to Vercel Dockerfile hosting. +/// Provides public per-workload AppHost options for Vercel deployments, such as overriding the +/// Aspire-managed project name for repeatable provider resources. /// [Experimental("CTASPIREVERCEL001")] public static class VercelResourceBuilderExtensions diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceContainerImageManager.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceContainerImageManager.cs index 1335cfa7c..9c3165e36 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceContainerImageManager.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceContainerImageManager.cs @@ -6,6 +6,10 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Decorates Aspire's container image manager to add the VCR login behavior needed before +/// pushing Vercel-targeted resources, while leaving unrelated resource pushes untouched. +/// internal sealed class VercelResourceContainerImageManager( IResourceContainerImageManager inner, IVercelCliRunner runner) : IResourceContainerImageManager diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceExtensions.cs index dcd95dbee..9c18ec03b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceExtensions.cs @@ -7,6 +7,10 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; +/// +/// Keeps internal Vercel annotation access and pipeline-finalizer wiring out of the public +/// AppHost API while sharing those helpers across resource extension methods and pipeline code. +/// internal static class VercelResourceExtensions { [AspireExportIgnore(Reason = "Internal Vercel annotation access is not part of the generated AppHost API.")] From 221aa7805017256cb9d379decb7c73e0a7120d15 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Fri, 3 Jul 2026 11:31:29 -0700 Subject: [PATCH 29/33] Ignore Vercel wait annotations Stop rejecting Aspire wait relationships for Vercel deployments and cover the behavior with a deployment-plan regression test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../VercelDeploymentModel.cs | 6 ------ .../VercelEnvironmentTests.cs | 18 +++++++++++++----- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs index 3496a0a65..87871df0d 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs @@ -259,12 +259,6 @@ private static void ValidateUnsupportedResourceModel(VercelDeploymentEntry entry $"Resource '{resource.Name}' configures Aspire replicas or scale, but the Vercel preview integration does not map replica counts to Vercel-native scaling. Configure scaling in Vercel instead."); } - if (resource.Annotations.OfType().Any()) - { - throw new DistributedApplicationException( - $"Resource '{resource.Name}' configures Aspire wait/dependency ordering, but Vercel deploys each project independently and the preview integration does not preserve Aspire startup ordering. Remove the wait relationship or deploy dependent services separately."); - } - ValidateEndpointModel(entry); ValidateProjectName(entry); } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs index f6e44fe2b..2c2c234d8 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs @@ -840,7 +840,7 @@ public async Task WriteDeploymentPlanThrowsForReplicas() } [Fact] - public async Task WriteDeploymentPlanThrowsForWaitDependencies() + public async Task WriteDeploymentPlanIgnoresWaitDependencies() { using var sourceRoot = TemporaryDirectory.Create(); using var outputRoot = TemporaryDirectory.Create(); @@ -849,19 +849,27 @@ public async Task WriteDeploymentPlanThrowsForWaitDependencies() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); builder.AddVercelEnvironment("vercel"); var backend = builder.AddContainer("backend", "backend") - .WithDockerfile(sourceRoot.Path, "Dockerfile"); + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithVercelProjectName("backend"); builder.AddContainer("api", "api") .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithVercelProjectName("api") .WaitFor(backend); using var app = builder.Build(); var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); - var exception = await Assert.ThrowsAsync(() => - VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken)); + string planPath = await VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken); + + using var document = JsonDocument.Parse(File.ReadAllText(planPath)); + var deployments = document.RootElement.GetProperty("deployments") + .EnumerateArray() + .Select(static deployment => deployment.GetProperty("resourceName").GetString()!) + .Order(StringComparer.Ordinal) + .ToArray(); - Assert.Contains("wait/dependency ordering", exception.Message); + Assert.Equal(["api", "backend"], deployments); } [Fact] From 7da8ed6d2f4e3edaea08ab28d2cd058851b79e6e Mon Sep 17 00:00:00 2001 From: David Fowler Date: Fri, 3 Jul 2026 11:32:46 -0700 Subject: [PATCH 30/33] Ignore Vercel replica annotations Stop rejecting Aspire replica annotations for Vercel deployments and cover the behavior with a deployment-plan regression test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../VercelDeploymentModel.cs | 6 ------ .../VercelEnvironmentTests.cs | 6 ++---- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs index 87871df0d..1883b3c8b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs @@ -253,12 +253,6 @@ private static void ValidateUnsupportedResourceModel(VercelDeploymentEntry entry $"Resource '{resource.Name}' configures Aspire health checks or container probes, but the Vercel preview integration does not map them to Vercel-native checks. Remove the Aspire probes or configure health behavior in Vercel."); } - if (resource.Annotations.OfType().Any()) - { - throw new DistributedApplicationException( - $"Resource '{resource.Name}' configures Aspire replicas or scale, but the Vercel preview integration does not map replica counts to Vercel-native scaling. Configure scaling in Vercel instead."); - } - ValidateEndpointModel(entry); ValidateProjectName(entry); } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs index 2c2c234d8..a2669182b 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs @@ -831,12 +831,10 @@ public async Task WriteDeploymentPlanThrowsForHealthChecksAndProbes() } [Fact] - public async Task WriteDeploymentPlanThrowsForReplicas() + public async Task WriteDeploymentPlanIgnoresReplicas() { - var exception = await AssertWriteDeploymentPlanThrowsAsync(static api => + await AssertWriteDeploymentPlanSucceedsAsync(static api => api.Resource.Annotations.Add(new ReplicaAnnotation(2))); - - Assert.Contains("replicas or scale", exception.Message); } [Fact] From dec69dc920599f2d72c3233cad71813223fafeb4 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Fri, 3 Jul 2026 11:33:56 -0700 Subject: [PATCH 31/33] Ignore Vercel health checks Stop rejecting Aspire health check annotations for Vercel deployments while continuing to reject container and endpoint probes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../VercelDeploymentModel.cs | 5 ++--- .../VercelEnvironmentTests.cs | 11 +++++++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs index 1883b3c8b..0cc1ec24b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs @@ -246,11 +246,10 @@ private static void ValidateUnsupportedResourceModel(VercelDeploymentEntry entry } if (resource.Annotations.OfType().Any() - || resource.Annotations.OfType().Any() - || resource.Annotations.OfType().Any()) + || resource.Annotations.OfType().Any()) { throw new DistributedApplicationException( - $"Resource '{resource.Name}' configures Aspire health checks or container probes, but the Vercel preview integration does not map them to Vercel-native checks. Remove the Aspire probes or configure health behavior in Vercel."); + $"Resource '{resource.Name}' configures Aspire container probes, but the Vercel preview integration does not map them to Vercel-native checks. Remove the Aspire probes or configure health behavior in Vercel."); } ValidateEndpointModel(entry); diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs index a2669182b..59fb3c0d1 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs @@ -821,13 +821,20 @@ public async Task WriteDeploymentPlanThrowsForContainerFiles() } [Fact] - public async Task WriteDeploymentPlanThrowsForHealthChecksAndProbes() + public async Task WriteDeploymentPlanThrowsForProbes() { var exception = await AssertWriteDeploymentPlanThrowsAsync(static api => api.WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true) .WithHttpProbe(ProbeType.Readiness, path: "/health")); - Assert.Contains("health checks or container probes", exception.Message); + Assert.Contains("container probes", exception.Message); + } + + [Fact] + public async Task WriteDeploymentPlanIgnoresHealthChecks() + { + await AssertWriteDeploymentPlanSucceedsAsync(static api => + api.WithHealthCheck("api-health")); } [Fact] From ecbaf0b1e82fedce4a74136a2cf71b3d4c5366f0 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Fri, 3 Jul 2026 11:49:22 -0700 Subject: [PATCH 32/33] Use typed Vercel CLI runner Replace the standalone Vercel CLI argument bag helper with typed Vercel and Docker runner operations, keeping raw process execution private to the concrete runner and updating tests to exercise the typed seam. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../VercelCliArguments.cs | 270 -------------- .../VercelCliRunner.cs | 342 +++++++++++++++++- .../VercelDeploymentPlanWriter.cs | 4 +- .../VercelDeploymentStep.BuildOutput.cs | 12 +- .../VercelDeploymentStep.Cli.cs | 9 +- .../VercelDeploymentStep.State.cs | 18 +- .../VercelDeploymentStep.cs | 12 +- .../VercelEnvironmentTests.cs | 205 ++++++++--- 8 files changed, 518 insertions(+), 354 deletions(-) delete mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliArguments.cs diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliArguments.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliArguments.cs deleted file mode 100644 index 526f1ac64..000000000 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliArguments.cs +++ /dev/null @@ -1,270 +0,0 @@ -namespace CommunityToolkit.Aspire.Hosting.Vercel; - -/// -/// Builds Vercel and Docker command argument arrays in one place so deploy logic can stay -/// provider-oriented while tests assert exact CLI boundaries without shell quoting concerns. -/// -internal static class VercelCliArguments -{ - public static string[] BuildDeployArguments(VercelEnvironmentOptionsAnnotation options, VercelDeploymentEntry entry) - => BuildDeployArguments(options, entry.EffectiveDeployDirectory, VercelProjectNameResolver.GetProjectOption(entry), environmentVariables: []); - - public static string[] BuildDockerInspectDigestArguments(string imageReference) - // Inspect the pushed tag through Docker buildx so deploy can pin the Vercel Build - // Output API container handler to the linux/amd64 manifest digest Vercel accepts. - // See https://vercel.com/docs/functions/container-images. - => ["buildx", "imagetools", "inspect", "--format", "{{json .Manifest}}", imageReference]; - - public static string[] BuildDestroyProjectArguments(VercelEnvironmentOptionsAnnotation options, string projectName) - { - List arguments = []; - - arguments.Add("project"); - arguments.Add("remove"); - arguments.Add(projectName); - AddOptionalScopeArgument(arguments, options); - - return [.. arguments]; - } - - public static string[] BuildListProjectEnvironmentVariablesArguments( - VercelEnvironmentOptionsAnnotation options, - string projectLinkDirectory, - string targetEnvironment) - { - List arguments = []; - - arguments.Add("env"); - arguments.Add("ls"); - arguments.Add(targetEnvironment); - AddOptionalScopeArgument(arguments, options); - // Environment variables are project-scoped in the Vercel CLI. The linked scratch - // directory selects the project; JSON output avoids parsing localized table text. - // See https://vercel.com/docs/cli/env. - arguments.Add("--cwd"); - arguments.Add(projectLinkDirectory); - arguments.Add("--format=json"); - - return [.. arguments]; - } - - public static string[] BuildAddProjectArguments(VercelEnvironmentOptionsAnnotation options, string projectName) - { - List arguments = []; - - arguments.Add("project"); - arguments.Add("add"); - arguments.Add(projectName); - AddOptionalScopeArgument(arguments, options); - - return [.. arguments]; - } - - public static string[] BuildAddProjectEnvironmentVariableArguments( - VercelEnvironmentOptionsAnnotation options, - string projectLinkDirectory, - string name, - string targetEnvironment) - { - List arguments = []; - - arguments.Add("env"); - arguments.Add("add"); - arguments.Add(name); - arguments.Add(targetEnvironment); - AddOptionalScopeArgument(arguments, options); - // Vercel env commands must run inside a linked project directory. Use the - // Aspire-owned scratch link instead of the source root so provider metadata - // and pulled env files never mutate the user's checkout. - // --sensitive ensures the value is stored in Vercel's project env store instead - // of being visible in generated Build Output or deploy command arguments. - // See https://vercel.com/docs/cli/env and https://vercel.com/docs/environment-variables/sensitive-environment-variables. - arguments.Add("--cwd"); - arguments.Add(projectLinkDirectory); - arguments.Add("--yes"); - arguments.Add("--force"); - arguments.Add("--sensitive"); - - return [.. arguments]; - } - - public static string[] BuildRemoveProjectEnvironmentVariableArguments( - VercelEnvironmentOptionsAnnotation options, - string projectLinkDirectory, - string name, - string targetEnvironment) - { - List arguments = []; - - arguments.Add("env"); - arguments.Add("rm"); - arguments.Add(name); - arguments.Add(targetEnvironment); - AddOptionalScopeArgument(arguments, options); - arguments.Add("--cwd"); - arguments.Add(projectLinkDirectory); - arguments.Add("--yes"); - - return [.. arguments]; - } - - public static string[] BuildLinkProjectArguments( - VercelEnvironmentOptionsAnnotation options, - string projectLinkDirectory, - string projectNameOrId) - { - List arguments = []; - - arguments.Add("link"); - AddOptionalScopeArgument(arguments, options); - // Link writes .vercel/project.json in the selected working directory. Always link a - // scratch directory; checked-in .vercel/project.json in the source root is read-only - // user intent, not something deploy should mutate. - // See https://vercel.com/docs/cli/link. - arguments.Add("--cwd"); - arguments.Add(projectLinkDirectory); - arguments.Add("--yes"); - arguments.Add("--project"); - arguments.Add(projectNameOrId); - - return [.. arguments]; - } - - public static string[] BuildPullProjectSettingsArguments( - VercelEnvironmentOptionsAnnotation options, - string projectLinkDirectory, - string targetEnvironment) - { - List arguments = []; - - arguments.Add("pull"); - AddOptionalScopeArgument(arguments, options); - // Pull materializes .vercel/project.json plus .vercel/.env..local. - // The integration reads the project identity and VCR OIDC token, then deletes the - // pulled env files before writing deploy artifacts. - // See https://vercel.com/docs/cli/pull. - arguments.Add("--cwd"); - arguments.Add(projectLinkDirectory); - arguments.Add("--yes"); - arguments.Add("--environment"); - arguments.Add(targetEnvironment); - - return [.. arguments]; - } - - public static string[] BuildDockerLoginArguments(string username) - => ["login", VercelConstants.VcrRegistry, "--username", username, "--password-stdin"]; - - public static string[] BuildInspectDeploymentArguments(VercelEnvironmentOptionsAnnotation options, string deploymentUrl) - { - List arguments = []; - - arguments.Add("inspect"); - arguments.Add(deploymentUrl); - AddOptionalScopeArgument(arguments, options); - // Deploy returning successfully only confirms submission. Ask inspect to wait for the - // provider's final JSON state before deployment state is persisted. - // See https://vercel.com/docs/cli/inspect. - arguments.Add("--wait"); - arguments.Add("--timeout"); - arguments.Add("120s"); - arguments.Add("--format=json"); - - return [.. arguments]; - } - - public static string[] BuildValidateScopeArguments(VercelEnvironmentOptionsAnnotation options) - => BuildListProjectsArguments(options); - - public static string[] BuildListProjectsArguments(VercelEnvironmentOptionsAnnotation options, string? filter = null) - { - List arguments = []; - - arguments.Add("project"); - arguments.Add("ls"); - AddOptionalScopeArgument(arguments, options); - if (!string.IsNullOrWhiteSpace(filter)) - { - arguments.Add("--filter"); - arguments.Add(filter); - } - - arguments.Add("--format=json"); - - return [.. arguments]; - } - - public static string[] BuildDeployArguments( - VercelEnvironmentOptionsAnnotation options, - string deployDirectory, - string? projectNameOrId, - IReadOnlyList> environmentVariables) - { - List arguments = []; - - arguments.Add("deploy"); - AddOptionalScopeArgument(arguments, options); - arguments.Add("--cwd"); - arguments.Add(deployDirectory); - AddOptionalProjectArgument(arguments, projectNameOrId); - // --prebuilt tells the CLI to upload the generated .vercel/output tree rather than - // running a source build. That tree is produced by VercelBuildOutputWriter. - // See https://vercel.com/docs/cli/deploy and https://vercel.com/docs/build-output-api. - arguments.Add("--prebuilt"); - arguments.Add("--yes"); - - if (options.Production) - { - arguments.Add("--prod"); - } - - if (!string.IsNullOrWhiteSpace(options.Target)) - { - arguments.Add("--target"); - arguments.Add(options.Target); - } - - foreach (var environmentVariable in environmentVariables.OrderBy(static variable => variable.Key, StringComparer.Ordinal)) - { - arguments.Add("--env"); - arguments.Add($"{environmentVariable.Key}={environmentVariable.Value}"); - } - - return [.. arguments]; - } - - public static string BuildDisplayDeployCommand( - VercelEnvironmentOptionsAnnotation options, - string resourceName, - IReadOnlyList> environmentVariables) - { - // The plan should explain the command shape without leaking concrete source roots, - // project names, or environment values that may be machine- or account-specific. - var displayEnvironmentVariables = environmentVariables - .Select(static environmentVariable => new KeyValuePair(environmentVariable.Key, "")) - .ToArray(); - - string displayImage = $"{VercelConstants.VcrRegistry}///{VercelConstants.ContainerServiceName}:"; - string displayDeployDirectory = $"<{resourceName}-build-output>"; - string displayProject = $"<{resourceName}-vercel-project>"; - return $"vercel pull --cwd <{resourceName}-vercel-project-link> --yes --environment {VercelProjectEnvironment.GetName(options)} && aspire build/push {resourceName} -> {displayImage} && docker {string.Join(" ", BuildDockerInspectDigestArguments(displayImage))} && vercel {string.Join(" ", BuildDeployArguments(options, displayDeployDirectory, displayProject, displayEnvironmentVariables))}"; - } - - private static void AddOptionalProjectArgument(List arguments, string? projectNameOrId) - { - if (!string.IsNullOrWhiteSpace(projectNameOrId)) - { - arguments.Add("--project"); - arguments.Add(projectNameOrId); - } - } - - private static void AddOptionalScopeArgument(List arguments, VercelEnvironmentOptionsAnnotation options) - { - if (!string.IsNullOrWhiteSpace(options.Scope)) - { - arguments.Add("--scope"); - arguments.Add(options.Scope); - } - } -} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs index 83019578a..14bb1e1e6 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs @@ -6,12 +6,41 @@ namespace CommunityToolkit.Aspire.Hosting.Vercel; /// -/// Runs external command-line tools for the Vercel integration. Keeping this behind an interface -/// lets tests validate deploy behavior without invoking the real Vercel or Docker CLIs. +/// Runs typed Vercel and Docker CLI operations for the Vercel integration. Keeping this behind +/// an interface lets tests validate deploy behavior without invoking the real tools or passing +/// generic argument bags through deployment code. /// internal interface IVercelCliRunner { - Task RunAsync(string fileName, IReadOnlyList arguments, string? workingDirectory, CancellationToken cancellationToken, string? standardInput = null); + Task GetVersionAsync(CancellationToken cancellationToken); + + Task GetCurrentUserAsync(CancellationToken cancellationToken); + + Task ListProjectsAsync(VercelEnvironmentOptionsAnnotation options, string? filter, CancellationToken cancellationToken); + + Task AddProjectAsync(VercelEnvironmentOptionsAnnotation options, string projectName, CancellationToken cancellationToken); + + Task RemoveProjectAsync(VercelEnvironmentOptionsAnnotation options, string projectName, CancellationToken cancellationToken); + + Task LinkProjectAsync(VercelEnvironmentOptionsAnnotation options, string projectLinkDirectory, string projectNameOrId, CancellationToken cancellationToken); + + Task PullProjectSettingsAsync(VercelEnvironmentOptionsAnnotation options, string projectLinkDirectory, string targetEnvironment, CancellationToken cancellationToken); + + Task ListProjectEnvironmentVariablesAsync(VercelEnvironmentOptionsAnnotation options, string projectLinkDirectory, string targetEnvironment, CancellationToken cancellationToken); + + Task AddProjectEnvironmentVariableAsync(VercelEnvironmentOptionsAnnotation options, string projectLinkDirectory, string name, string targetEnvironment, string value, CancellationToken cancellationToken); + + Task RemoveProjectEnvironmentVariableAsync(VercelEnvironmentOptionsAnnotation options, string projectLinkDirectory, string name, string targetEnvironment, CancellationToken cancellationToken); + + Task DeployPrebuiltAsync(VercelEnvironmentOptionsAnnotation options, string deployDirectory, string? projectNameOrId, IReadOnlyList> environmentVariables, CancellationToken cancellationToken); + + Task InspectDeploymentAsync(VercelEnvironmentOptionsAnnotation options, string deploymentUrl, CancellationToken cancellationToken); + + Task ValidateDockerBuildxAsync(CancellationToken cancellationToken); + + Task LoginToVcrAsync(string ownerId, string oidcToken, CancellationToken cancellationToken); + + Task InspectDockerImageDigestAsync(string imageReference, CancellationToken cancellationToken); } /// @@ -20,7 +49,312 @@ internal interface IVercelCliRunner /// internal sealed class VercelCliRunner : IVercelCliRunner { - public async Task RunAsync(string fileName, IReadOnlyList arguments, string? workingDirectory, CancellationToken cancellationToken, string? standardInput = null) + internal delegate Task ProcessRunner( + string fileName, + IReadOnlyList arguments, + string? workingDirectory, + CancellationToken cancellationToken, + string? standardInput); + + private readonly ProcessRunner _processRunner; + + public VercelCliRunner() + : this(RunProcessAsync) + { + } + + internal VercelCliRunner(ProcessRunner processRunner) + { + _processRunner = processRunner; + } + + public Task GetVersionAsync(CancellationToken cancellationToken) + => RunVercelAsync(["--version"], workingDirectory: null, cancellationToken); + + public Task GetCurrentUserAsync(CancellationToken cancellationToken) + => RunVercelAsync(["whoami"], workingDirectory: null, cancellationToken); + + public Task ListProjectsAsync( + VercelEnvironmentOptionsAnnotation options, + string? filter, + CancellationToken cancellationToken) + { + List arguments = ["project", "ls"]; + AddOptionalScopeArgument(arguments, options); + if (!string.IsNullOrWhiteSpace(filter)) + { + arguments.Add("--filter"); + arguments.Add(filter); + } + + arguments.Add("--format=json"); + + return RunVercelAsync(arguments, workingDirectory: null, cancellationToken); + } + + public Task AddProjectAsync( + VercelEnvironmentOptionsAnnotation options, + string projectName, + CancellationToken cancellationToken) + { + List arguments = ["project", "add", projectName]; + AddOptionalScopeArgument(arguments, options); + + return RunVercelAsync(arguments, workingDirectory: null, cancellationToken); + } + + public Task RemoveProjectAsync( + VercelEnvironmentOptionsAnnotation options, + string projectName, + CancellationToken cancellationToken) + { + List arguments = ["project", "remove", projectName]; + AddOptionalScopeArgument(arguments, options); + + return RunVercelAsync(arguments, workingDirectory: null, cancellationToken, standardInput: "y\n"); + } + + public Task LinkProjectAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string projectNameOrId, + CancellationToken cancellationToken) + { + List arguments = ["link"]; + AddOptionalScopeArgument(arguments, options); + // Link writes .vercel/project.json in the selected working directory. Always link a + // scratch directory; checked-in .vercel/project.json in the source root is read-only + // user intent, not something deploy should mutate. + // See https://vercel.com/docs/cli/link. + arguments.Add("--cwd"); + arguments.Add(projectLinkDirectory); + arguments.Add("--yes"); + arguments.Add("--project"); + arguments.Add(projectNameOrId); + + return RunVercelAsync(arguments, projectLinkDirectory, cancellationToken); + } + + public Task PullProjectSettingsAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string targetEnvironment, + CancellationToken cancellationToken) + { + List arguments = ["pull"]; + AddOptionalScopeArgument(arguments, options); + // Pull materializes .vercel/project.json plus .vercel/.env..local. + // The integration reads the project identity and VCR OIDC token, then deletes the + // pulled env files before writing deploy artifacts. + // See https://vercel.com/docs/cli/pull. + arguments.Add("--cwd"); + arguments.Add(projectLinkDirectory); + arguments.Add("--yes"); + arguments.Add("--environment"); + arguments.Add(targetEnvironment); + + return RunVercelAsync(arguments, projectLinkDirectory, cancellationToken); + } + + public Task ListProjectEnvironmentVariablesAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string targetEnvironment, + CancellationToken cancellationToken) + { + List arguments = ["env", "ls", targetEnvironment]; + AddOptionalScopeArgument(arguments, options); + // Environment variables are project-scoped in the Vercel CLI. The linked scratch + // directory selects the project; JSON output avoids parsing localized table text. + // See https://vercel.com/docs/cli/env. + arguments.Add("--cwd"); + arguments.Add(projectLinkDirectory); + arguments.Add("--format=json"); + + return RunVercelAsync(arguments, projectLinkDirectory, cancellationToken); + } + + public Task AddProjectEnvironmentVariableAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string name, + string targetEnvironment, + string value, + CancellationToken cancellationToken) + { + List arguments = ["env", "add", name, targetEnvironment]; + AddOptionalScopeArgument(arguments, options); + // Vercel env commands must run inside a linked project directory. Use the + // Aspire-owned scratch link instead of the source root so provider metadata + // and pulled env files never mutate the user's checkout. + // --sensitive ensures the value is stored in Vercel's project env store instead + // of being visible in generated Build Output or deploy command arguments. + // See https://vercel.com/docs/cli/env and https://vercel.com/docs/environment-variables/sensitive-environment-variables. + arguments.Add("--cwd"); + arguments.Add(projectLinkDirectory); + arguments.Add("--yes"); + arguments.Add("--force"); + arguments.Add("--sensitive"); + + return RunVercelAsync(arguments, projectLinkDirectory, cancellationToken, standardInput: value); + } + + public Task RemoveProjectEnvironmentVariableAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string name, + string targetEnvironment, + CancellationToken cancellationToken) + { + List arguments = ["env", "rm", name, targetEnvironment]; + AddOptionalScopeArgument(arguments, options); + arguments.Add("--cwd"); + arguments.Add(projectLinkDirectory); + arguments.Add("--yes"); + + return RunVercelAsync(arguments, projectLinkDirectory, cancellationToken); + } + + public Task DeployPrebuiltAsync( + VercelEnvironmentOptionsAnnotation options, + string deployDirectory, + string? projectNameOrId, + IReadOnlyList> environmentVariables, + CancellationToken cancellationToken) + => RunVercelAsync(BuildDeployPrebuiltArguments(options, deployDirectory, projectNameOrId, environmentVariables), deployDirectory, cancellationToken); + + public Task InspectDeploymentAsync( + VercelEnvironmentOptionsAnnotation options, + string deploymentUrl, + CancellationToken cancellationToken) + { + List arguments = ["inspect", deploymentUrl]; + AddOptionalScopeArgument(arguments, options); + // Deploy returning successfully only confirms submission. Ask inspect to wait for the + // provider's final JSON state before deployment state is persisted. + // See https://vercel.com/docs/cli/inspect. + arguments.Add("--wait"); + arguments.Add("--timeout"); + arguments.Add("120s"); + arguments.Add("--format=json"); + + return RunVercelAsync(arguments, workingDirectory: null, cancellationToken); + } + + public Task ValidateDockerBuildxAsync(CancellationToken cancellationToken) + => RunDockerAsync(["buildx", "version"], workingDirectory: null, cancellationToken); + + public Task LoginToVcrAsync(string ownerId, string oidcToken, CancellationToken cancellationToken) + => RunDockerAsync(["login", VercelConstants.VcrRegistry, "--username", ownerId, "--password-stdin"], workingDirectory: null, cancellationToken, standardInput: oidcToken); + + public Task InspectDockerImageDigestAsync(string imageReference, CancellationToken cancellationToken) + // Inspect the pushed tag through Docker buildx so deploy can pin the Vercel Build + // Output API container handler to the linux/amd64 manifest digest Vercel accepts. + // See https://vercel.com/docs/functions/container-images. + => RunDockerAsync(BuildDockerInspectDigestArguments(imageReference), workingDirectory: null, cancellationToken); + + internal static string[] BuildDeployPrebuiltArgumentsForPlan( + VercelEnvironmentOptionsAnnotation options, + string deployDirectory, + string? projectNameOrId, + IReadOnlyList> environmentVariables) + => BuildDeployPrebuiltArguments(options, deployDirectory, projectNameOrId, environmentVariables); + + internal static string BuildDisplayDeployCommand( + VercelEnvironmentOptionsAnnotation options, + string resourceName, + IReadOnlyList> environmentVariables) + { + // The plan should explain the command shape without leaking concrete source roots, + // project names, or environment values that may be machine- or account-specific. + var displayEnvironmentVariables = environmentVariables + .Select(static environmentVariable => new KeyValuePair(environmentVariable.Key, "")) + .ToArray(); + + string displayImage = $"{VercelConstants.VcrRegistry}///{VercelConstants.ContainerServiceName}:"; + string displayDeployDirectory = $"<{resourceName}-build-output>"; + string displayProject = $"<{resourceName}-vercel-project>"; + return $"vercel pull --cwd <{resourceName}-vercel-project-link> --yes --environment {VercelProjectEnvironment.GetName(options)} && aspire build/push {resourceName} -> {displayImage} && docker {string.Join(" ", BuildDockerInspectDigestArguments(displayImage))} && vercel {string.Join(" ", BuildDeployPrebuiltArguments(options, displayDeployDirectory, displayProject, displayEnvironmentVariables))}"; + } + + private Task RunVercelAsync( + IReadOnlyList arguments, + string? workingDirectory, + CancellationToken cancellationToken, + string? standardInput = null) + => _processRunner(VercelConstants.CliFileName, arguments, workingDirectory, cancellationToken, standardInput); + + private Task RunDockerAsync( + IReadOnlyList arguments, + string? workingDirectory, + CancellationToken cancellationToken, + string? standardInput = null) + => _processRunner(VercelConstants.DockerCliFileName, arguments, workingDirectory, cancellationToken, standardInput); + + private static string[] BuildDockerInspectDigestArguments(string imageReference) + => ["buildx", "imagetools", "inspect", "--format", "{{json .Manifest}}", imageReference]; + + private static string[] BuildDeployPrebuiltArguments( + VercelEnvironmentOptionsAnnotation options, + string deployDirectory, + string? projectNameOrId, + IReadOnlyList> environmentVariables) + { + List arguments = ["deploy"]; + AddOptionalScopeArgument(arguments, options); + arguments.Add("--cwd"); + arguments.Add(deployDirectory); + AddOptionalProjectArgument(arguments, projectNameOrId); + // --prebuilt tells the CLI to upload the generated .vercel/output tree rather than + // running a source build. That tree is produced by VercelBuildOutputWriter. + // See https://vercel.com/docs/cli/deploy and https://vercel.com/docs/build-output-api. + arguments.Add("--prebuilt"); + arguments.Add("--yes"); + + if (options.Production) + { + arguments.Add("--prod"); + } + + if (!string.IsNullOrWhiteSpace(options.Target)) + { + arguments.Add("--target"); + arguments.Add(options.Target); + } + + foreach (var environmentVariable in environmentVariables.OrderBy(static variable => variable.Key, StringComparer.Ordinal)) + { + arguments.Add("--env"); + arguments.Add($"{environmentVariable.Key}={environmentVariable.Value}"); + } + + return [.. arguments]; + } + + private static void AddOptionalProjectArgument(List arguments, string? projectNameOrId) + { + if (!string.IsNullOrWhiteSpace(projectNameOrId)) + { + arguments.Add("--project"); + arguments.Add(projectNameOrId); + } + } + + private static void AddOptionalScopeArgument(List arguments, VercelEnvironmentOptionsAnnotation options) + { + if (!string.IsNullOrWhiteSpace(options.Scope)) + { + arguments.Add("--scope"); + arguments.Add(options.Scope); + } + } + + private static async Task RunProcessAsync( + string fileName, + IReadOnlyList arguments, + string? workingDirectory, + CancellationToken cancellationToken, + string? standardInput) { // Keep all target CLI calls behind this runner so unit tests can assert exact // executable/argument/stdin boundaries. Secrets use stdin; arguments are never diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPlanWriter.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPlanWriter.cs index 7457b8bcf..9bd078146 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPlanWriter.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPlanWriter.cs @@ -101,7 +101,7 @@ public static async Task BuildDeployArgumentsAsync( resolveProjectEnvironmentVariableValues: false, cancellationToken).ConfigureAwait(false); - return VercelCliArguments.BuildDeployArguments(options, entry.EffectiveDeployDirectory, VercelProjectNameResolver.GetProjectOption(entry), environmentConfiguration.DeploymentEnvironmentVariables); + return VercelCliRunner.BuildDeployPrebuiltArgumentsForPlan(options, entry.EffectiveDeployDirectory, VercelProjectNameResolver.GetProjectOption(entry), environmentConfiguration.DeploymentEnvironmentVariables); } public static async Task GetEnvironmentConfigurationAsync( @@ -160,7 +160,7 @@ private static async Task CreateEntriesAsync( planEntries.Add(new( entry.Resource.Name, VercelDeploymentModel.GetDisplayDockerfilePath(entry), - VercelCliArguments.BuildDisplayDeployCommand(options, entry.Resource.Name, environmentConfiguration.DeploymentEnvironmentVariables), + VercelCliRunner.BuildDisplayDeployCommand(options, entry.Resource.Name, environmentConfiguration.DeploymentEnvironmentVariables), [.. environmentConfiguration.AllEnvironmentVariableNames.Order(StringComparer.Ordinal)])); } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.BuildOutput.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.BuildOutput.cs index 8882fe518..11713b6dc 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.BuildOutput.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.BuildOutput.cs @@ -31,8 +31,7 @@ private static async Task PrepareProjectEnvironmentDirectoryAsync( // See https://vercel.com/docs/cli/env and https://vercel.com/docs/cli/link. // Link a scratch directory instead of the source root so secret configuration can use // the CLI's native project lookup without writing .vercel metadata into user code. - string[] linkArguments = VercelCliArguments.BuildLinkProjectArguments(options, projectLinkDirectory, VercelProjectNameResolver.GetProjectOption(entry)); - var result = await runner.RunAsync(VercelCliFileName, linkArguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); + var result = await runner.LinkProjectAsync(options, projectLinkDirectory, VercelProjectNameResolver.GetProjectOption(entry), context.CancellationToken).ConfigureAwait(false); if (!result.Succeeded) { throw CreateCliException($"prepare temporary Vercel project link for resource '{entry.Resource.Name}'", VercelCliFileName, result); @@ -52,8 +51,7 @@ private static async Task PullProjectSettingsAsync( // files under `.vercel/`; VCR authentication depends on the pulled VERCEL_OIDC_TOKEN. // See https://vercel.com/docs/cli/pull and https://vercel.com/docs/container-registry. string targetEnvironment = VercelProjectEnvironment.GetName(options); - string[] arguments = VercelCliArguments.BuildPullProjectSettingsArguments(options, projectLinkDirectory, targetEnvironment); - var result = await runner.RunAsync(VercelCliFileName, arguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); + var result = await runner.PullProjectSettingsAsync(options, projectLinkDirectory, targetEnvironment, context.CancellationToken).ConfigureAwait(false); if (!result.Succeeded) { throw CreateCliException($"pull Vercel project settings for resource '{entry.Resource.Name}'", VercelCliFileName, result); @@ -121,8 +119,7 @@ internal static async Task LoginToVcrAsync( // VCR supports Docker-compatible tooling at vcr.vercel.com. This login uses the // Vercel-issued OIDC token pulled for the linked project. // See https://vercel.com/docs/container-registry. - string[] arguments = VercelCliArguments.BuildDockerLoginArguments(claims.OwnerId); - var result = await runner.RunAsync(DockerCliFileName, arguments, workingDirectory: null, cancellationToken, standardInput: oidcToken).ConfigureAwait(false); + var result = await runner.LoginToVcrAsync(claims.OwnerId, oidcToken, cancellationToken).ConfigureAwait(false); if (!result.Succeeded) { throw CreateCliException("authenticate Docker to VCR", DockerCliFileName, result); @@ -139,8 +136,7 @@ private static async Task EnsureManagedProjectAsync( // this integration supports: it creates the project or validates that it already // exists and is accessible. Failure here means deploy should not proceed to image push. string projectName = VercelProjectNameResolver.GetProjectName(entry); - string[] arguments = VercelCliArguments.BuildAddProjectArguments(options, projectName); - var result = await runner.RunAsync(VercelCliFileName, arguments, workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + var result = await runner.AddProjectAsync(options, projectName, context.CancellationToken).ConfigureAwait(false); if (!result.Succeeded) { throw CreateCliException($"create or validate Vercel project '{projectName}' for resource '{entry.Resource.Name}'", VercelCliFileName, result); diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Cli.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Cli.cs index aeb45be90..4d0e21666 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Cli.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Cli.cs @@ -19,7 +19,7 @@ public static async Task ValidateCliPrerequisitesAsync(PipelineStepContext conte var options = environment.GetVercelOptions(); var runner = context.Services.GetRequiredService(); - var versionResult = await runner.RunAsync(VercelCliFileName, ["--version"], workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + var versionResult = await runner.GetVersionAsync(context.CancellationToken).ConfigureAwait(false); if (!versionResult.Succeeded) { throw CreateCliException("validate Vercel CLI installation", VercelCliFileName, versionResult); @@ -41,7 +41,7 @@ public static async Task ValidateCliPrerequisitesAsync(PipelineStepContext conte $"Vercel CLI version '{version}' is not supported. Install Vercel CLI {MinimumVercelCliVersion} or later from https://vercel.com/docs/cli."); } - var whoamiResult = await runner.RunAsync(VercelCliFileName, ["whoami"], workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + var whoamiResult = await runner.GetCurrentUserAsync(context.CancellationToken).ConfigureAwait(false); if (!whoamiResult.Succeeded) { throw CreateCliException("validate Vercel authentication", VercelCliFileName, whoamiResult); @@ -49,7 +49,7 @@ public static async Task ValidateCliPrerequisitesAsync(PipelineStepContext conte if (!string.IsNullOrWhiteSpace(options.Scope)) { - var scopeResult = await runner.RunAsync(VercelCliFileName, VercelCliArguments.BuildValidateScopeArguments(options), workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + var scopeResult = await runner.ListProjectsAsync(options, filter: null, context.CancellationToken).ConfigureAwait(false); if (!scopeResult.Succeeded) { throw CreateCliException($"validate Vercel scope '{options.Scope}'", VercelCliFileName, scopeResult); @@ -67,8 +67,7 @@ private static async Task VerifyDeploymentAsync( // A successful `vercel deploy` only means the CLI accepted the submission. // Query the provider before recording state so Aspire does not persist failed // or still-building deployments as successfully applied resources. - string[] arguments = VercelCliArguments.BuildInspectDeploymentArguments(options, deploymentResult.DeploymentUrl); - var result = await runner.RunAsync(VercelCliFileName, arguments, workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + var result = await runner.InspectDeploymentAsync(options, deploymentResult.DeploymentUrl, context.CancellationToken).ConfigureAwait(false); if (!result.Succeeded) { diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.State.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.State.cs index 8e11462c7..3fded97b3 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.State.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.State.cs @@ -87,8 +87,7 @@ await RemoveLinkedProjectEnvironmentVariablesAsync( } else { - string[] arguments = VercelCliArguments.BuildDestroyProjectArguments(options, projectName); - var result = await runner.RunAsync(VercelCliFileName, arguments, workingDirectory: null, context.CancellationToken, standardInput: "y\n").ConfigureAwait(false); + var result = await runner.RemoveProjectAsync(options, projectName, context.CancellationToken).ConfigureAwait(false); if (!result.Succeeded) { @@ -124,8 +123,7 @@ private static async Task ProjectExistsAsync( // Avoid treating localized or reformatted CLI errors as provider state. The Vercel // CLI exposes project lists as JSON, so destroy checks exact project names before // deleting and again after a failed delete to distinguish races from real failures. - string[] arguments = VercelCliArguments.BuildListProjectsArguments(options, projectName); - var result = await runner.RunAsync(VercelCliFileName, arguments, workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + var result = await runner.ListProjectsAsync(options, projectName, context.CancellationToken).ConfigureAwait(false); if (!result.Succeeded) { throw CreateCliException($"list Vercel projects while checking for '{projectName}'", VercelCliFileName, result); @@ -145,8 +143,7 @@ private static async Task ProjectEnvironmentVariableExistsAsync( // `vercel env rm` reports absence as human text, but `vercel env ls --format=json` // returns the linked project's exact keys. Use that provider read for idempotent // stale-secret cleanup instead of parsing failure prose. - string[] arguments = VercelCliArguments.BuildListProjectEnvironmentVariablesArguments(options, projectLinkDirectory, targetEnvironment); - var result = await runner.RunAsync(VercelCliFileName, arguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); + var result = await runner.ListProjectEnvironmentVariablesAsync(options, projectLinkDirectory, targetEnvironment, context.CancellationToken).ConfigureAwait(false); if (!result.Succeeded) { throw CreateCliException($"list Vercel project environment variables before removing '{name}'", VercelCliFileName, result); @@ -193,8 +190,7 @@ await RemoveProjectEnvironmentVariablesAsync( string targetEnvironment = VercelProjectEnvironment.GetName(options); foreach (var environmentVariable in environmentVariables.OrderBy(static variable => variable.Key, StringComparer.Ordinal)) { - string[] arguments = VercelCliArguments.BuildAddProjectEnvironmentVariableArguments(options, projectLinkDirectory, environmentVariable.Key, targetEnvironment); - var result = await runner.RunAsync(VercelCliFileName, arguments, projectLinkDirectory, context.CancellationToken, standardInput: environmentVariable.Value).ConfigureAwait(false); + var result = await runner.AddProjectEnvironmentVariableAsync(options, projectLinkDirectory, environmentVariable.Key, targetEnvironment, environmentVariable.Value, context.CancellationToken).ConfigureAwait(false); if (!result.Succeeded) { throw CreateCliException($"configure Vercel project environment variable '{environmentVariable.Key}' for resource '{entry.Resource.Name}'", VercelCliFileName, result); @@ -218,8 +214,7 @@ private static async Task RemoveProjectEnvironmentVariablesAsync( continue; } - string[] arguments = VercelCliArguments.BuildRemoveProjectEnvironmentVariableArguments(options, projectLinkDirectory, name, targetEnvironment); - var result = await runner.RunAsync(VercelCliFileName, arguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); + var result = await runner.RemoveProjectEnvironmentVariableAsync(options, projectLinkDirectory, name, targetEnvironment, context.CancellationToken).ConfigureAwait(false); if (!result.Succeeded && await ProjectEnvironmentVariableExistsAsync(context, runner, options, projectLinkDirectory, name, targetEnvironment).ConfigureAwait(false)) { @@ -246,8 +241,7 @@ private static async Task RemoveLinkedProjectEnvironmentVariablesAsync( try { - string[] linkArguments = VercelCliArguments.BuildLinkProjectArguments(options, projectLinkDirectory, deployment.ProjectId ?? deployment.ProjectName); - var linkResult = await runner.RunAsync(VercelCliFileName, linkArguments, projectLinkDirectory, context.CancellationToken).ConfigureAwait(false); + var linkResult = await runner.LinkProjectAsync(options, projectLinkDirectory, deployment.ProjectId ?? deployment.ProjectName, context.CancellationToken).ConfigureAwait(false); if (!linkResult.Succeeded) { throw CreateCliException($"link Vercel project '{deployment.ProjectName}' for environment variable cleanup", VercelCliFileName, linkResult); diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs index 9835f93db..3073369f0 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs @@ -92,7 +92,7 @@ await PrepareResourceForBuiltInImagePushAsync( private static async Task ValidateDockerDigestInspectionPrerequisitesAsync(PipelineStepContext context) { var runner = context.Services.GetRequiredService(); - var result = await runner.RunAsync(DockerCliFileName, ["buildx", "version"], workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + var result = await runner.ValidateDockerBuildxAsync(context.CancellationToken).ConfigureAwait(false); if (!result.Succeeded) { throw CreateCliException("validate Docker buildx for VCR image digest inspection", DockerCliFileName, result); @@ -396,13 +396,12 @@ private static async Task DeployPrebuiltOutputAsync( // `vercel deploy --prebuilt` uploads metadata only, not a staged source tree. await VercelBuildOutputWriter.WriteAsync(entry, projectContext.PulledProject, image.Reference, context.CancellationToken).ConfigureAwait(false); - string[] deployArguments = VercelCliArguments.BuildDeployArguments( + var result = await runner.DeployPrebuiltAsync( options, entry.DeployDirectory, VercelProjectNameResolver.GetProjectOption(entry), - projectContext.EnvironmentConfiguration.DeploymentEnvironmentVariables); - - var result = await runner.RunAsync(VercelCliFileName, deployArguments, entry.DeployDirectory, context.CancellationToken).ConfigureAwait(false); + projectContext.EnvironmentConfiguration.DeploymentEnvironmentVariables, + context.CancellationToken).ConfigureAwait(false); if (!result.Succeeded) { throw CreateCliException($"deploy prebuilt resource '{entry.Resource.Name}' to Vercel", VercelCliFileName, result); @@ -503,8 +502,7 @@ await LoginToVcrAsync( preparedDeployment.ProjectContext.OidcClaims, context.CancellationToken).ConfigureAwait(false); - string[] arguments = VercelCliArguments.BuildDockerInspectDigestArguments(preparedDeployment.TaggedImageReference); - var result = await runner.RunAsync(DockerCliFileName, arguments, workingDirectory: null, context.CancellationToken).ConfigureAwait(false); + var result = await runner.InspectDockerImageDigestAsync(preparedDeployment.TaggedImageReference, context.CancellationToken).ConfigureAwait(false); if (!result.Succeeded) { throw CreateCliException($"resolve pushed VCR image digest for resource '{preparedDeployment.Entry.Resource.Name}'", DockerCliFileName, result); diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs index 59fb3c0d1..7f96cb3ce 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs @@ -1136,26 +1136,33 @@ public async Task DeployAsyncUsesGeneratedLocalConfigWithConfiguredProjectName() } [Fact] - public void BuildDeployArgumentsIncludesConfiguredOptions() + public async Task DeployPrebuiltUsesConfiguredOptions() { var options = new VercelEnvironmentOptionsAnnotation { Production = true, Scope = "team" }; - var entry = CreateDeploymentEntry("/repo/src/api"); + var runner = new FakeVercelCliRunner(); - string[] arguments = VercelCliArguments.BuildDeployArguments(options, entry); + await runner.DeployPrebuiltAsync(options, "/repo/src/api", "api", [], TestContext.Current.CancellationToken); - Assert.Equal(["deploy", "--scope", "team", "--cwd", "/repo/src/api", "--project", "api", "--prebuilt", "--yes", "--prod"], arguments); + var invocation = Assert.Single(runner.Invocations); + Assert.Equal("vercel", invocation.FileName); + Assert.Equal("/repo/src/api", invocation.WorkingDirectory); + Assert.Equal(["deploy", "--scope", "team", "--cwd", "/repo/src/api", "--project", "api", "--prebuilt", "--yes", "--prod"], invocation.Arguments); } [Fact] - public void BuildDockerInspectDigestArgumentsUsesBuildxImagetools() + public async Task InspectDockerImageDigestUsesBuildxImagetools() { - string[] arguments = VercelCliArguments.BuildDockerInspectDigestArguments("vcr.vercel.com/team/project/app:tag"); + var runner = new FakeVercelCliRunner(); + + await runner.InspectDockerImageDigestAsync("vcr.vercel.com/team/project/app:tag", TestContext.Current.CancellationToken); - Assert.Equal(["buildx", "imagetools", "inspect", "--format", "{{json .Manifest}}", "vcr.vercel.com/team/project/app:tag"], arguments); + var invocation = Assert.Single(runner.Invocations); + Assert.Equal("docker", invocation.FileName); + Assert.Equal(["buildx", "imagetools", "inspect", "--format", "{{json .Manifest}}", "vcr.vercel.com/team/project/app:tag"], invocation.Arguments); } [Fact] @@ -1280,89 +1287,101 @@ public void EnvironmentVariableListContainsNameUsesExactJsonKeyAndSkipsBranchSco } [Fact] - public void BuildProjectEnvironmentVariableArgumentsUseLinkedScratchDirectory() + public async Task ProjectEnvironmentVariableOperationsUseLinkedScratchDirectory() { var options = new VercelEnvironmentOptionsAnnotation { Scope = "team" }; + var runner = new FakeVercelCliRunner(); - string[] linkArguments = VercelCliArguments.BuildLinkProjectArguments(options, "/tmp/vercel-link", "api-project"); - string[] envArguments = VercelCliArguments.BuildAddProjectEnvironmentVariableArguments(options, "/tmp/vercel-link", "API_KEY", "production"); - string[] removeEnvArguments = VercelCliArguments.BuildRemoveProjectEnvironmentVariableArguments(options, "/tmp/vercel-link", "API_KEY", "production"); - string[] listEnvArguments = VercelCliArguments.BuildListProjectEnvironmentVariablesArguments(options, "/tmp/vercel-link", "production"); + await runner.LinkProjectAsync(options, "/tmp/vercel-link", "api-project", TestContext.Current.CancellationToken); + await runner.AddProjectEnvironmentVariableAsync(options, "/tmp/vercel-link", "API_KEY", "production", "secret", TestContext.Current.CancellationToken); + await runner.RemoveProjectEnvironmentVariableAsync(options, "/tmp/vercel-link", "API_KEY", "production", TestContext.Current.CancellationToken); + await runner.ListProjectEnvironmentVariablesAsync(options, "/tmp/vercel-link", "production", TestContext.Current.CancellationToken); - Assert.Equal(["link", "--scope", "team", "--cwd", "/tmp/vercel-link", "--yes", "--project", "api-project"], linkArguments); - Assert.Equal(["env", "add", "API_KEY", "production", "--scope", "team", "--cwd", "/tmp/vercel-link", "--yes", "--force", "--sensitive"], envArguments); - Assert.Equal(["env", "rm", "API_KEY", "production", "--scope", "team", "--cwd", "/tmp/vercel-link", "--yes"], removeEnvArguments); - Assert.Equal(["env", "ls", "production", "--scope", "team", "--cwd", "/tmp/vercel-link", "--format=json"], listEnvArguments); - Assert.DoesNotContain("--project", envArguments); + Assert.Equal(["link", "--scope", "team", "--cwd", "/tmp/vercel-link", "--yes", "--project", "api-project"], runner.Invocations[0].Arguments); + Assert.Equal(["env", "add", "API_KEY", "production", "--scope", "team", "--cwd", "/tmp/vercel-link", "--yes", "--force", "--sensitive"], runner.Invocations[1].Arguments); + Assert.Equal("secret", runner.Invocations[1].StandardInput); + Assert.Equal(["env", "rm", "API_KEY", "production", "--scope", "team", "--cwd", "/tmp/vercel-link", "--yes"], runner.Invocations[2].Arguments); + Assert.Equal(["env", "ls", "production", "--scope", "team", "--cwd", "/tmp/vercel-link", "--format=json"], runner.Invocations[3].Arguments); + Assert.DoesNotContain("--project", runner.Invocations[1].Arguments); } [Fact] - public void BuildDeployArgumentsIncludesTarget() + public async Task DeployPrebuiltIncludesTarget() { var options = new VercelEnvironmentOptionsAnnotation { Target = "preview" }; - var entry = CreateDeploymentEntry("/repo/src/api"); + var runner = new FakeVercelCliRunner(); - string[] arguments = VercelCliArguments.BuildDeployArguments(options, entry); + await runner.DeployPrebuiltAsync(options, "/repo/src/api", "api", [], TestContext.Current.CancellationToken); - Assert.Equal(["deploy", "--cwd", "/repo/src/api", "--project", "api", "--prebuilt", "--yes", "--target", "preview"], arguments); + var invocation = Assert.Single(runner.Invocations); + Assert.Equal(["deploy", "--cwd", "/repo/src/api", "--project", "api", "--prebuilt", "--yes", "--target", "preview"], invocation.Arguments); } [Fact] - public void BuildDestroyProjectArgumentsIncludesConfiguredOptions() + public async Task RemoveProjectIncludesConfiguredOptions() { var options = new VercelEnvironmentOptionsAnnotation { Scope = "team" }; + var runner = new FakeVercelCliRunner(); - string[] arguments = VercelCliArguments.BuildDestroyProjectArguments(options, "api"); + await runner.RemoveProjectAsync(options, "api", TestContext.Current.CancellationToken); - Assert.Equal(["project", "remove", "api", "--scope", "team"], arguments); + var invocation = Assert.Single(runner.Invocations); + Assert.Equal(["project", "remove", "api", "--scope", "team"], invocation.Arguments); + Assert.Equal("y\n", invocation.StandardInput); } [Fact] - public void BuildValidateScopeArgumentsIncludesConfiguredOptions() + public async Task ListProjectsWithoutFilterIncludesConfiguredOptions() { var options = new VercelEnvironmentOptionsAnnotation { Scope = "team" }; + var runner = new FakeVercelCliRunner(); - string[] arguments = VercelCliArguments.BuildValidateScopeArguments(options); + await runner.ListProjectsAsync(options, filter: null, TestContext.Current.CancellationToken); - Assert.Equal(["project", "ls", "--scope", "team", "--format=json"], arguments); + var invocation = Assert.Single(runner.Invocations); + Assert.Equal(["project", "ls", "--scope", "team", "--format=json"], invocation.Arguments); } [Fact] - public void BuildListProjectsArgumentsIncludesFilter() + public async Task ListProjectsIncludesFilter() { var options = new VercelEnvironmentOptionsAnnotation { Scope = "team" }; + var runner = new FakeVercelCliRunner(); - string[] arguments = VercelCliArguments.BuildListProjectsArguments(options, "api"); + await runner.ListProjectsAsync(options, "api", TestContext.Current.CancellationToken); - Assert.Equal(["project", "ls", "--scope", "team", "--filter", "api", "--format=json"], arguments); + var invocation = Assert.Single(runner.Invocations); + Assert.Equal(["project", "ls", "--scope", "team", "--filter", "api", "--format=json"], invocation.Arguments); } [Fact] - public void BuildInspectDeploymentArgumentsIncludesConfiguredOptions() + public async Task InspectDeploymentIncludesConfiguredOptions() { var options = new VercelEnvironmentOptionsAnnotation { Scope = "team" }; + var runner = new FakeVercelCliRunner(); - string[] arguments = VercelCliArguments.BuildInspectDeploymentArguments(options, "https://api.vercel.app"); + await runner.InspectDeploymentAsync(options, "https://api.vercel.app", TestContext.Current.CancellationToken); - Assert.Equal(["inspect", "https://api.vercel.app", "--scope", "team", "--wait", "--timeout", "120s", "--format=json"], arguments); + var invocation = Assert.Single(runner.Invocations); + Assert.Equal(["inspect", "https://api.vercel.app", "--scope", "team", "--wait", "--timeout", "120s", "--format=json"], invocation.Arguments); } [Fact] @@ -2139,7 +2158,7 @@ public async Task DeployAsyncRunsVercelCliAndSavesDeploymentState() var digestInvocation = Assert.Single(runner.Invocations, invocation => invocation.FileName == "docker" && invocation.Arguments.Contains("imagetools")); var invocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("deploy")); string expectedDeployDirectory = Path.Combine(tempRoot.Path, "api", "vercel-build-output"); - Assert.Equal(VercelCliArguments.BuildDockerInspectDigestArguments(digestInvocation.Arguments[^1]), digestInvocation.Arguments); + Assert.Equal(["buildx", "imagetools", "inspect", "--format", "{{json .Manifest}}", digestInvocation.Arguments[^1]], digestInvocation.Arguments); Assert.StartsWith("vcr.vercel.com/test-team/test-project/app:", digestInvocation.Arguments[^1], StringComparison.Ordinal); Assert.Equal("vercel", invocation.FileName); Assert.Equal(expectedDeployDirectory, invocation.WorkingDirectory); @@ -3260,26 +3279,28 @@ public async Task DestroyAsyncPreservesStateWhenProjectRemovalFails() } [Fact] - public async Task VercelCliRunnerRunsProcessAndCapturesOutput() + public async Task VercelCliRunnerInvokesTypedProcessOperation() { - var runner = new VercelCliRunner(); + var runner = new VercelCliRunner((fileName, arguments, workingDirectory, cancellationToken, standardInput) => + Task.FromResult(new VercelCliResult(0, $"{fileName} {string.Join(" ", arguments)}", ""))); - var result = await runner.RunAsync("dotnet", ["--version"], Directory.GetCurrentDirectory(), TestContext.Current.CancellationToken); + var result = await runner.GetVersionAsync(TestContext.Current.CancellationToken); Assert.True(result.Succeeded); Assert.Equal(0, result.ExitCode); - Assert.False(string.IsNullOrWhiteSpace(result.StandardOutput)); + Assert.Equal("vercel --version", result.StandardOutput); } [Fact] - public async Task VercelCliRunnerThrowsWhenProcessCannotStart() + public async Task VercelCliRunnerSurfacesTypedProcessFailures() { - var runner = new VercelCliRunner(); + var runner = new VercelCliRunner((_, _, _, _, _) => + Task.FromResult(new VercelCliResult(1, "", "failed"))); - var exception = await Assert.ThrowsAsync(() => - runner.RunAsync($"missing-vercel-cli-{Guid.NewGuid():N}", [], workingDirectory: null, TestContext.Current.CancellationToken)); + var result = await runner.GetVersionAsync(TestContext.Current.CancellationToken); - Assert.Contains("could not be started", exception.Message); + Assert.False(result.Succeeded); + Assert.Equal("failed", result.StandardError); } [Fact] @@ -3809,9 +3830,10 @@ public void Dispose() } } - private sealed class FakeVercelCliRunner(params VercelCliResult[] results) : IVercelCliRunner + private sealed class FakeVercelCliRunner : IVercelCliRunner { - private readonly Queue _results = new(results); + private readonly VercelCliRunner _runner; + private readonly Queue _results; private readonly HashSet _projects = new(StringComparer.Ordinal); private readonly Dictionary _linkedProjects = new(StringComparer.Ordinal); private readonly HashSet _projectEnvironmentVariables = new(StringComparer.Ordinal) @@ -3850,7 +3872,98 @@ private sealed class FakeVercelCliRunner(params VercelCliResult[] results) : IVe public List Invocations { get; } = []; - public Task RunAsync( + public FakeVercelCliRunner(params VercelCliResult[] results) + { + _results = new(results); + _runner = new(RunProcessAsync); + } + + public Task GetVersionAsync(CancellationToken cancellationToken) + => _runner.GetVersionAsync(cancellationToken); + + public Task GetCurrentUserAsync(CancellationToken cancellationToken) + => _runner.GetCurrentUserAsync(cancellationToken); + + public Task ListProjectsAsync( + VercelEnvironmentOptionsAnnotation options, + string? filter, + CancellationToken cancellationToken) + => _runner.ListProjectsAsync(options, filter, cancellationToken); + + public Task AddProjectAsync( + VercelEnvironmentOptionsAnnotation options, + string projectName, + CancellationToken cancellationToken) + => _runner.AddProjectAsync(options, projectName, cancellationToken); + + public Task RemoveProjectAsync( + VercelEnvironmentOptionsAnnotation options, + string projectName, + CancellationToken cancellationToken) + => _runner.RemoveProjectAsync(options, projectName, cancellationToken); + + public Task LinkProjectAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string projectNameOrId, + CancellationToken cancellationToken) + => _runner.LinkProjectAsync(options, projectLinkDirectory, projectNameOrId, cancellationToken); + + public Task PullProjectSettingsAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string targetEnvironment, + CancellationToken cancellationToken) + => _runner.PullProjectSettingsAsync(options, projectLinkDirectory, targetEnvironment, cancellationToken); + + public Task ListProjectEnvironmentVariablesAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string targetEnvironment, + CancellationToken cancellationToken) + => _runner.ListProjectEnvironmentVariablesAsync(options, projectLinkDirectory, targetEnvironment, cancellationToken); + + public Task AddProjectEnvironmentVariableAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string name, + string targetEnvironment, + string value, + CancellationToken cancellationToken) + => _runner.AddProjectEnvironmentVariableAsync(options, projectLinkDirectory, name, targetEnvironment, value, cancellationToken); + + public Task RemoveProjectEnvironmentVariableAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string name, + string targetEnvironment, + CancellationToken cancellationToken) + => _runner.RemoveProjectEnvironmentVariableAsync(options, projectLinkDirectory, name, targetEnvironment, cancellationToken); + + public Task DeployPrebuiltAsync( + VercelEnvironmentOptionsAnnotation options, + string deployDirectory, + string? projectNameOrId, + IReadOnlyList> environmentVariables, + CancellationToken cancellationToken) + => _runner.DeployPrebuiltAsync(options, deployDirectory, projectNameOrId, environmentVariables, cancellationToken); + + public Task InspectDeploymentAsync( + VercelEnvironmentOptionsAnnotation options, + string deploymentUrl, + CancellationToken cancellationToken) + => _runner.InspectDeploymentAsync(options, deploymentUrl, cancellationToken); + + public Task ValidateDockerBuildxAsync(CancellationToken cancellationToken) + => _runner.ValidateDockerBuildxAsync(cancellationToken); + + public Task LoginToVcrAsync(string ownerId, string oidcToken, CancellationToken cancellationToken) + => _runner.LoginToVcrAsync(ownerId, oidcToken, cancellationToken); + + public Task InspectDockerImageDigestAsync(string imageReference, CancellationToken cancellationToken) + => _runner.InspectDockerImageDigestAsync(imageReference, cancellationToken); + + private Task RunProcessAsync( string fileName, IReadOnlyList arguments, string? workingDirectory, From f3fd6b5e8421fd89b4c3dc89bcd60a352c5dd747 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Fri, 3 Jul 2026 20:32:40 -0700 Subject: [PATCH 33/33] Redesign Vercel deployment cardinality Map public Aspire workloads to Vercel projects and referenced private workloads to Vercel services. Generate service-aware Build Output API artifacts, push service-scoped VCR images, and validate unsupported grouping shapes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../README.md | 91 ++- .../VercelBuildOutputWriter.cs | 121 ++- .../VercelCliRunner.cs | 3 +- .../VercelDeploymentModel.cs | 40 - .../VercelDeploymentPlanWriter.cs | 17 +- .../VercelDeploymentProjectGrouper.cs | 202 +++++ .../VercelDeploymentStep.Types.cs | 78 +- .../VercelDeploymentStep.cs | 321 +++++--- .../VercelEnvironmentMapper.cs | 154 +++- .../VercelProjectNameResolver.cs | 12 +- .../VercelEnvironmentTests.cs | 700 ++++++++++++++++-- 11 files changed, 1475 insertions(+), 264 deletions(-) create mode 100644 src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentProjectGrouper.cs diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md index e0373c73f..3fd31095a 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md @@ -1,6 +1,6 @@ # Vercel hosting integration -Use this integration to model, configure, and deploy Aspire workloads to Vercel's Dockerfile-based hosting. +Use this integration to model, configure, and deploy Aspire workloads to Vercel's Dockerfile-based hosting and Vercel Services. ## Getting started @@ -38,14 +38,14 @@ builder.AddContainer("api", "api") .WithDockerfile("../api", "Dockerfile"); ``` -By default, `aspire deploy` runs: +By default, each public Aspire workload becomes a Vercel project root. Referenced non-public workloads become private Vercel Services inside that project and get their own VCR repository: ```bash vercel link --cwd --yes --project vercel pull --cwd --yes --environment docker login vcr.vercel.com --username --password-stdin -aspire build/push -> vcr.vercel.com///app: -docker buildx imagetools inspect --format '{{json .Manifest}}' vcr.vercel.com///app: +aspire build/push -> vcr.vercel.com///: +docker buildx imagetools inspect --format '{{json .Manifest}}' vcr.vercel.com///: vercel deploy --cwd --project --prebuilt --yes ``` @@ -61,7 +61,7 @@ builder.AddContainer("api", "api") .WithComputeEnvironment(vercel); ``` -Use `WithVercelProjectName` when an Aspire-managed resource should deploy to a specific Vercel project name instead of inferring one from the source directory: +Use `WithVercelProjectName` when an Aspire-managed public project root should deploy to a specific Vercel project name instead of inferring one from the source directory: ```csharp builder.AddContainer("api", "api") @@ -69,7 +69,39 @@ builder.AddContainer("api", "api") .WithVercelProjectName("my-api"); ``` -Linked projects with `.vercel/project.json` keep their existing provider project identity and take precedence over `WithVercelProjectName`. +Linked projects with `.vercel/project.json` keep their existing provider project identity and take precedence over `WithVercelProjectName`. Project names apply only to public project roots; private services use the normalized Aspire resource name as their Vercel service and VCR repository name. + +## Project and service mapping + +The integration maps Aspire resources to Vercel resources using the public routing shape of your app: + +| Aspire model | Vercel model | +| --- | --- | +| One Vercel-targeted resource | One Vercel project with one public root service, preserving the original single-resource behavior. | +| Multiple Vercel-targeted resources with external HTTP endpoints | One Vercel project per external HTTP resource. Each project receives public traffic through its root service. | +| A non-public resource referenced by exactly one public root | A private Vercel Service inside that public root's project. The service gets its own image repository at `vcr.vercel.com///:`. | +| A non-public resource referenced by multiple public roots | Rejected as ambiguous until an explicit mapping API exists. Deploy the shared resource separately or reference it from exactly one public workload. | +| A non-public resource with no public owner | Rejected. Add an external HTTP endpoint, reference it from one public workload, or deploy it separately. | + +Vercel Services are private by default. Public traffic reaches only the project root through the generated project-level rewrite. Same-project server-to-server references are emitted as Vercel service bindings, so the caller receives a private service URL in the requested environment variable. Cross-project references are emitted as deterministic production URLs and therefore require `WithVercelProductionDeployments`. + +Example with one public app and one private backend: + +```csharp +var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelProductionDeployments(); + +var backend = builder.AddNodeApp("backend", "../backend", "server.mjs") + .WithEndpoint(name: "http", scheme: "http", env: "PORT", isExternal: false) + .WithComputeEnvironment(vercel); + +builder.AddNodeApp("frontend", "../frontend", "server.mjs") + .WithEndpoint(name: "http", scheme: "http", env: "PORT", isExternal: true) + .WithEnvironment("BACKEND_URL", backend.GetEndpoint("http")) + .WithComputeEnvironment(vercel); +``` + +This produces one Vercel project rooted by `frontend`, two Vercel Services (`frontend` and `backend`), two VCR repositories under the same project, and a service binding that injects `BACKEND_URL` into `frontend`. For low-level container resources, Vercel requires Aspire image-build metadata. `WithDockerfile` is supported for checked-in Dockerfiles and Containerfiles, including custom names or paths outside the source root. Generated Dockerfiles (`WithDockerfileFactory` / `WithDockerfileBuilder`) and language integrations such as `AddNodeApp` use Aspire's built-in image build path without staging source or writing generated files into the user checkout. Resource-level `WithContainerRegistry` remains unsupported for Vercel-targeted resources because the integration must push to the Vercel project-owned VCR repository used by the generated Build Output API metadata. Add a `.dockerignore` file to keep local files such as `.env`, test artifacts, large assets, and other files out of the local Docker build context. @@ -79,14 +111,15 @@ For low-level container resources, Vercel requires Aspire image-build metadata. 1. `aspire publish` validates the targeted resources and writes `vercel-deployments.json`, a deterministic review plan with command shape, Dockerfile/source information, and environment variable names. It does not call Vercel, resolve secrets, or push images. 2. The deploy prerequisite step validates Vercel CLI, Docker buildx, Vercel auth, configured scope, existing state compatibility, Vercel project names, endpoints, and unsupported Aspire concepts before provider mutation. -3. For each resource, the integration creates or links a Vercel project in an Aspire-owned scratch directory, configures sensitive project environment variables with `vercel env add --sensitive` over standard input, and runs [`vercel pull`](https://vercel.com/docs/cli/pull) in that scratch directory to obtain `.vercel/project.json`, `.vercel/.env..local`, and the short-lived `VERCEL_OIDC_TOKEN`. -4. The integration decodes only routing claims from the Vercel OIDC JWT payload, configures [Vercel Container Registry](https://vercel.com/docs/container-registry) (`vcr.vercel.com//`) as the resource's Aspire deployment target registry, and lets Aspire's built-in build/push steps build the workload image and push it to VCR. -5. After the push, deploy logs in to VCR again, runs `docker buildx imagetools inspect --format '{{json .Manifest}}'`, and selects the linux/amd64 manifest digest. Live Vercel validation rejected OCI index digests, so the generated artifact references the concrete platform manifest accepted by [Vercel Container Images](https://vercel.com/docs/functions/container-images). -6. Deploy writes [Vercel Build Output API](https://vercel.com/docs/build-output-api) v3 files under Aspire temp/output storage: `.vercel/project.json`, `.vercel/output/config.json`, and `.vercel/output/functions/index.func/.vc-config.json` with `runtime: "container"` and a digest-pinned VCR `handler`. -7. Deploy runs [`vercel deploy --prebuilt`](https://vercel.com/docs/cli/deploy), parses the deployment URL from JSON or plain CLI output, verifies readiness with [`vercel inspect --wait`](https://vercel.com/docs/cli/inspect), records deployment URLs/image digests/state, and adds deployment URLs to the pipeline summary. -8. `aspire destroy` reads saved deployment state first, removes tracked project environment variables from linked projects, deletes only Aspire-managed Vercel projects, treats already-missing projects as converged, and saves partial state after each successful delete so retry remains safe. +3. The deploy prerequisite step groups resources into Vercel project roots and private services before provider mutation. Grouping failures happen before any Vercel project is created. +4. For each project group, the integration creates or links one Vercel project in an Aspire-owned scratch directory, configures sensitive project environment variables with `vercel env add --sensitive` over standard input, and runs [`vercel pull`](https://vercel.com/docs/cli/pull) once in that scratch directory to obtain `.vercel/project.json`, `.vercel/.env..local`, and the short-lived `VERCEL_OIDC_TOKEN`. +5. The integration decodes only routing claims from the Vercel OIDC JWT payload, configures [Vercel Container Registry](https://vercel.com/docs/container-registry) (`vcr.vercel.com//`) as each service resource's Aspire deployment target registry, and lets Aspire's built-in build/push steps build each service image and push it to the service-specific VCR repository. +6. After the push, deploy logs in to VCR again for each service, runs `docker buildx imagetools inspect --format '{{json .Manifest}}'`, and selects the linux/amd64 manifest digest. Live Vercel validation rejected OCI index digests, so the generated artifact references the concrete platform manifest accepted by [Vercel Container Images](https://vercel.com/docs/functions/container-images). +7. Deploy writes [Vercel Build Output API](https://vercel.com/docs/build-output-api) v3 files under Aspire temp/output storage: `.vercel/project.json`, root `.vercel/output/config.json` with Vercel Services and bindings, and per-service `.vercel/output/services//functions/index.func/.vc-config.json` files with `runtime: "container"` and digest-pinned VCR `handler` values. +8. Deploy runs one [`vercel deploy --prebuilt`](https://vercel.com/docs/cli/deploy) per project group, parses the deployment URL from JSON or plain CLI output, verifies readiness with [`vercel inspect --wait`](https://vercel.com/docs/cli/inspect), records deployment URLs/image digests/state, and adds deployment URLs to the pipeline summary. +9. `aspire destroy` reads saved deployment state first, removes tracked project environment variables from linked projects, deletes only Aspire-managed Vercel projects, treats already-missing projects as converged, and saves partial state after each successful delete so retry remains safe. -Non-secret Aspire environment variables configured on the resource are processed during publish/deploy and passed to Vercel as deployment-scoped CLI environment variables: +Non-secret Aspire environment variables configured on a resource are processed during publish/deploy and written to that service's generated Build Output container function config: ```csharp builder.AddContainer("api", "api") @@ -94,9 +127,7 @@ builder.AddContainer("api", "api") .WithEnvironment("GREETING", "hello"); ``` -```bash -vercel deploy --cwd --project --prebuilt --yes --env GREETING=hello -``` +The value is not passed with `vercel deploy --env`; it is written into the generated `.vc-config.json` for that service. Secret parameters, connection strings, and composite values that contain secrets are configured as [sensitive Vercel project environment variables](https://vercel.com/docs/environment-variables/sensitive-environment-variables) before deploy. Because [`vercel env add`](https://vercel.com/docs/cli/env) is scoped through [`vercel link`](https://vercel.com/docs/cli/link) metadata and does not accept `--project`, the integration links a temporary scratch directory outside the source tree and sends the value through standard input instead of putting it on the command line: @@ -115,7 +146,7 @@ vercel env add API_KEY production --cwd --yes --force --s Secret project environment variables are written to `production` when `WithVercelProductionDeployments` is used, to the configured `WithVercelTarget` value when one is set, and to `preview` otherwise. Deploy treats secret environment variable names configured in the AppHost as Aspire-owned for that Vercel target and overwrites those values on each deploy. -Production endpoint references to other Vercel-targeted workloads are converted to deterministic Vercel project URLs: +Endpoint references to other Vercel-targeted workloads are mapped based on project grouping. Same-project references become service bindings; cross-project references become deterministic production project URLs. ```csharp var vercel = builder.AddVercelEnvironment("vercel") @@ -126,30 +157,31 @@ var backend = builder.AddNodeApp("backend", "../backend", "server.mjs") .WithComputeEnvironment(vercel); builder.AddNodeApp("api", "../api", "server.mjs") + .WithEndpoint(name: "http", scheme: "http", env: "PORT", isExternal: true) .WithEnvironment("BACKEND_URL", backend.GetEndpoint("http")) .WithComputeEnvironment(vercel); ``` -The `BACKEND_URL` value is deployed as `https://.vercel.app`. Endpoint references must target external HTTP or HTTPS endpoints on workloads in the same Vercel environment. Preview and custom-target deployments still reject endpoint references because those URLs are assigned by Vercel after deployment. +Because both resources are public project roots, `BACKEND_URL` is deployed as `https://.vercel.app`. Preview and custom-target deployments reject cross-project endpoint references because those URLs are assigned by Vercel after deployment. Same-project service bindings can only inject direct URL-valued environment variables. ## Deployment behavior - `aspire start` is unchanged. The Vercel environment is publish/deploy-only. -- `aspire publish` writes a deterministic `vercel-deployments.json` plan for the targeted resources, including the environment variable names passed to Vercel. -- `aspire deploy` validates the Vercel CLI, Docker authentication, and configured scope; creates Aspire-managed Vercel projects when needed; links a temporary scratch directory for project-scoped Vercel CLI operations; configures secret project environment variables; pulls Vercel project settings into that scratch directory for VCR OIDC authentication; configures VCR as the target registry for Aspire's built-in build/push steps; resolves the pushed image tag to a digest; generates a minimal Build Output API directory; invokes `vercel deploy --prebuilt`; and verifies the resulting deployment with `vercel inspect --wait --format=json` before saving deployment state for each verified resource. The deployment summary records the deployment URL, deployment state records the VCR image digest and Build Output API version for diagnostics, and production deployments also record the deterministic `https://.vercel.app` production URL. +- `aspire publish` writes a deterministic `vercel-deployments.json` plan for the targeted resources, including environment variable and service binding names. +- `aspire deploy` validates the Vercel CLI, Docker authentication, and configured scope; groups public project roots and private services; creates Aspire-managed Vercel projects when needed; links one temporary scratch directory per project group for project-scoped Vercel CLI operations; configures secret project environment variables; pulls Vercel project settings into that scratch directory for VCR OIDC authentication; configures one VCR repository per service for Aspire's built-in build/push steps; resolves pushed image tags to digests; generates a Vercel Services Build Output API directory; invokes `vercel deploy --prebuilt` once per project group; and verifies each deployment with `vercel inspect --wait --format=json` before saving deployment state. The deployment summary records the deployment URL, deployment state records each service's VCR image digest and Build Output API version for diagnostics, and production deployments also record the deterministic `https://.vercel.app` production URL. - `aspire destroy` reads Aspire deployment state first and removes only Vercel projects that were created by this integration for the same environment/scope. Missing state or unmanaged-only state does not require Vercel CLI/auth. State is preserved if project removal fails. ## Validation coverage -The implementation keeps Vercel-specific behavior unit-testable behind injectable services and pure helpers. Unit tests verify pipeline step registration and direct step actions, Aspire built-in build/push ordering, VCR registry annotations, linux/amd64 build target selection, project/env/destroy state transitions, generated Build Output API files, secret redaction, exact CLI argument boundaries, Docker digest parsing, Vercel deploy/inspect output parsing, compact JWT claim decoding, Vercel dotenv parsing, TypeScript AppHost publish/projection, the TypeScript start smoke when `pwsh` is available, and failure messages for unsupported Aspire concepts. +The implementation keeps Vercel-specific behavior unit-testable behind injectable services and pure helpers. Unit tests verify pipeline step registration and direct step actions, Aspire built-in build/push ordering, project/service grouping, VCR registry annotations, linux/amd64 build target selection, project/env/destroy state transitions, generated Build Output API files for Vercel Services and service bindings, secret redaction, exact CLI argument boundaries, Docker digest parsing, Vercel deploy/inspect output parsing, compact JWT claim decoding, Vercel dotenv parsing, TypeScript AppHost publish/projection, the TypeScript start smoke when `pwsh` is available, and failure messages for unsupported Aspire concepts. -The integration was also validated against Vercel end to end during development: Dockerfile workloads were built and pushed to VCR through Aspire's built-in pipeline, deployed with `vercel deploy --prebuilt`, verified with `vercel inspect`, called through the deployed URL, and cleaned up with destroy. +The integration was also validated against Vercel end to end during development: Dockerfile workloads were built and pushed to VCR through Aspire's built-in pipeline, deployed with `vercel deploy --prebuilt`, verified with `vercel inspect`, called through the deployed URL where deployment protection allowed it, and cleaned up with destroy. The Vercel Services Build Output shape was separately deployed against real disposable Vercel projects to confirm `vercel deploy --prebuilt` accepts generated services and bindings. ## Prerequisites - An Aspire workload that participates in Aspire's image build/push model, such as a .NET project, a language app resource that emits Dockerfile metadata, or a resource configured with `WithDockerfile`. - The deployed container should listen on `$PORT`; the default port is `80`. -- Vercel CLI 54.18.6 or later installed and available on `PATH`. This preview depends on `vercel link`, `vercel pull`, `deploy --prebuilt`, `deploy --env`, `inspect --wait --timeout --format=json`, and `project remove`; older CLI versions are rejected during deploy preflight. +- Vercel CLI 54.18.6 or later installed and available on `PATH`. This preview depends on `vercel link`, `vercel pull`, `deploy --prebuilt`, `inspect --wait --timeout --format=json`, `env add --sensitive`, and `project remove`; older CLI versions are rejected during deploy preflight. - Docker CLI with buildx and a running Docker daemon for Aspire's built-in image build/push and VCR digest inspection. - Vercel authentication from an existing CLI login or the `VERCEL_TOKEN` environment variable. - Vercel project access that allows `vercel pull` to mint the `VERCEL_OIDC_TOKEN` used by VCR. @@ -163,17 +195,18 @@ This preview integration intentionally supports a narrow Vercel Dockerfile deplo | --- | --- | | Image-build compute resources | Supported through Aspire's built-in project/Dockerfile image build and push pipeline, Vercel Container Registry, digest resolution, and Build Output API prebuilt deploy. .NET projects, checked-in custom Dockerfile names, non-root Dockerfile paths, and generated Dockerfiles from language app integrations are supported. | | Container registries, image build, image push | Uses Vercel Container Registry and the Vercel OIDC token from `vercel pull` as the deployment target registry for Aspire's built-in push steps. Resource-level `WithContainerRegistry` is rejected for Vercel-targeted resources. | -| Non-secret environment variables | Passed with `vercel deploy --env KEY=value`; names must use letters, digits, and underscores and values are redacted from publish output. | +| Public routing and project/service cardinality | A single resource deploys as one project for compatibility. With multiple resources, external HTTP workloads are public Vercel project roots; referenced non-public workloads become private Vercel Services inside one owning project. Shared private services referenced by multiple public roots are rejected. | +| Non-secret environment variables | Written into the generated per-service Build Output function config; names must use letters, digits, and underscores and values are redacted from publish output. | | Secret parameters and connection strings | Configured as sensitive Vercel project environment variables with `vercel env add --force --sensitive`; values are supplied on stdin and redacted from publish output. Missing secret values are rejected. | | Docker build args/secrets | Supported when the underlying Aspire image build integration supports them; Aspire's built-in build step handles the local Docker build before Vercel deploy receives only Build Output API metadata. | -| Service discovery, endpoint references, `WithReference` to another resource | Supported for external HTTP(S) endpoints on workloads in the same Vercel production environment by using deterministic `https://.vercel.app` URLs. Rejected for preview/custom targets because those URLs are assigned after deployment. | -| Endpoints | External HTTP and HTTPS endpoints with HTTP transports are accepted when they map to one target port. Internal endpoints, non-HTTP(S) endpoints/transports, or multiple target ports are rejected. | +| Service discovery, endpoint references, `WithReference` to another resource | Same-project direct URL references become Vercel service bindings. Cross-project references are supported for external HTTP(S) endpoints on workloads in the same Vercel production environment by using deterministic `https://.vercel.app` URLs. Cross-project references are rejected for preview/custom targets because those URLs are assigned after deployment. | +| Endpoints | External HTTP and HTTPS endpoints with HTTP transports are accepted as public project roots when they map to one target port. Internal HTTP endpoints are accepted only for services owned by one public root or for single-resource compatibility. Non-HTTP(S) endpoints/transports or multiple target ports are rejected. | | Volumes, bind mounts, Aspire container files | Rejected. Include required files in the source tree or use a Vercel-supported external service for state. | -| Health checks, probes, replicas, wait/dependency ordering | Rejected until they are mapped to Vercel-native behavior. | +| Health checks, replicas, wait/dependency ordering | Ignored by deployment planning until they are mapped to Vercel-native behavior. Probes are rejected. | | Container entrypoint overrides and Aspire command-line args | Rejected. Configure runtime command behavior through the Dockerfile/workload publish output or Vercel project settings. | | Existing linked Vercel projects | Supported for deploy. Destroy preserves projects that were linked before deploy and only deletes Aspire-created projects recorded in state. | -Managed Vercel project names are inferred from the source directory name when no `.vercel/project.json` link exists and no explicit `WithVercelProjectName` is configured. The integration slugifies inferred names to Vercel's lowercase, hyphenated project-name form before deployment and rejects duplicate project names within the same Vercel environment, including linked projects and explicitly configured names. Each Aspire resource must map to one distinct Vercel project because production endpoint references use the project-level `https://.vercel.app` URL. This preview intentionally does not yet expose resource-level alias, domain, framework/output, build-setting, deployment-protection, or per-resource target APIs; link each resource to a distinct Vercel project before deploy when you need existing provider project identities. +Managed Vercel project names are inferred from the public root source directory name when no `.vercel/project.json` link exists and no explicit `WithVercelProjectName` is configured. The integration slugifies inferred names to Vercel's lowercase, hyphenated project-name form before deployment and rejects duplicate public project names within the same Vercel environment, including linked projects and explicitly configured names. Service names are inferred from Aspire resource names and must be unique within the Vercel environment. This preview intentionally does not yet expose resource-level alias, domain, framework/output, build-setting, deployment-protection, shared-private-service, or per-resource target APIs; link public roots to existing Vercel projects before deploy when you need existing provider project identities. If the source root already contains a `vercel.json`, the integration reads it only for validation; the source file is not modified. Top-level Vercel build, env, routing, function, and services settings are rejected because this preview owns the generated Build Output API container function, catch-all route, and AppHost-driven environment variables. @@ -184,6 +217,8 @@ If the source root already contains a `vercel.json`, the integration reads it on - [Vercel Container Registry](https://vercel.com/docs/container-registry) - [Vercel Build Output API](https://vercel.com/docs/build-output-api) - [Build Output API configuration](https://vercel.com/docs/build-output-api/configuration) +- [Vercel Services](https://vercel.com/docs/vercel-platform/services) +- [Vercel Service bindings](https://vercel.com/docs/vercel-platform/services/service-bindings) - [Vercel CLI deploy](https://vercel.com/docs/cli/deploy) - [Vercel CLI link](https://vercel.com/docs/cli/link) - [Vercel CLI pull](https://vercel.com/docs/cli/pull) diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelBuildOutputWriter.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelBuildOutputWriter.cs index 2fdf0a7d7..c14c8179b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelBuildOutputWriter.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelBuildOutputWriter.cs @@ -15,27 +15,87 @@ internal static class VercelBuildOutputWriter }; public static async Task WriteAsync( - VercelDeploymentEntry entry, + VercelDeploymentProjectGroup group, VercelPulledProject project, - string imageReference, + IReadOnlyList resolvedDeployments, CancellationToken cancellationToken) { // Vercel Build Output API v3 expects the file-system contract documented at // https://vercel.com/docs/build-output-api and // https://vercel.com/docs/build-output-api/configuration: // .vercel/project.json copied project identity from `vercel pull` - // .vercel/output/config.json routes and API version - // .vercel/output/functions/index.func/.vc-config.json + // .vercel/output/config.json routes, services, bindings, and API version + // .vercel/output/services/{service}/functions/index.func/.vc-config.json // { "runtime": "container", "handler": "@sha256:..." } // There is intentionally no user source copy here; Aspire's build/push pipeline has // already built the image, and Vercel deploy uploads only metadata that points at it. - string vercelDirectory = Path.Combine(entry.EffectiveDeployDirectory, VercelConstants.DirectoryName); + string vercelDirectory = Path.Combine(group.RootEntry.EffectiveDeployDirectory, VercelConstants.DirectoryName); string outputDirectory = Path.Combine(vercelDirectory, VercelConstants.OutputDirectoryName); - string functionDirectory = Path.Combine(outputDirectory, "functions", "index.func"); - Directory.CreateDirectory(functionDirectory); + Directory.CreateDirectory(outputDirectory); await File.WriteAllTextAsync(Path.Combine(vercelDirectory, VercelConstants.ProjectFileName), project.ProjectJsonContent, cancellationToken).ConfigureAwait(false); + var resolvedByResourceName = resolvedDeployments.ToDictionary(static resolved => resolved.PreparedDeployment.Entry.Resource.Name, StringComparer.Ordinal); + JsonObject experimentalServices = []; + JsonArray services = []; + + foreach (var service in group.Services) + { + var resolved = resolvedByResourceName[service.Entry.Resource.Name]; + string serviceOutputDirectory = Path.Combine(outputDirectory, "services", service.ServiceName); + string functionDirectory = Path.Combine(serviceOutputDirectory, "functions", "index.func"); + Directory.CreateDirectory(functionDirectory); + + var serviceConfig = new JsonObject + { + ["version"] = VercelConstants.BuildOutputApiVersion, + ["routes"] = new JsonArray + { + new JsonObject + { + ["handle"] = "filesystem" + }, + new JsonObject + { + ["src"] = "/(.*)", + ["dest"] = "/index" + } + }, + ["crons"] = new JsonArray() + }; + + var functionConfig = new JsonObject + { + ["handler"] = resolved.Image.Reference, + ["runtime"] = "container", + ["environment"] = CreateEnvironmentObject(resolved.PreparedDeployment.EnvironmentConfiguration.DeploymentEnvironmentVariables) + }; + + await File.WriteAllTextAsync(Path.Combine(serviceOutputDirectory, "config.json"), serviceConfig.ToJsonString(JsonOptions), cancellationToken).ConfigureAwait(false); + await File.WriteAllTextAsync(Path.Combine(functionDirectory, ".vc-config.json"), functionConfig.ToJsonString(JsonOptions), cancellationToken).ConfigureAwait(false); + + var serviceDefinition = new JsonObject + { + ["root"] = $"{service.ServiceName}/" + }; + var serviceManifestEntry = new JsonObject + { + ["schema"] = "experimentalServicesV2", + ["name"] = service.ServiceName, + ["root"] = service.ServiceName + }; + + JsonArray bindings = CreateBindingsArray(resolved.PreparedDeployment.EnvironmentConfiguration.ServiceBindings); + if (bindings.Count > 0) + { + serviceDefinition["bindings"] = bindings.DeepClone(); + serviceManifestEntry["bindings"] = bindings; + } + + experimentalServices[service.ServiceName] = serviceDefinition; + services.Add(serviceManifestEntry); + } + var outputConfig = new JsonObject { ["version"] = VercelConstants.BuildOutputApiVersion, @@ -47,20 +107,47 @@ public static async Task WriteAsync( }, new JsonObject { - ["src"] = "/(.*)", - ["dest"] = "/index" + ["src"] = "^(?:/(.*))$", + ["destination"] = new JsonObject + { + ["service"] = group.Root.ServiceName, + ["type"] = "service" + } } - } + }, + ["crons"] = new JsonArray(), + ["experimentalServicesV2"] = experimentalServices, + ["services"] = services }; - var functionConfig = new JsonObject + await File.WriteAllTextAsync(Path.Combine(outputDirectory, "config.json"), outputConfig.ToJsonString(JsonOptions), cancellationToken).ConfigureAwait(false); + } + + private static JsonObject CreateEnvironmentObject(IReadOnlyList> environmentVariables) + { + JsonObject environment = []; + foreach (var environmentVariable in environmentVariables) { - ["handler"] = imageReference, - ["runtime"] = "container", - ["environment"] = new JsonObject() - }; + environment[environmentVariable.Key] = environmentVariable.Value; + } - await File.WriteAllTextAsync(Path.Combine(outputDirectory, "config.json"), outputConfig.ToJsonString(JsonOptions), cancellationToken).ConfigureAwait(false); - await File.WriteAllTextAsync(Path.Combine(functionDirectory, ".vc-config.json"), functionConfig.ToJsonString(JsonOptions), cancellationToken).ConfigureAwait(false); + return environment; + } + + private static JsonArray CreateBindingsArray(IReadOnlyList serviceBindings) + { + JsonArray bindings = []; + foreach (var binding in serviceBindings) + { + bindings.Add(new JsonObject + { + ["type"] = "service", + ["service"] = binding.ServiceName, + ["format"] = "url", + ["env"] = binding.EnvironmentVariableName + }); + } + + return bindings; } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs index 14bb1e1e6..100ed79ad 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs @@ -263,6 +263,7 @@ internal static string[] BuildDeployPrebuiltArgumentsForPlan( internal static string BuildDisplayDeployCommand( VercelEnvironmentOptionsAnnotation options, string resourceName, + string serviceName, IReadOnlyList> environmentVariables) { // The plan should explain the command shape without leaking concrete source roots, @@ -271,7 +272,7 @@ internal static string BuildDisplayDeployCommand( .Select(static environmentVariable => new KeyValuePair(environmentVariable.Key, "")) .ToArray(); - string displayImage = $"{VercelConstants.VcrRegistry}///{VercelConstants.ContainerServiceName}:"; + string displayImage = $"{VercelConstants.VcrRegistry}///{serviceName}:"; string displayDeployDirectory = $"<{resourceName}-build-output>"; string displayProject = $"<{resourceName}-vercel-project>"; return $"vercel pull --cwd <{resourceName}-vercel-project-link> --yes --environment {VercelProjectEnvironment.GetName(options)} && aspire build/push {resourceName} -> {displayImage} && docker {string.Join(" ", BuildDockerInspectDigestArguments(displayImage))} && vercel {string.Join(" ", BuildDeployPrebuiltArguments(options, displayDeployDirectory, displayProject, displayEnvironmentVariables))}"; diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs index 0cc1ec24b..f28f9ec7c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs @@ -105,7 +105,6 @@ public static void ValidateEntries(IReadOnlyList entries) ValidateUnsupportedResourceModel(entry); } - ValidateUniqueProjectNames(entries); } public static async Task PrepareEntryAsync(PipelineStepContext context, VercelDeploymentEntry entry) @@ -191,32 +190,6 @@ private static bool IsTargetedToEnvironment(IResource resource, VercelEnvironmen || (computeEnvironment is null && allowImplicitTargeting); } - private static void ValidateUniqueProjectNames(IReadOnlyList entries) - { - // Production endpoint references use https://{projectName}.vercel.app. If two - // resources resolve to the same Vercel project, endpoint references and destroy - // ownership would both become ambiguous. - var projectNames = entries - .Select(entry => new - { - Entry = entry, - ProjectLink = VercelProjectNameResolver.GetProjectLink(entry) - }) - .GroupBy(item => item.ProjectLink.ProjectName, StringComparer.Ordinal) - .Where(group => group.Count() > 1) - .ToArray(); - - if (projectNames.Length == 0) - { - return; - } - - var collision = projectNames[0]; - string resources = string.Join(", ", collision.Select(static item => $"'{item.Entry.Resource.Name}'").Order(StringComparer.Ordinal)); - throw new DistributedApplicationException( - $"Multiple Vercel resources resolve to project name '{collision.Key}' ({resources}). Vercel project names must be unique per environment because each resource deploys to and references one project production URL. Use WithVercelProjectName, distinct source directory names, or link each resource to a distinct Vercel project with .vercel/project.json."); - } - private static void ValidateUnsupportedResourceModel(VercelDeploymentEntry entry) { IResource resource = entry.Resource; @@ -265,19 +238,6 @@ private static void ValidateEndpointModel(VercelDeploymentEntry entry) return; } - // Reject the tempting Compose/ACA shapes up front: private listeners, multiple - // target ports, and non-HTTP protocols do not have an equivalent in this preview's - // single public Vercel container ingress. - // Vercel's Dockerfile preview exposes one public platform ingress; it has no - // Aspire-modeled private service network for internal endpoints. - // See https://vercel.com/docs/functions/container-images. - var internalEndpoint = endpoints.FirstOrDefault(static endpoint => !endpoint.IsExternal); - if (internalEndpoint is not null) - { - throw new DistributedApplicationException( - $"Resource '{entry.Resource.Name}' configures endpoint '{internalEndpoint.Name}' as internal, but Vercel Dockerfile deployments expose public platform HTTPS ingress only. Mark the endpoint external or remove it before deploying to Vercel."); - } - var unsupportedEndpoint = endpoints.FirstOrDefault(static endpoint => !IsHttpEndpoint(endpoint)); if (unsupportedEndpoint is not null) { diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPlanWriter.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPlanWriter.cs index 9bd078146..28c06ca0c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPlanWriter.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPlanWriter.cs @@ -43,6 +43,9 @@ public static async Task WriteAsync( { var entries = VercelDeploymentModel.GetEntries(model, environment).ToList(); VercelDeploymentModel.ValidateEntries(entries); + var projectMap = executionContext is null || logger is null + ? VercelDeploymentProjectGrouper.CreateMap(entries, entries.ToDictionary(static entry => entry.Resource.Name, static _ => Array.Empty(), StringComparer.Ordinal)) + : await VercelDeploymentProjectGrouper.CreateMapAsync(executionContext, logger, entries, cancellationToken).ConfigureAwait(false); // Publish is a reviewable handoff, not a dry-run deploy. Keep it deterministic: // show commands, Dockerfile paths, and env var names without resolving secrets or @@ -57,6 +60,7 @@ await CreateEntriesAsync( logger, options, entries, + projectMap, cancellationToken).ConfigureAwait(false)); string planPath = Path.Combine(outputDirectory, VercelConstants.DeploymentPlanFileName); @@ -91,6 +95,7 @@ public static async Task BuildDeployArgumentsAsync( // Publish plans must be useful without Vercel credentials or secret resolution. // Secret-bearing values are reduced to names/placeholders here; deploy resolves them // only when they are sent to Vercel's project env store over stdin. + var projectMap = await VercelDeploymentProjectGrouper.CreateMapAsync(executionContext, logger, entries, cancellationToken).ConfigureAwait(false); var entriesByResourceName = VercelDeploymentModel.GetEntriesByResourceName(entries); var environmentConfiguration = await GetEnvironmentConfigurationAsync( executionContext, @@ -98,6 +103,7 @@ public static async Task BuildDeployArgumentsAsync( options, entry, entriesByResourceName, + projectMap, resolveProjectEnvironmentVariableValues: false, cancellationToken).ConfigureAwait(false); @@ -110,6 +116,7 @@ public static async Task GetEnvironmentConfigura VercelEnvironmentOptionsAnnotation options, VercelDeploymentEntry entry, IReadOnlyDictionary entriesByResourceName, + VercelDeploymentProjectMap? projectMap, bool resolveProjectEnvironmentVariableValues, CancellationToken cancellationToken) { @@ -132,6 +139,7 @@ public static async Task GetEnvironmentConfigura options, executionConfiguration, entriesByResourceName, + projectMap, resolveProjectEnvironmentVariableValues, cancellationToken).ConfigureAwait(false); @@ -143,6 +151,7 @@ private static async Task CreateEntriesAsync( ILogger? logger, VercelEnvironmentOptionsAnnotation options, IReadOnlyList entries, + VercelDeploymentProjectMap projectMap, CancellationToken cancellationToken) { List planEntries = []; @@ -155,12 +164,16 @@ private static async Task CreateEntriesAsync( // handed off more often than deploy logs. var environmentConfiguration = executionContext is null || logger is null ? VercelEnvironmentConfiguration.Empty - : await GetEnvironmentConfigurationAsync(executionContext, logger, options, entry, entriesByResourceName, resolveProjectEnvironmentVariableValues: false, cancellationToken).ConfigureAwait(false); + : await GetEnvironmentConfigurationAsync(executionContext, logger, options, entry, entriesByResourceName, projectMap, resolveProjectEnvironmentVariableValues: false, cancellationToken).ConfigureAwait(false); + + string serviceName = projectMap.TryGetService(entry.Resource.Name, out var service) + ? service.ServiceName + : VercelConstants.ContainerServiceName; planEntries.Add(new( entry.Resource.Name, VercelDeploymentModel.GetDisplayDockerfilePath(entry), - VercelCliRunner.BuildDisplayDeployCommand(options, entry.Resource.Name, environmentConfiguration.DeploymentEnvironmentVariables), + VercelCliRunner.BuildDisplayDeployCommand(options, entry.Resource.Name, serviceName, []), [.. environmentConfiguration.AllEnvironmentVariableNames.Order(StringComparer.Ordinal)])); } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentProjectGrouper.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentProjectGrouper.cs new file mode 100644 index 000000000..16a6e2502 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentProjectGrouper.cs @@ -0,0 +1,202 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Publishing; +using Microsoft.Extensions.Logging; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Converts the flat set of Vercel-targeted Aspire resources into Vercel project groups. +/// +internal static class VercelDeploymentProjectGrouper +{ + public static async Task CreateMapAsync( + DistributedApplicationExecutionContext executionContext, + ILogger logger, + IReadOnlyList entries, + CancellationToken cancellationToken) + { + Dictionary referencesByResourceName = new(StringComparer.Ordinal); + foreach (var entry in entries) + { + var executionConfiguration = await ExecutionConfigurationBuilder + .Create(entry.Resource) + .WithEnvironmentVariablesConfig() + .WithArgumentsConfig() + .BuildAsync(executionContext, logger, cancellationToken) + .ConfigureAwait(false); + + if (executionConfiguration.Exception is not null) + { + throw new DistributedApplicationException($"Failed to process deployment configuration for resource '{entry.Resource.Name}'.", executionConfiguration.Exception); + } + + referencesByResourceName[entry.Resource.Name] = [.. VercelEnvironmentMapper.GetReferencedResourceNames(entry.Resource, executionConfiguration) + .Where(name => entries.Any(candidate => string.Equals(candidate.Resource.Name, name, StringComparison.Ordinal))) + .Order(StringComparer.Ordinal)]; + } + + return CreateMap(entries, referencesByResourceName); + } + + public static VercelDeploymentProjectMap CreateMap( + IReadOnlyList entries, + IReadOnlyDictionary referencesByResourceName) + { + if (entries.Count == 1) + { + var single = CreateService(entries[0], isPublicRoot: true); + return new([new(single, [single])]); + } + + var entriesByName = entries.ToDictionary(static entry => entry.Resource.Name, StringComparer.Ordinal); + var publicRoots = entries + .Where(HasPublicHttpEndpoint) + .Select(entry => CreateService(entry, isPublicRoot: true)) + .ToArray(); + + if (publicRoots.Length == 0) + { + throw new DistributedApplicationException("Multiple resources target Vercel, but none exposes an external HTTP endpoint that can be used as a Vercel project root. Add an external HTTP endpoint to the public workload or deploy a single resource."); + } + + ValidateUniqueProjectNames(publicRoots); + ValidateUniqueServiceNames(entries); + + var publicRootNames = publicRoots + .Select(static root => root.Entry.Resource.Name) + .ToHashSet(StringComparer.Ordinal); + + Dictionary> servicesByRootName = publicRoots + .ToDictionary(static root => root.Entry.Resource.Name, static root => new List { root }, StringComparer.Ordinal); + + foreach (var entry in entries) + { + if (publicRootNames.Contains(entry.Resource.Name)) + { + continue; + } + + if (entry.Resource.TryGetLastAnnotation(out _)) + { + throw new DistributedApplicationException($"Resource '{entry.Resource.Name}' is an internal Vercel service because it is owned by a public project root. WithVercelProjectName can only be applied to public Vercel project roots."); + } + + var owners = publicRoots + .Where(root => ReferencesResourceTransitively(root.Entry.Resource.Name, entry.Resource.Name, referencesByResourceName, publicRootNames)) + .ToArray(); + + if (owners.Length == 0) + { + throw new DistributedApplicationException($"Resource '{entry.Resource.Name}' targets Vercel but is not public and is not referenced by any public Vercel project root. Add an external HTTP endpoint to make it a public project, reference it from one public workload, or deploy it separately."); + } + + if (owners.Length > 1) + { + string ownerNames = string.Join(", ", owners.Select(static owner => $"'{owner.Entry.Resource.Name}'").Order(StringComparer.Ordinal)); + throw new DistributedApplicationException($"Resource '{entry.Resource.Name}' is referenced by multiple public Vercel project roots ({ownerNames}). Shared internal Vercel services are ambiguous; deploy the service separately or reference it from exactly one public workload."); + } + + servicesByRootName[owners[0].Entry.Resource.Name].Add(CreateService(entry, isPublicRoot: false)); + } + + List groups = []; + foreach (var root in publicRoots) + { + groups.Add(new(root, [.. servicesByRootName[root.Entry.Resource.Name].OrderBy(static service => service.IsPublicRoot ? 0 : 1).ThenBy(static service => service.ServiceName, StringComparer.Ordinal)])); + } + + return new(groups); + } + + private static VercelDeploymentService CreateService(VercelDeploymentEntry entry, bool isPublicRoot) + => new(entry, VercelProjectNameResolver.GetServiceName(entry.Resource), isPublicRoot); + + private static bool HasPublicHttpEndpoint(VercelDeploymentEntry entry) + => entry.Resource.Annotations + .OfType() + .Any(static endpoint => endpoint.IsExternal && VercelDeploymentModel.IsHttpEndpoint(endpoint)); + + private static bool ReferencesResourceTransitively( + string sourceResourceName, + string targetResourceName, + IReadOnlyDictionary referencesByResourceName, + HashSet publicRootNames) + { + Queue queue = []; + HashSet visited = new(StringComparer.Ordinal); + queue.Enqueue(sourceResourceName); + + while (queue.Count > 0) + { + string current = queue.Dequeue(); + if (!visited.Add(current) + || !referencesByResourceName.TryGetValue(current, out string[]? references)) + { + continue; + } + + foreach (string reference in references) + { + if (string.Equals(reference, targetResourceName, StringComparison.Ordinal)) + { + return true; + } + + if (!publicRootNames.Contains(reference)) + { + queue.Enqueue(reference); + } + } + } + + return false; + } + + private static void ValidateUniqueProjectNames(IReadOnlyList publicRoots) + { + var projectNames = publicRoots + .Select(root => new + { + Root = root, + ProjectLink = VercelProjectNameResolver.GetProjectLink(root.Entry) + }) + .GroupBy(item => item.ProjectLink.ProjectName, StringComparer.Ordinal) + .Where(static group => group.Count() > 1) + .ToArray(); + + if (projectNames.Length == 0) + { + return; + } + + var collision = projectNames[0]; + string resources = string.Join(", ", collision.Select(static item => $"'{item.Root.Entry.Resource.Name}'").Order(StringComparer.Ordinal)); + throw new DistributedApplicationException($"Multiple public Vercel project roots resolve to project name '{collision.Key}' ({resources}). Use WithVercelProjectName, distinct source directory names, or link each root to a distinct Vercel project with .vercel/project.json."); + } + + private static void ValidateUniqueServiceNames(IReadOnlyList entries) + { + var serviceNames = entries + .Select(static entry => new + { + Entry = entry, + ServiceName = VercelProjectNameResolver.GetServiceName(entry.Resource) + }) + .GroupBy(static item => item.ServiceName, StringComparer.Ordinal) + .Where(static group => group.Count() > 1) + .ToArray(); + + if (serviceNames.Length == 0) + { + return; + } + + var collision = serviceNames[0]; + string resources = string.Join(", ", collision.Select(static item => $"'{item.Entry.Resource.Name}'").Order(StringComparer.Ordinal)); + throw new DistributedApplicationException($"Multiple Vercel services resolve to service name '{collision.Key}' ({resources}). Rename one of the Aspire resources so each Vercel service has a unique name."); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Types.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Types.cs index 3832ec101..b70a265b5 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Types.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Types.cs @@ -38,15 +38,22 @@ internal sealed record VercelDeploymentPlanEntry(string ResourceName, string Doc /// internal sealed record VercelEnvironmentConfiguration( IReadOnlyList> DeploymentEnvironmentVariables, - IReadOnlyList> ProjectEnvironmentVariables) + IReadOnlyList> ProjectEnvironmentVariables, + IReadOnlyList ServiceBindings) { - public static VercelEnvironmentConfiguration Empty { get; } = new([], []); + public static VercelEnvironmentConfiguration Empty { get; } = new([], [], []); public IEnumerable AllEnvironmentVariableNames => DeploymentEnvironmentVariables.Select(static variable => variable.Key) - .Concat(ProjectEnvironmentVariables.Select(static variable => variable.Key)); + .Concat(ProjectEnvironmentVariables.Select(static variable => variable.Key)) + .Concat(ServiceBindings.Select(static binding => binding.EnvironmentVariableName)); } +/// +/// Describes one Vercel service binding that injects a private target service URL into the caller. +/// +internal sealed record VercelServiceBinding(string EnvironmentVariableName, string ServiceName); + /// /// Typed result from vercel deploy; the URL is used for verification and the optional ID /// is persisted when the CLI provides it. @@ -92,6 +99,8 @@ internal sealed record VercelDeploymentStateEntry( public string? VcrImageDigest { get; init; } + public VercelServiceDeploymentStateEntry[] Services { get; init; } = []; + public int? BuildOutputApiVersion { get; init; } public string[] ProjectEnvironmentVariables { get; init; } = []; @@ -102,14 +111,76 @@ internal sealed record VercelDeploymentStateEntry( /// internal sealed record VercelImageReference(string Reference, string Digest); +/// +/// Captures one Aspire workload as a Vercel service within a project group. +/// +internal sealed record VercelDeploymentService( + VercelDeploymentEntry Entry, + string ServiceName, + bool IsPublicRoot); + +/// +/// A Vercel project deployment unit: one public/root service plus private services it owns. +/// +internal sealed record VercelDeploymentProjectGroup( + VercelDeploymentService Root, + VercelDeploymentService[] Services) +{ + public VercelDeploymentEntry RootEntry => Root.Entry; +} + +/// +/// Lookup structure used while translating Aspire references into Vercel project/service concepts. +/// +internal sealed class VercelDeploymentProjectMap(IReadOnlyList groups) +{ + private readonly Dictionary _groupsByResourceName = groups + .SelectMany(group => group.Services.Select(service => new { service.Entry.Resource.Name, Group = group })) + .ToDictionary(static item => item.Name, static item => item.Group, StringComparer.Ordinal); + + private readonly Dictionary _servicesByResourceName = groups + .SelectMany(static group => group.Services) + .ToDictionary(static service => service.Entry.Resource.Name, StringComparer.Ordinal); + + public IReadOnlyList Groups { get; } = groups; + + public bool TryGetService(string resourceName, out VercelDeploymentService service) + => _servicesByResourceName.TryGetValue(resourceName, out service!); + + public bool TryGetGroup(string resourceName, out VercelDeploymentProjectGroup group) + => _groupsByResourceName.TryGetValue(resourceName, out group!); + + public bool AreInSameProject(string sourceResourceName, string targetResourceName) + => _groupsByResourceName.TryGetValue(sourceResourceName, out var sourceGroup) + && _groupsByResourceName.TryGetValue(targetResourceName, out var targetGroup) + && ReferenceEquals(sourceGroup, targetGroup); +} + +/// +/// Pairs a prepared Vercel service with the immutable image digest resolved after Aspire pushes it. +/// +internal sealed record VercelResolvedDeployment( + VercelPreparedDeploymentAnnotation PreparedDeployment, + VercelImageReference Image); + +/// +/// Persisted digest/repository details for one service inside a Vercel project deployment. +/// +internal sealed record VercelServiceDeploymentStateEntry( + string ResourceName, + string ServiceName, + string? VcrImageDigest); + /// /// Resource annotation produced during Vercel prerequisite work and consumed by Aspire's image /// push decorator and deploy step to keep project, token, and image-tag context together. /// internal sealed record VercelPreparedDeploymentAnnotation( VercelDeploymentEntry Entry, + string ServiceName, VercelProjectLink ProjectLink, VercelPulledProjectContext ProjectContext, + VercelEnvironmentConfiguration EnvironmentConfiguration, bool ManagedByAspire, string RemoteImageName, string RemoteImageTag, @@ -143,7 +214,6 @@ internal sealed record VercelPulledProject( /// project settings, and decoded claims for VCR operations. /// internal sealed record VercelPulledProjectContext( - VercelEnvironmentConfiguration EnvironmentConfiguration, VercelPulledProject PulledProject, VercelOidcClaims OidcClaims); diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs index 3073369f0..46d7f2148 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs @@ -16,6 +16,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Net.Sockets; +using System.Runtime.ExceptionServices; using System.Text; using System.Text.RegularExpressions; @@ -69,6 +70,8 @@ public static async Task ValidatePrerequisitesAsync(PipelineStepContext context, await VercelDeploymentModel.ValidateVercelJsonAsync(entry.Resource, entry.SourceRoot, context.CancellationToken).ConfigureAwait(false); } + var projectMap = await VercelDeploymentProjectGrouper.CreateMapAsync(context.ExecutionContext, context.Logger, entries, context.CancellationToken).ConfigureAwait(false); + await ValidateCliPrerequisitesAsync(context, environment).ConfigureAwait(false); await ValidateDockerDigestInspectionPrerequisitesAsync(context).ConfigureAwait(false); var runner = context.Services.GetRequiredService(); @@ -76,16 +79,17 @@ public static async Task ValidatePrerequisitesAsync(PipelineStepContext context, await VercelDeploymentStateStore.ValidateExistingAsync(context, environment, options).ConfigureAwait(false); var entriesByResourceName = VercelDeploymentModel.GetEntriesByResourceName(entries); - foreach (var entry in entries) + foreach (var group in projectMap.Groups) { - await PrepareResourceForBuiltInImagePushAsync( + await PrepareProjectGroupForBuiltInImagePushAsync( context, environment, options, runner, registryClient, entriesByResourceName, - entry).ConfigureAwait(false); + projectMap, + group).ConfigureAwait(false); } } @@ -106,6 +110,7 @@ public static async Task DeployAsync(PipelineStepContext context, VercelEnvironm var entries = VercelDeploymentModel.GetEntries(context.Model, environment).ToList(); VercelDeploymentModel.ValidateEntries(entries); + var projectMap = await VercelDeploymentProjectGrouper.CreateMapAsync(context.ExecutionContext, context.Logger, entries, context.CancellationToken).ConfigureAwait(false); // Normal pipeline execution runs the prereq step first. Keep this fallback so the // deploy delegate remains directly unit-testable and safe if invoked by a custom runner. if (entries.Any(entry => GetPreparedDeployment(entry.Resource) is null)) @@ -113,60 +118,106 @@ public static async Task DeployAsync(PipelineStepContext context, VercelEnvironm await ValidatePrerequisitesAsync(context, environment).ConfigureAwait(false); } - foreach (var entry in entries) + List<( + VercelDeploymentProjectGroup Group, + VercelPreparedDeploymentAnnotation RootDeployment, + IReadOnlyList ResolvedDeployments)> preparedGroups = []; + + foreach (var group in projectMap.Groups) { - await DeployEntryAsync( - context, - environment, - options, - runner, - entry).ConfigureAwait(false); - } - } + List resolvedDeployments = []; + foreach (var service in group.Services) + { + var preparedDeployment = GetPreparedDeployment(service.Entry.Resource); + if (preparedDeployment is null) + { + throw new DistributedApplicationException($"Resource '{service.Entry.Resource.Name}' was not prepared for Vercel deployment. Run the '{DeployPrereqStepNamePrefix}{environment.Name}' pipeline step before deploying."); + } + // Docker auth for VCR is registry-host scoped, so keep the re-login/digest inspect + // phase sequential before producing the single Vercel project deployment. + var image = await ResolvePushedImageDigestAsync(context, runner, preparedDeployment).ConfigureAwait(false); + resolvedDeployments.Add(new(preparedDeployment, image)); + } - private static async Task DeployEntryAsync( - PipelineStepContext context, - VercelEnvironmentResource environment, - VercelEnvironmentOptionsAnnotation options, - IVercelCliRunner runner, - VercelDeploymentEntry entry) - { - var preparedDeployment = GetPreparedDeployment(entry.Resource); - if (preparedDeployment is null) + var rootDeployment = resolvedDeployments.Single(resolved => resolved.PreparedDeployment.Entry.Resource.Name == group.RootEntry.Resource.Name).PreparedDeployment; + VercelDeploymentService[] preparedServices = [.. group.Services + .Select(service => + { + var preparedDeployment = resolvedDeployments.Single(resolved => resolved.PreparedDeployment.Entry.Resource.Name == service.Entry.Resource.Name).PreparedDeployment; + return service with + { + Entry = preparedDeployment.Entry, + ServiceName = preparedDeployment.ServiceName + }; + })]; + var preparedGroup = group with + { + Root = preparedServices.Single(service => string.Equals(service.Entry.Resource.Name, group.RootEntry.Resource.Name, StringComparison.Ordinal)), + Services = preparedServices + }; + + preparedGroups.Add((preparedGroup, rootDeployment, resolvedDeployments)); + } + + var deploymentOutcomes = await Task.WhenAll(preparedGroups.Select((preparedGroup, index) => DeployProjectGroupAsync(preparedGroup, index))).ConfigureAwait(false); + foreach (var outcome in deploymentOutcomes.OrderBy(static outcome => outcome.Index)) { - throw new DistributedApplicationException($"Resource '{entry.Resource.Name}' was not prepared for Vercel deployment. Run the '{DeployPrereqStepNamePrefix}{environment.Name}' pipeline step before deploying."); + if (outcome.Exception is not null || outcome.DeploymentResult is null) + { + continue; + } + + string? productionUrl = VercelDeploymentStateStore.GetProductionUrl(options, outcome.RootDeployment.ProjectLink.ProjectName); + var stateEntry = CreateSuccessfulDeploymentStateEntry( + outcome.Group, + outcome.RootDeployment.ProjectLink, + outcome.RootDeployment.ProjectContext, + outcome.DeploymentResult, + outcome.ResolvedDeployments, + outcome.RootDeployment.ManagedByAspire, + productionUrl); + + // Persist each verified project group independently. Another group can fail after + // Vercel has already created earlier projects, and destroy must still know which + // managed projects are safe to remove or retry. + await VercelDeploymentStateStore.SaveEntryAsync(context, environment, options, stateEntry).ConfigureAwait(false); + + AddDeploymentSummary(context, outcome.Group.RootEntry.Resource.Name, outcome.DeploymentResult, productionUrl); } - // At this point Aspire's built-in build/push steps have pushed the VCR tag selected - // in prereq. Vercel requires the immutable linux/amd64 manifest digest in the Build - // Output API handler, so deploy resolves the tag after push instead of trusting a tag. - var image = await ResolvePushedImageDigestAsync(context, runner, preparedDeployment).ConfigureAwait(false); + var failure = deploymentOutcomes.FirstOrDefault(static outcome => outcome.Exception is not null); + if (failure.Exception is not null) + { + ExceptionDispatchInfo.Capture(failure.Exception).Throw(); + } - var deploymentResult = await DeployPrebuiltOutputAsync( - context, - runner, - options, - preparedDeployment.Entry, - preparedDeployment.ProjectContext, - image).ConfigureAwait(false); - - string? productionUrl = VercelDeploymentStateStore.GetProductionUrl(options, preparedDeployment.ProjectLink.ProjectName); - var stateEntry = CreateSuccessfulDeploymentStateEntry( - preparedDeployment.Entry, - preparedDeployment.ProjectLink, - preparedDeployment.ProjectContext, - deploymentResult, - image, - preparedDeployment.ManagedByAspire, - productionUrl); - - // Persist each verified resource independently. A later resource can fail after - // Vercel has already created earlier projects, and destroy must still know which - // managed projects are safe to remove or retry. - await VercelDeploymentStateStore.SaveEntryAsync(context, environment, options, stateEntry).ConfigureAwait(false); - - AddDeploymentSummary(context, entry.Resource.Name, deploymentResult, productionUrl); + async Task<( + int Index, + VercelDeploymentProjectGroup Group, + VercelPreparedDeploymentAnnotation RootDeployment, + IReadOnlyList ResolvedDeployments, + VercelDeploymentResult? DeploymentResult, + Exception? Exception)> DeployProjectGroupAsync( + (VercelDeploymentProjectGroup Group, VercelPreparedDeploymentAnnotation RootDeployment, IReadOnlyList ResolvedDeployments) preparedGroup, + int index) + { + try + { + var deploymentResult = await DeployPrebuiltOutputAsync( + context, + runner, + options, + preparedGroup.Group, + preparedGroup.RootDeployment, + preparedGroup.ResolvedDeployments).ConfigureAwait(false); + return (index, preparedGroup.Group, preparedGroup.RootDeployment, preparedGroup.ResolvedDeployments, deploymentResult, Exception: null); + } + catch (Exception ex) + { + return (index, preparedGroup.Group, preparedGroup.RootDeployment, preparedGroup.ResolvedDeployments, DeploymentResult: null, ex); + } + } } private static async Task EnsureManagedProjectAndSaveInitialStateAsync( @@ -201,6 +252,31 @@ private static async Task EnsureManagedProjectAndSaveInitialStateAsync( }).ConfigureAwait(false); } + private static void ValidateProjectEnvironmentVariableCollisions( + VercelDeploymentProjectGroup group, + IReadOnlyDictionary environmentConfigurations) + { + var collisions = group.Services + .SelectMany(service => environmentConfigurations[service.Entry.Resource.Name].ProjectEnvironmentVariables + .Select(variable => new + { + variable.Key, + ResourceName = service.Entry.Resource.Name + })) + .GroupBy(static item => item.Key, StringComparer.Ordinal) + .Where(static grouping => grouping.Select(static item => item.ResourceName).Distinct(StringComparer.Ordinal).Count() > 1) + .ToArray(); + + if (collisions.Length == 0) + { + return; + } + + var collision = collisions[0]; + string resources = string.Join(", ", collision.Select(static item => $"'{item.ResourceName}'").Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal)); + throw new DistributedApplicationException($"Multiple services in Vercel project root '{group.RootEntry.Resource.Name}' configure secret project environment variable '{collision.Key}' ({resources}). Vercel project environment variables are project-scoped; use distinct environment variable names or move the value to a non-secret per-service setting."); + } + public static void ConfigurePipeline(PipelineConfigurationContext context, VercelEnvironmentResource environment) { // Pipeline ordering is the main integration point with Aspire's built-in container @@ -277,26 +353,42 @@ public static void NormalizePipelineDependencies(PipelineConfigurationContext co } } - private static async Task PrepareResourceForBuiltInImagePushAsync( + private static async Task PrepareProjectGroupForBuiltInImagePushAsync( PipelineStepContext context, VercelEnvironmentResource environment, VercelEnvironmentOptionsAnnotation options, IVercelCliRunner runner, IVercelContainerRegistryClient registryClient, IReadOnlyDictionary entriesByResourceName, - VercelDeploymentEntry entry) + VercelDeploymentProjectMap projectMap, + VercelDeploymentProjectGroup group) { - var preparedEntry = await VercelDeploymentModel.PrepareEntryAsync(context, entry).ConfigureAwait(false); - var projectLink = VercelProjectNameResolver.GetProjectLink(preparedEntry); + List preparedServices = []; + foreach (var service in group.Services) + { + preparedServices.Add(service with + { + Entry = await VercelDeploymentModel.PrepareEntryAsync(context, service.Entry).ConfigureAwait(false) + }); + } + + group = group with + { + Root = preparedServices.Single(service => service.IsPublicRoot), + Services = [.. preparedServices] + }; + + var preparedRoot = group.Root.Entry; + var projectLink = VercelProjectNameResolver.GetProjectLink(preparedRoot); var previousDeployment = await VercelDeploymentStateStore.GetPreviousAsync( context, environment, options, - preparedEntry.Resource.Name, + preparedRoot.Resource.Name, projectLink.ProjectName).ConfigureAwait(false); // A checked-in .vercel/project.json means the user linked an existing provider project, // so deploy can target it but destroy must not claim ownership of it. - bool managedByAspire = !VercelProjectNameResolver.HasProjectLinkFile(entry.SourceRoot); + bool managedByAspire = !VercelProjectNameResolver.HasProjectLinkFile(preparedRoot.SourceRoot); // Managed projects are recorded before the image build begins. If Docker build, // project-env configuration, or VCR push fails later, destroy/retry still has enough @@ -308,47 +400,70 @@ await EnsureManagedProjectAndSaveInitialStateAsync( environment, options, runner, - preparedEntry, - entry.SourceRoot, + preparedRoot, + preparedRoot.SourceRoot, projectLink, previousDeployment).ConfigureAwait(false); } - var projectContext = await PreparePulledProjectContextAsync( + var (projectContext, environmentConfigurations) = await PreparePulledProjectContextAsync( context, runner, options, - preparedEntry, + group, entriesByResourceName, + projectMap, previousDeployment).ConfigureAwait(false); await LoginToVcrAsync(context, runner, projectContext.PulledProject.OidcToken, projectContext.OidcClaims).ConfigureAwait(false); - await registryClient.EnsureRepositoryAsync(projectContext.PulledProject.OidcToken, projectContext.OidcClaims, VercelContainerServiceName, context.CancellationToken).ConfigureAwait(false); + foreach (var service in group.Services) + { + await registryClient.EnsureRepositoryAsync(projectContext.PulledProject.OidcToken, projectContext.OidcClaims, service.ServiceName, context.CancellationToken).ConfigureAwait(false); - AddVcrDeploymentAnnotations(environment, preparedEntry, projectLink, projectContext, managedByAspire); + AddVcrDeploymentAnnotations( + environment, + service, + projectLink, + projectContext, + environmentConfigurations[service.Entry.Resource.Name], + managedByAspire); + } } - internal static async Task PreparePulledProjectContextAsync( + internal static async Task<(VercelPulledProjectContext ProjectContext, IReadOnlyDictionary EnvironmentConfigurations)> PreparePulledProjectContextAsync( PipelineStepContext context, IVercelCliRunner runner, VercelEnvironmentOptionsAnnotation options, - VercelDeploymentEntry preparedEntry, + VercelDeploymentProjectGroup group, IReadOnlyDictionary entriesByResourceName, + VercelDeploymentProjectMap projectMap, PreviousVercelDeployment? previousDeployment = null) { - string projectLinkDirectory = await PrepareProjectEnvironmentDirectoryAsync(context, runner, options, preparedEntry).ConfigureAwait(false); + string projectLinkDirectory = await PrepareProjectEnvironmentDirectoryAsync(context, runner, options, group.RootEntry).ConfigureAwait(false); try { - // Use Aspire's unprocessed values so endpoint references and secrets keep their - // graph meaning until Vercel-specific deployment translation happens. - var environmentConfiguration = await VercelDeploymentPlanWriter.GetEnvironmentConfigurationAsync( - context.ExecutionContext, - context.Logger, - options, - preparedEntry, - entriesByResourceName, - resolveProjectEnvironmentVariableValues: true, - context.CancellationToken).ConfigureAwait(false); + Dictionary environmentConfigurations = new(StringComparer.Ordinal); + foreach (var service in group.Services) + { + // Use Aspire's unprocessed values so endpoint references and secrets keep their + // graph meaning until Vercel-specific deployment translation happens. + environmentConfigurations[service.Entry.Resource.Name] = await VercelDeploymentPlanWriter.GetEnvironmentConfigurationAsync( + context.ExecutionContext, + context.Logger, + options, + service.Entry, + entriesByResourceName, + projectMap, + resolveProjectEnvironmentVariableValues: true, + context.CancellationToken).ConfigureAwait(false); + } + + ValidateProjectEnvironmentVariableCollisions(group, environmentConfigurations); + var projectEnvironmentVariables = environmentConfigurations + .SelectMany(static item => item.Value.ProjectEnvironmentVariables) + .GroupBy(static variable => variable.Key, StringComparer.Ordinal) + .Select(static group => group.First()) + .ToArray(); // Vercel resolves project env vars during the provider-side build/runtime. // Configure secret-bearing values before deploy; non-secret per-deployment @@ -357,19 +472,19 @@ await ConfigureProjectEnvironmentVariablesAsync( context, runner, options, - preparedEntry, + group.RootEntry, projectLinkDirectory, - environmentConfiguration.ProjectEnvironmentVariables, + projectEnvironmentVariables, previousDeployment).ConfigureAwait(false); // The VCR image is not enough for deployment. Vercel deploy consumes Build // Output API metadata tied to a project, and VCR auth is minted by `vercel pull` // for that linked project. Keep that link/pull in scratch space so the source // root never receives provider metadata or pulled env files. - var pulledProject = await PullProjectSettingsAsync(context, runner, options, preparedEntry, projectLinkDirectory).ConfigureAwait(false); + var pulledProject = await PullProjectSettingsAsync(context, runner, options, group.RootEntry, projectLinkDirectory).ConfigureAwait(false); var oidcClaims = VercelOidcToken.DecodeUnvalidatedClaims(pulledProject.OidcToken); - return new(environmentConfiguration, pulledProject, oidcClaims); + return (new(pulledProject, oidcClaims), environmentConfigurations); } finally { @@ -387,39 +502,41 @@ private static async Task DeployPrebuiltOutputAsync( PipelineStepContext context, IVercelCliRunner runner, VercelEnvironmentOptionsAnnotation options, - VercelDeploymentEntry entry, - VercelPulledProjectContext projectContext, - VercelImageReference image) + VercelDeploymentProjectGroup group, + VercelPreparedDeploymentAnnotation rootDeployment, + IReadOnlyList resolvedDeployments) { // The generated Build Output API tree is the provider-owned deploy artifact. // It points at the immutable VCR digest and is written to temp/output storage so // `vercel deploy --prebuilt` uploads metadata only, not a staged source tree. - await VercelBuildOutputWriter.WriteAsync(entry, projectContext.PulledProject, image.Reference, context.CancellationToken).ConfigureAwait(false); + await VercelBuildOutputWriter.WriteAsync(group, rootDeployment.ProjectContext.PulledProject, resolvedDeployments, context.CancellationToken).ConfigureAwait(false); var result = await runner.DeployPrebuiltAsync( options, - entry.DeployDirectory, - VercelProjectNameResolver.GetProjectOption(entry), - projectContext.EnvironmentConfiguration.DeploymentEnvironmentVariables, + group.RootEntry.DeployDirectory, + rootDeployment.ProjectLink.ProjectId ?? rootDeployment.ProjectLink.ProjectName, + [], context.CancellationToken).ConfigureAwait(false); if (!result.Succeeded) { - throw CreateCliException($"deploy prebuilt resource '{entry.Resource.Name}' to Vercel", VercelCliFileName, result); + throw CreateCliException($"deploy prebuilt project root '{group.RootEntry.Resource.Name}' to Vercel", VercelCliFileName, result); } var deploymentResult = VercelCliOutputParser.GetDeploymentResult(result.StandardOutput); - await VerifyDeploymentAsync(context, runner, options, entry.Resource.Name, deploymentResult).ConfigureAwait(false); + await VerifyDeploymentAsync(context, runner, options, group.RootEntry.Resource.Name, deploymentResult).ConfigureAwait(false); return deploymentResult; } private static void AddVcrDeploymentAnnotations( VercelEnvironmentResource environment, - VercelDeploymentEntry entry, + VercelDeploymentService service, VercelProjectLink projectLink, VercelPulledProjectContext projectContext, + VercelEnvironmentConfiguration environmentConfiguration, bool managedByAspire) { + var entry = service.Entry; EnsureVercelImagePushOptionsCallback(entry.Resource); var claims = projectContext.OidcClaims; @@ -436,7 +553,7 @@ private static void AddVcrDeploymentAnnotations( string repository = $"{claims.Owner}/{claims.Project}"; string tag = $"aspire-{Guid.NewGuid():N}"; - string taggedImageReference = $"{VcrRegistry}/{repository}/{VercelContainerServiceName}:{tag}"; + string taggedImageReference = $"{VcrRegistry}/{repository}/{service.ServiceName}:{tag}"; var registry = new ContainerRegistryResource( $"{environment.Name}-{entry.Resource.Name}-vcr", ReferenceExpression.Create($"{VcrRegistry}"), @@ -450,10 +567,12 @@ private static void AddVcrDeploymentAnnotations( entry.Resource.Annotations.Add(new VercelPreparedDeploymentAnnotation( entry, + service.ServiceName, projectLink, projectContext, + environmentConfiguration, managedByAspire, - VercelContainerServiceName, + service.ServiceName, tag, taggedImageReference)); } @@ -514,29 +633,37 @@ await LoginToVcrAsync( } private static VercelDeploymentStateEntry CreateSuccessfulDeploymentStateEntry( - VercelDeploymentEntry entry, + VercelDeploymentProjectGroup group, VercelProjectLink projectLink, VercelPulledProjectContext projectContext, VercelDeploymentResult deploymentResult, - VercelImageReference image, + IReadOnlyList resolvedDeployments, bool managedByAspire, string? productionUrl) // State stores ownership, provider IDs/URLs, image digest, and env var names only. // Never persist secret values or temp project-link files from `vercel pull`. => new( - entry.Resource.Name, + group.RootEntry.Resource.Name, projectLink.ProjectName, projectLink.ProjectId ?? projectContext.PulledProject.ProjectId, deploymentResult.DeploymentId, deploymentResult.DeploymentUrl, - entry.SourceRoot, + group.RootEntry.SourceRoot, managedByAspire) { ProductionUrl = productionUrl, - VcrImageDigest = image.Digest, + VcrImageDigest = resolvedDeployments.Single(resolved => resolved.PreparedDeployment.Entry.Resource.Name == group.RootEntry.Resource.Name).Image.Digest, + Services = [.. resolvedDeployments + .Select(static resolved => new VercelServiceDeploymentStateEntry( + resolved.PreparedDeployment.Entry.Resource.Name, + resolved.PreparedDeployment.ServiceName, + resolved.Image.Digest)) + .OrderBy(static service => service.ServiceName, StringComparer.Ordinal)], BuildOutputApiVersion = VercelConstants.BuildOutputApiVersion, - ProjectEnvironmentVariables = [.. projectContext.EnvironmentConfiguration.ProjectEnvironmentVariables + ProjectEnvironmentVariables = [.. resolvedDeployments + .SelectMany(static resolved => resolved.PreparedDeployment.EnvironmentConfiguration.ProjectEnvironmentVariables) .Select(static variable => variable.Key) + .Distinct(StringComparer.Ordinal) .Order(StringComparer.Ordinal)] }; diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentMapper.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentMapper.cs index 1afe3a75c..842fe53f1 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentMapper.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentMapper.cs @@ -19,11 +19,13 @@ public static async Task GetConfigurationAsync( VercelEnvironmentOptionsAnnotation options, IExecutionConfigurationResult executionConfiguration, IReadOnlyDictionary entriesByResourceName, + VercelDeploymentProjectMap? projectMap, bool resolveProjectEnvironmentVariableValues, CancellationToken cancellationToken) { List> deploymentEnvironmentVariables = []; List> projectEnvironmentVariables = []; + List serviceBindings = []; HashSet names = new(StringComparer.Ordinal); // This is the env-var edge-case boundary. Vercel has deployment env args and @@ -46,16 +48,22 @@ public static async Task GetConfigurationAsync( $"Resource '{resource.Name}' configures environment variable '{name}' more than once. Vercel project environment variable names must be unique."); } + if (TryCreateServiceBinding(resource, name, projectMap, unprocessedValue, out var serviceBinding)) + { + serviceBindings.Add(serviceBinding); + continue; + } + if (ContainsUnsupportedResourceReference(resource, unprocessedValue)) { throw new DistributedApplicationException( $"Environment variable '{name}' for resource '{resource.Name}' references another Aspire resource or service in a way that cannot be represented as a Vercel deployment URL. Use endpoint references to Vercel production workloads, or configure the value in Vercel project environment variables."); } - // Non-secrets can ride on `vercel deploy --env`; secret-bearing values must use - // Vercel project environment variables so values never appear in CLI arguments. - // See https://vercel.com/docs/cli/deploy and - // https://vercel.com/docs/environment-variables/sensitive-environment-variables. + // Non-secrets can be written into the generated per-service Build Output config; + // secret-bearing values must use Vercel project environment variables so values + // never appear in generated artifacts or CLI arguments. + // See https://vercel.com/docs/environment-variables/sensitive-environment-variables. bool containsSecret = ContainsSecretReference(unprocessedValue); if (containsSecret) { @@ -64,12 +72,13 @@ public static async Task GetConfigurationAsync( resource, options, entriesByResourceName, + projectMap, unprocessedValue, value, cancellationToken).ConfigureAwait(false) : ""; } - else if (TryGetEnvironmentVariableValue(resource, options, entriesByResourceName, unprocessedValue, out string? vercelValue)) + else if (TryGetEnvironmentVariableValue(resource, options, entriesByResourceName, projectMap, unprocessedValue, out string? vercelValue)) { value = vercelValue; } @@ -84,7 +93,28 @@ public static async Task GetConfigurationAsync( } } - return new(deploymentEnvironmentVariables, projectEnvironmentVariables); + return new(deploymentEnvironmentVariables, projectEnvironmentVariables, serviceBindings); + } + + public static IEnumerable GetReferencedResourceNames( + IResource resource, + IExecutionConfigurationResult executionConfiguration) + { + foreach (var environmentVariable in executionConfiguration.EnvironmentVariablesWithUnprocessed) + { + foreach (string resourceName in GetReferencedResourceNames(resource, environmentVariable.Value.Item1)) + { + yield return resourceName; + } + } + + foreach (var argument in executionConfiguration.ArgumentsWithUnprocessed) + { + foreach (string resourceName in GetReferencedResourceNames(resource, argument.Item1)) + { + yield return resourceName; + } + } } public static void ValidateUnsupportedRuntimeConfiguration( @@ -113,6 +143,7 @@ private static async ValueTask GetProjectEnvironmentVariableValueAsync( IResource resource, VercelEnvironmentOptionsAnnotation options, IReadOnlyDictionary entriesByResourceName, + VercelDeploymentProjectMap? projectMap, object? value, string processedValue, CancellationToken cancellationToken) @@ -136,7 +167,7 @@ private static async ValueTask GetProjectEnvironmentVariableValueAsync( case IResourceBuilder connectionStringBuilder: return await GetValueProviderValueAsync(connectionStringBuilder.Resource.ConnectionStringExpression, $"connection string for resource '{connectionStringBuilder.Resource.Name}'", cancellationToken).ConfigureAwait(false); case ReferenceExpression referenceExpression: - return await GetProjectReferenceExpressionValueAsync(resource, options, entriesByResourceName, referenceExpression, cancellationToken).ConfigureAwait(false); + return await GetProjectReferenceExpressionValueAsync(resource, options, entriesByResourceName, projectMap, referenceExpression, cancellationToken).ConfigureAwait(false); case IValueProvider valueProvider: return await GetValueProviderValueAsync(valueProvider, "environment variable value", cancellationToken).ConfigureAwait(false); default: @@ -148,6 +179,7 @@ private static async ValueTask GetProjectReferenceExpressionValueAsync( IResource resource, VercelEnvironmentOptionsAnnotation options, IReadOnlyDictionary entriesByResourceName, + VercelDeploymentProjectMap? projectMap, ReferenceExpression referenceExpression, CancellationToken cancellationToken) { @@ -164,8 +196,8 @@ private static async ValueTask GetProjectReferenceExpressionValueAsync( { // Secret-bearing project env vars may combine endpoint URLs with secret // parameters because the final value is sent through Vercel's secret path. - EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference) => GetEndpointPropertyValue(resource, options, entriesByResourceName, endpointReference.Property(EndpointProperty.Url)), - EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint) => GetEndpointPropertyValue(resource, options, entriesByResourceName, endpointReferenceExpression), + EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference) => GetEndpointPropertyValue(resource, options, entriesByResourceName, projectMap, endpointReference.Property(EndpointProperty.Url)), + EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint) => GetEndpointPropertyValue(resource, options, entriesByResourceName, projectMap, endpointReferenceExpression), ParameterResource parameter => await GetParameterValueAsync(parameter, cancellationToken).ConfigureAwait(false), IResourceWithConnectionString connectionStringResource => await GetValueProviderValueAsync(connectionStringResource.ConnectionStringExpression, $"connection string for resource '{connectionStringResource.Name}'", cancellationToken).ConfigureAwait(false), _ => await GetValueProviderValueAsync(valueProvider, "reference expression value", cancellationToken).ConfigureAwait(false) @@ -210,6 +242,7 @@ private static bool TryGetEnvironmentVariableValue( IResource resource, VercelEnvironmentOptionsAnnotation options, IReadOnlyDictionary entriesByResourceName, + VercelDeploymentProjectMap? projectMap, object? value, [NotNullWhen(true)] out string? vercelValue) { @@ -218,13 +251,13 @@ private static bool TryGetEnvironmentVariableValue( switch (value) { case EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference): - vercelValue = GetEndpointPropertyValue(resource, options, entriesByResourceName, endpointReference.Property(EndpointProperty.Url)); + vercelValue = GetEndpointPropertyValue(resource, options, entriesByResourceName, projectMap, endpointReference.Property(EndpointProperty.Url)); return true; case EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint): - vercelValue = GetEndpointPropertyValue(resource, options, entriesByResourceName, endpointReferenceExpression); + vercelValue = GetEndpointPropertyValue(resource, options, entriesByResourceName, projectMap, endpointReferenceExpression); return true; case ReferenceExpression referenceExpression when ContainsCrossResourceEndpointReference(resource, referenceExpression): - vercelValue = GetReferenceExpressionValue(resource, options, entriesByResourceName, referenceExpression); + vercelValue = GetReferenceExpressionValue(resource, options, entriesByResourceName, projectMap, referenceExpression); return true; default: vercelValue = null; @@ -236,6 +269,7 @@ private static string GetReferenceExpressionValue( IResource resource, VercelEnvironmentOptionsAnnotation options, IReadOnlyDictionary entriesByResourceName, + VercelDeploymentProjectMap? projectMap, ReferenceExpression referenceExpression) { if (referenceExpression.IsConditional) @@ -249,8 +283,8 @@ private static string GetReferenceExpressionValue( IValueProvider valueProvider = referenceExpression.ValueProviders[i]; arguments[i] = valueProvider switch { - EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference) => GetEndpointPropertyValue(resource, options, entriesByResourceName, endpointReference.Property(EndpointProperty.Url)), - EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint) => GetEndpointPropertyValue(resource, options, entriesByResourceName, endpointReferenceExpression), + EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference) => GetEndpointPropertyValue(resource, options, entriesByResourceName, projectMap, endpointReference.Property(EndpointProperty.Url)), + EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint) => GetEndpointPropertyValue(resource, options, entriesByResourceName, projectMap, endpointReferenceExpression), // Mixed expressions can hide provider-specific ordering or secret semantics. // Keep this path to deterministic endpoint-only production URLs. _ => throw new DistributedApplicationException("Vercel endpoint reference expressions cannot be combined with parameters, secrets, or other value providers. Configure a concrete Vercel project environment variable instead.") @@ -269,6 +303,7 @@ private static string GetEndpointPropertyValue( IResource resource, VercelEnvironmentOptionsAnnotation options, IReadOnlyDictionary entriesByResourceName, + VercelDeploymentProjectMap? projectMap, EndpointReferenceExpression endpointReferenceExpression) { // This is the endpoint-reference edge-case boundary: preview/custom URLs are @@ -284,6 +319,12 @@ private static string GetEndpointPropertyValue( var endpointReference = endpointReferenceExpression.Endpoint; var endpoint = endpointReference.EndpointAnnotation; + if (projectMap?.AreInSameProject(resource.Name, endpointReference.Resource.Name) == true) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' references endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}' inside the same Vercel project. Same-project references must be injected as Vercel service bindings and can only be used as direct URL-valued environment variables."); + } + if (!endpoint.IsExternal) { throw new DistributedApplicationException( @@ -340,6 +381,58 @@ private static void ValidateEnvironmentVariableName(IResource resource, string n } } + private static bool TryCreateServiceBinding( + IResource resource, + string environmentVariableName, + VercelDeploymentProjectMap? projectMap, + object? value, + [NotNullWhen(true)] out VercelServiceBinding? binding) + { + binding = null; + if (projectMap is null) + { + return false; + } + + EndpointReferenceExpression? endpointReferenceExpression = value switch + { + EndpointReference reference => reference.Property(EndpointProperty.Url), + EndpointReferenceExpression expression => expression, + _ => null + }; + + if (endpointReferenceExpression is null) + { + return false; + } + + var endpointReference = endpointReferenceExpression.Endpoint; + if (!IsCrossResourceEndpointReference(resource, endpointReference) + || !projectMap.AreInSameProject(resource.Name, endpointReference.Resource.Name)) + { + return false; + } + + var endpoint = endpointReference.EndpointAnnotation; + if (!VercelDeploymentModel.IsHttpEndpoint(endpoint)) + { + throw new DistributedApplicationException($"Resource '{resource.Name}' references endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}' with scheme '{endpoint.UriScheme}', but Vercel service bindings support only HTTP or HTTPS endpoints."); + } + + if (endpointReferenceExpression.Property != EndpointProperty.Url) + { + throw new DistributedApplicationException($"Resource '{resource.Name}' references endpoint property '{endpointReferenceExpression.Property}' for same-project Vercel service '{endpointReference.Resource.Name}', but Vercel service bindings can only inject URL values."); + } + + if (!projectMap.TryGetService(endpointReference.Resource.Name, out var targetService)) + { + throw new DistributedApplicationException($"Resource '{resource.Name}' references resource '{endpointReference.Resource.Name}', but the referenced resource is not part of a Vercel project group."); + } + + binding = new(environmentVariableName, targetService.ServiceName); + return true; + } + private static bool ContainsSecretReference(object? value) { // Connection strings are treated as secret-bearing even when the underlying provider @@ -397,4 +490,37 @@ private static bool IsSameResource(IResource resource, IResource otherResource) // can recreate resource references across an RPC boundary, but resource name is the // app-model identity used by deployment target maps. => string.Equals(resource.Name, otherResource.Name, StringComparison.Ordinal); + + private static IEnumerable GetReferencedResourceNames(IResource resource, object? value) + { + switch (value) + { + case null: + case string: + case ParameterResource: + case IResourceBuilder: + yield break; + case EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference): + yield return endpointReference.Resource.Name; + yield break; + case EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint): + yield return endpointReferenceExpression.Endpoint.Resource.Name; + yield break; + case IResource referencedResource when !IsSameResource(resource, referencedResource): + yield return referencedResource.Name; + yield break; + case IResourceBuilder resourceBuilder when !IsSameResource(resource, resourceBuilder.Resource): + yield return resourceBuilder.Resource.Name; + yield break; + case IValueWithReferences valueWithReferences: + foreach (var reference in valueWithReferences.References) + { + foreach (string resourceName in GetReferencedResourceNames(resource, reference)) + { + yield return resourceName; + } + } + break; + } + } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs index 6d48f5d48..a423cbdf2 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs @@ -74,6 +74,16 @@ public static bool IsValidProjectName(string projectName) || character == '-'); } + public static string GetServiceName(IResource resource) + { + if (TryCreateProjectName(resource.Name, out string? serviceName)) + { + return serviceName; + } + + throw new DistributedApplicationException($"Could not infer a valid Vercel service name for resource '{resource.Name}'. Rename the Aspire resource to use letters, digits, or hyphens."); + } + private static string GetManagedProjectName(VercelDeploymentEntry entry) { if (entry.Resource.TryGetLastAnnotation(out var options)) @@ -96,7 +106,7 @@ private static string GetManagedProjectName(VercelDeploymentEntry entry) throw new DistributedApplicationException($"Could not infer a valid Vercel project name for resource '{entry.Resource.Name}' from source root '{entry.SourceRoot}'. Rename the source directory or link the source root to an existing Vercel project."); } - private static bool TryCreateProjectName(string? value, [NotNullWhen(true)] out string? projectName) + internal static bool TryCreateProjectName(string? value, [NotNullWhen(true)] out string? projectName) { if (string.IsNullOrWhiteSpace(value)) { diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs index 7f96cb3ce..7a23ec0c8 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs @@ -322,7 +322,7 @@ public async Task ValidatePrerequisitesConfiguresVcrAsDeploymentTargetRegistry() Assert.NotNull(target.ContainerRegistry); Assert.Equal("vcr.vercel.com", await target.ContainerRegistry.Endpoint.GetValueAsync(TestContext.Current.CancellationToken)); Assert.Equal("test-team/test-project", await target.ContainerRegistry.Repository!.GetValueAsync(TestContext.Current.CancellationToken)); - Assert.Equal([ "app" ], registryClient.Repositories); + Assert.Equal([ "api" ], registryClient.Repositories); var pushOptions = new ContainerImagePushOptions(); var pushContext = new ContainerImagePushOptionsCallbackContext @@ -336,7 +336,7 @@ public async Task ValidatePrerequisitesConfiguresVcrAsDeploymentTargetRegistry() await annotation.Callback(pushContext); } - Assert.Equal("app", pushOptions.RemoteImageName); + Assert.Equal("api", pushOptions.RemoteImageName); Assert.StartsWith("aspire-", pushOptions.RemoteImageTag, StringComparison.Ordinal); var buildContext = new ContainerBuildOptionsCallbackContext( @@ -613,8 +613,8 @@ public async Task WriteDeploymentPlanWritesExpectedJson() Assert.Equal("Dockerfile", deployment.GetProperty("dockerfilePath").GetString()); string deployCommand = deployment.GetProperty("deployCommand").GetString()!; Assert.Contains("vercel pull --cwd --yes --environment preview", deployCommand); - Assert.Contains("aspire build/push api -> vcr.vercel.com///app:", deployCommand); - Assert.Contains("docker buildx imagetools inspect --format {{json .Manifest}} vcr.vercel.com///app:", deployCommand); + Assert.Contains("aspire build/push api -> vcr.vercel.com///api:", deployCommand); + Assert.Contains("docker buildx imagetools inspect --format {{json .Manifest}} vcr.vercel.com///api:", deployCommand); Assert.Contains("vercel deploy --cwd --project --prebuilt --yes", deployCommand); } @@ -673,8 +673,8 @@ public async Task WriteDeploymentPlanProcessesEnvironmentVariables() var deployment = Assert.Single(document.RootElement.GetProperty("deployments").EnumerateArray()); string deployCommand = deployment.GetProperty("deployCommand").GetString()!; - Assert.Contains("aspire build/push api -> vcr.vercel.com///app:", deployCommand); - Assert.Contains("vercel deploy --cwd --project --prebuilt --yes --env GREETING=", deployCommand); + Assert.Contains("aspire build/push api -> vcr.vercel.com///api:", deployCommand); + Assert.Contains("vercel deploy --cwd --project --prebuilt --yes", deployCommand); Assert.Equal("GREETING", Assert.Single(deployment.GetProperty("environmentVariables").EnumerateArray()).GetString()); Assert.DoesNotContain("hello", File.ReadAllText(planPath)); } @@ -855,9 +855,11 @@ public async Task WriteDeploymentPlanIgnoresWaitDependencies() builder.AddVercelEnvironment("vercel"); var backend = builder.AddContainer("backend", "backend") .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true) .WithVercelProjectName("backend"); builder.AddContainer("api", "api") .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true) .WithVercelProjectName("api") .WaitFor(backend); @@ -931,12 +933,145 @@ await AssertWriteDeploymentPlanSucceedsAsync(api => } [Fact] - public async Task WriteDeploymentPlanThrowsForInternalHttpEndpoints() + public async Task WriteDeploymentPlanAllowsSingleResourceInternalHttpEndpoint() { - var exception = await AssertWriteDeploymentPlanThrowsAsync(static api => + await AssertWriteDeploymentPlanSucceedsAsync(static api => api.WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: false)); + } + + [Fact] + public void ProjectGrouperKeepsMultiplePublicResourcesInSeparateProjects() + { + var api = CreateGroupingEntry("api", sourceRoot: "/tmp/api", isExternal: true); + var admin = CreateGroupingEntry("admin", sourceRoot: "/tmp/admin", isExternal: true); + + var projectMap = VercelDeploymentProjectGrouper.CreateMap( + [api, admin], + new Dictionary(StringComparer.Ordinal) + { + ["api"] = [], + ["admin"] = [] + }); + + Assert.Collection( + projectMap.Groups.OrderBy(static group => group.Root.ServiceName), + group => + { + Assert.Equal("admin", group.Root.ServiceName); + Assert.Equal(["admin"], group.Services.Select(static service => service.ServiceName).ToArray()); + }, + group => + { + Assert.Equal("api", group.Root.ServiceName); + Assert.Equal(["api"], group.Services.Select(static service => service.ServiceName).ToArray()); + }); + } + + [Fact] + public void ProjectGrouperPlacesReferencedInternalResourceInPublicProject() + { + var api = CreateGroupingEntry("api", sourceRoot: "/tmp/api", isExternal: true); + var backend = CreateGroupingEntry("backend", sourceRoot: "/tmp/backend", isExternal: false); + + var projectMap = VercelDeploymentProjectGrouper.CreateMap( + [api, backend], + new Dictionary(StringComparer.Ordinal) + { + ["api"] = ["backend"], + ["backend"] = [] + }); + + var group = Assert.Single(projectMap.Groups); + Assert.Equal("api", group.Root.ServiceName); + Assert.Collection( + group.Services, + service => + { + Assert.Equal("api", service.ServiceName); + Assert.True(service.IsPublicRoot); + }, + service => + { + Assert.Equal("backend", service.ServiceName); + Assert.False(service.IsPublicRoot); + }); + } + + [Fact] + public void ProjectGrouperThrowsForSharedInternalResource() + { + var api = CreateGroupingEntry("api", sourceRoot: "/tmp/api", isExternal: true); + var admin = CreateGroupingEntry("admin", sourceRoot: "/tmp/admin", isExternal: true); + var backend = CreateGroupingEntry("backend", sourceRoot: "/tmp/backend", isExternal: false); + + var exception = Assert.Throws(() => + VercelDeploymentProjectGrouper.CreateMap( + [api, admin, backend], + new Dictionary(StringComparer.Ordinal) + { + ["api"] = ["backend"], + ["admin"] = ["backend"], + ["backend"] = [] + })); + + Assert.Contains("referenced by multiple public Vercel project roots", exception.Message); + Assert.Contains("'api'", exception.Message); + Assert.Contains("'admin'", exception.Message); + } + + [Fact] + public void ProjectGrouperThrowsForInternalResourceWithoutPublicOwner() + { + var api = CreateGroupingEntry("api", sourceRoot: "/tmp/api", isExternal: true); + var backend = CreateGroupingEntry("backend", sourceRoot: "/tmp/backend", isExternal: false); - Assert.Contains("public platform HTTPS ingress only", exception.Message); + var exception = Assert.Throws(() => + VercelDeploymentProjectGrouper.CreateMap( + [api, backend], + new Dictionary(StringComparer.Ordinal) + { + ["api"] = [], + ["backend"] = [] + })); + + Assert.Contains("is not public and is not referenced by any public Vercel project root", exception.Message); + } + + [Fact] + public void ProjectGrouperThrowsForInternalProjectNameOverride() + { + var api = CreateGroupingEntry("api", sourceRoot: "/tmp/api", isExternal: true); + var backend = CreateGroupingEntry("backend", sourceRoot: "/tmp/backend", isExternal: false); + backend.Resource.Annotations.Add(new VercelProjectOptionsAnnotation("backend-project")); + + var exception = Assert.Throws(() => + VercelDeploymentProjectGrouper.CreateMap( + [api, backend], + new Dictionary(StringComparer.Ordinal) + { + ["api"] = ["backend"], + ["backend"] = [] + })); + + Assert.Contains("WithVercelProjectName can only be applied to public Vercel project roots", exception.Message); + } + + [Fact] + public void ProjectGrouperThrowsForDuplicateServiceNames() + { + var first = CreateGroupingEntry("my.api", sourceRoot: "/tmp/first", isExternal: true); + var second = CreateGroupingEntry("my_api", sourceRoot: "/tmp/second", isExternal: true); + + var exception = Assert.Throws(() => + VercelDeploymentProjectGrouper.CreateMap( + [first, second], + new Dictionary(StringComparer.Ordinal) + { + ["my.api"] = [], + ["my_api"] = [] + })); + + Assert.Contains("service name 'my-api'", exception.Message); } [Fact] @@ -970,9 +1105,11 @@ public async Task WriteDeploymentPlanThrowsForManagedProjectNameCollisions() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") - .WithDockerfile(firstRoot, "Dockerfile"); + .WithDockerfile(firstRoot, "Dockerfile") + .WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true); builder.AddContainer("worker", "worker") - .WithDockerfile(secondRoot, "Dockerfile"); + .WithDockerfile(secondRoot, "Dockerfile") + .WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -1003,9 +1140,11 @@ public async Task WriteDeploymentPlanThrowsForLinkedProjectNameCollisions() var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") - .WithDockerfile(firstRoot, "Dockerfile"); + .WithDockerfile(firstRoot, "Dockerfile") + .WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true); builder.AddContainer("worker", "worker") - .WithDockerfile(secondRoot, "Dockerfile"); + .WithDockerfile(secondRoot, "Dockerfile") + .WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -1035,9 +1174,11 @@ public async Task WriteDeploymentPlanThrowsForConfiguredProjectNameCollisions() builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") .WithDockerfile(firstRoot, "Dockerfile") + .WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true) .WithVercelProjectName("shared-project"); builder.AddContainer("worker", "worker") .WithDockerfile(secondRoot, "Dockerfile") + .WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true) .WithVercelProjectName("shared-project"); using var app = builder.Build(); @@ -1082,7 +1223,7 @@ public async Task DeployAsyncUsesOriginalSourceRootWithSlugifiedProjectName() var digestInvocation = Assert.Single(runner.Invocations, invocation => invocation.FileName == "docker" && invocation.Arguments.Contains("imagetools")); Assert.Null(digestInvocation.WorkingDirectory); - Assert.StartsWith("vcr.vercel.com/test-team/test-project/app:", digestInvocation.Arguments[^1], StringComparison.Ordinal); + Assert.StartsWith("vcr.vercel.com/test-team/test-project/api:", digestInvocation.Arguments[^1], StringComparison.Ordinal); var deployInvocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("deploy")); Assert.Contains("--project", deployInvocation.Arguments); Assert.Contains("invalid-project", deployInvocation.Arguments); @@ -1556,9 +1697,9 @@ public async Task DeployAsyncConfiguresSecretEnvironmentVariablesBeforeDeploy() Assert.Contains(runner.Invocations, invocation => invocation.Arguments.SequenceEqual(["pull", "--cwd", expectedProjectLinkDirectory, "--yes", "--environment", "production"])); Assert.Contains(runner.Invocations, invocation => invocation.FileName == "docker" && invocation.Arguments.SequenceEqual(["login", "vcr.vercel.com", "--username", "team_test", "--password-stdin"])); Assert.Contains(runner.Invocations, invocation => invocation.FileName == "docker" && invocation.Arguments.Contains("imagetools")); - AssertGeneratedBuildOutput(expectedDeployDirectory); + AssertGeneratedBuildOutput(expectedDeployDirectory, expectedEnvironmentVariables: [new("GREETING", "hello")]); var deployInvocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("deploy")); - Assert.Contains("GREETING=hello", deployInvocation.Arguments); + Assert.DoesNotContain("--env", deployInvocation.Arguments); Assert.DoesNotContain(deployInvocation.Arguments, argument => argument.Contains("API_KEY", StringComparison.Ordinal)); Assert.DoesNotContain(deployInvocation.Arguments, argument => argument.Contains("AUTH_HEADER", StringComparison.Ordinal)); @@ -1757,6 +1898,7 @@ public async Task BuildDeployArgumentsUsesProductionUrlForEndpointEnvironmentVar .WithVercelProductionDeployments(); var api = builder.AddContainer("api", "api") .WithDockerfile(apiRoot, "Dockerfile") + .WithEndpoint(port: 8081, targetPort: 8080, scheme: "http", name: "http", isExternal: true) .WithComputeEnvironment(vercel); var backend = builder.AddContainer("backend", "backend") .WithDockerfile(backendRoot, "Dockerfile") @@ -1801,6 +1943,7 @@ public async Task BuildDeployArgumentsUsesProductionUrlForServiceDiscoveryRefere .WithComputeEnvironment(vercel); builder.AddContainer("api", "api") .WithDockerfile(apiRoot, "Dockerfile") + .WithEndpoint(port: 8081, targetPort: 8080, scheme: "http", name: "http", isExternal: true) .WithReference(backend.GetEndpoint("http")) .WithComputeEnvironment(vercel); @@ -1837,6 +1980,7 @@ public async Task BuildDeployArgumentsUsesConfiguredProjectNameForEndpointRefere .WithVercelProductionDeployments(); var api = builder.AddContainer("api", "api") .WithDockerfile(apiRoot, "Dockerfile") + .WithEndpoint(port: 8081, targetPort: 8080, scheme: "http", name: "http", isExternal: true) .WithComputeEnvironment(vercel); var backend = builder.AddContainer("backend", "backend") .WithDockerfile(backendRoot, "Dockerfile") @@ -1878,6 +2022,7 @@ public async Task BuildDeployArgumentsThrowsForEndpointReferencesWithoutProducti .WithVercelTarget("preview"); var api = builder.AddContainer("api", "api") .WithDockerfile(apiRoot, "Dockerfile") + .WithEndpoint(port: 8081, targetPort: 8080, scheme: "http", name: "http", isExternal: true) .WithComputeEnvironment(vercel); var backend = builder.AddContainer("backend", "backend") .WithDockerfile(backendRoot, "Dockerfile") @@ -1947,7 +2092,7 @@ public async Task BuildDeployArgumentsThrowsForEndpointReferencesToDifferentVerc } [Fact] - public async Task BuildDeployArgumentsThrowsForInternalEndpointReferences() + public async Task BuildDeployArgumentsUsesServiceBindingForSameProjectInternalEndpointReferences() { using var sourceRoot = TemporaryDirectory.Create(); string apiRoot = Path.Combine(sourceRoot.Path, "api-app"); @@ -1962,6 +2107,7 @@ public async Task BuildDeployArgumentsThrowsForInternalEndpointReferences() .WithVercelProductionDeployments(); var api = builder.AddContainer("api", "api") .WithDockerfile(apiRoot, "Dockerfile") + .WithEndpoint(port: 8081, targetPort: 8080, scheme: "http", name: "http", isExternal: true) .WithComputeEnvironment(vercel); var backend = builder.AddContainer("backend", "backend") .WithDockerfile(backendRoot, "Dockerfile") @@ -1974,17 +2120,24 @@ public async Task BuildDeployArgumentsThrowsForInternalEndpointReferences() var environment = Assert.Single(model.Resources.OfType()); var entries = VercelDeploymentModel.GetEntries(model, environment).ToArray(); var entry = Assert.Single(entries, entry => entry.Resource.Name == "api"); + var entriesByResourceName = VercelDeploymentModel.GetEntriesByResourceName(entries); + var projectMap = await VercelDeploymentProjectGrouper.CreateMapAsync(builder.ExecutionContext, NullLogger.Instance, entries, TestContext.Current.CancellationToken); - var exception = await Assert.ThrowsAsync(() => - VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( - builder.ExecutionContext, - NullLogger.Instance, - environment.GetVercelOptions(), - entry, - entries, - TestContext.Current.CancellationToken)); + var environmentConfiguration = await VercelDeploymentPlanWriter.GetEnvironmentConfigurationAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + entriesByResourceName, + projectMap, + resolveProjectEnvironmentVariableValues: false, + TestContext.Current.CancellationToken); - Assert.Contains("can only target external HTTP or HTTPS endpoints", exception.Message); + Assert.Empty(environmentConfiguration.DeploymentEnvironmentVariables); + Assert.Empty(environmentConfiguration.ProjectEnvironmentVariables); + var binding = Assert.Single(environmentConfiguration.ServiceBindings); + Assert.Equal("BACKEND_URL", binding.EnvironmentVariableName); + Assert.Equal("backend", binding.ServiceName); } [Fact] @@ -2003,6 +2156,7 @@ public async Task BuildDeployArgumentsThrowsForTargetPortEndpointReferenceWithou .WithVercelProductionDeployments(); var api = builder.AddContainer("api", "api") .WithDockerfile(apiRoot, "Dockerfile") + .WithEndpoint(port: 8081, targetPort: 8080, scheme: "http", name: "http", isExternal: true) .WithComputeEnvironment(vercel); var backend = builder.AddContainer("backend", "backend") .WithDockerfile(backendRoot, "Dockerfile") @@ -2159,12 +2313,12 @@ public async Task DeployAsyncRunsVercelCliAndSavesDeploymentState() var invocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("deploy")); string expectedDeployDirectory = Path.Combine(tempRoot.Path, "api", "vercel-build-output"); Assert.Equal(["buildx", "imagetools", "inspect", "--format", "{{json .Manifest}}", digestInvocation.Arguments[^1]], digestInvocation.Arguments); - Assert.StartsWith("vcr.vercel.com/test-team/test-project/app:", digestInvocation.Arguments[^1], StringComparison.Ordinal); + Assert.StartsWith("vcr.vercel.com/test-team/test-project/api:", digestInvocation.Arguments[^1], StringComparison.Ordinal); Assert.Equal("vercel", invocation.FileName); Assert.Equal(expectedDeployDirectory, invocation.WorkingDirectory); - Assert.Equal(["deploy", "--scope", "team", "--cwd", expectedDeployDirectory, "--project", "vercel-state-project", "--prebuilt", "--yes", "--prod", "--env", "GREETING=hello"], invocation.Arguments); + Assert.Equal(["deploy", "--scope", "team", "--cwd", expectedDeployDirectory, "--project", "vercel-state-project", "--prebuilt", "--yes", "--prod"], invocation.Arguments); Assert.Null(invocation.StandardInput); - AssertGeneratedBuildOutput(expectedDeployDirectory); + AssertGeneratedBuildOutput(expectedDeployDirectory, expectedEnvironmentVariables: [new("GREETING", "hello")]); Assert.False(File.Exists(Path.Combine(sourceRoot.Path, "vercel.json"))); var inspectInvocation = Assert.Single(runner.Invocations, invocation => invocation.FileName == "vercel" && invocation.Arguments.Contains("inspect")); Assert.Equal(["inspect", "https://api.vercel.app", "--scope", "team", "--wait", "--timeout", "120s", "--format=json"], inspectInvocation.Arguments); @@ -2211,10 +2365,14 @@ public async Task DeployAsyncSavesVerifiedResourcesBeforeLaterResourceFails() Directory.CreateDirectory(workerRoot); File.WriteAllText(Path.Combine(apiRoot, "Dockerfile"), "FROM nginx:alpine"); File.WriteAllText(Path.Combine(workerRoot, "Dockerfile"), "FROM nginx:alpine"); - var runner = new FakeVercelCliRunner( - new(0, "https://partial-api.vercel.app", ""), - ReadyInspectResult(), - new(1, "", "worker deploy failed")); + var runner = new CoordinatedVercelDeployRunner( + expectedDeployments: 2, + waitForAllDeployments: false, + deployResults: new Dictionary(StringComparer.Ordinal) + { + ["partial-api"] = new(0, "https://partial-api.vercel.app", ""), + ["partial-worker"] = new(1, "", "worker deploy failed") + }); var stateManager = new FakeDeploymentStateManager(); var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); @@ -2224,9 +2382,11 @@ public async Task DeployAsyncSavesVerifiedResourcesBeforeLaterResourceFails() builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); builder.AddVercelEnvironment("vercel"); builder.AddContainer("api", "api") - .WithDockerfile(apiRoot, "Dockerfile"); + .WithDockerfile(apiRoot, "Dockerfile") + .WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true); builder.AddContainer("worker", "worker") - .WithDockerfile(workerRoot, "Dockerfile"); + .WithDockerfile(workerRoot, "Dockerfile") + .WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true); using var app = builder.Build(); var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); @@ -2253,6 +2413,57 @@ public async Task DeployAsyncSavesVerifiedResourcesBeforeLaterResourceFails() }); } + [Fact] + public async Task DeployAsyncRunsPrebuiltDeploysInParallelAfterResolvingImageDigests() + { + using var parent = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + string apiRoot = Path.Combine(parent.Path, "parallel-api"); + string workerRoot = Path.Combine(parent.Path, "parallel-worker"); + Directory.CreateDirectory(apiRoot); + Directory.CreateDirectory(workerRoot); + File.WriteAllText(Path.Combine(apiRoot, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(workerRoot, "Dockerfile"), "FROM nginx:alpine"); + var runner = new CoordinatedVercelDeployRunner(expectedDeployments: 2, waitForAllDeployments: true); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(apiRoot, "Dockerfile") + .WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true); + builder.AddContainer("worker", "worker") + .WithDockerfile(workerRoot, "Dockerfile") + .WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + Assert.Equal(1, runner.MaxConcurrentDigestInspections); + Assert.Equal(2, runner.MaxConcurrentDeployments); + var state = ReadSavedState(stateManager.SavedSections[^1]); + Assert.Collection( + state.Deployments.OrderBy(static deployment => deployment.ResourceName), + deployment => + { + Assert.Equal("api", deployment.ResourceName); + Assert.Equal("https://parallel-api.vercel.app", deployment.DeploymentUrl); + }, + deployment => + { + Assert.Equal("worker", deployment.ResourceName); + Assert.Equal("https://parallel-worker.vercel.app", deployment.DeploymentUrl); + }); + } + [Fact] public async Task DeployAsyncPreservesPreviousManagedStateForResourcesRemovedFromModel() { @@ -2605,7 +2816,7 @@ public async Task DeployAsyncThrowsWhenVercelCliFails() var exception = await Assert.ThrowsAsync(() => VercelDeploymentStep.DeployAsync(context, environment)); - Assert.Contains("Failed to deploy prebuilt resource 'api' to Vercel using 'vercel' (exit code 1). deploy failed", exception.Message); + Assert.Contains("Failed to deploy prebuilt project root 'api' to Vercel using 'vercel' (exit code 1). deploy failed", exception.Message); } [Fact] @@ -3533,6 +3744,20 @@ private static VercelDeploymentEntry CreateDeploymentEntry(string sourceRoot) new DockerfileBuildAnnotation(sourceRoot, dockerfilePath, stage: null)); } + private static VercelDeploymentEntry CreateGroupingEntry(string resourceName, string sourceRoot, bool isExternal) + { + var resource = new ContainerResource(resourceName); + resource.Annotations.Add(new EndpointAnnotation( + ProtocolType.Tcp, + uriScheme: "http", + transport: "http", + name: "http", + targetPort: 8080, + isExternal: isExternal)); + + return new(resource, sourceRoot); + } + private static VercelPreparedDeploymentAnnotation CreatePreparedDeployment( IResource resource, string oidcToken, @@ -3550,19 +3775,18 @@ private static VercelPreparedDeploymentAnnotation CreatePreparedDeployment( "test-team", projectName, $"prj_{projectName}"); - var projectContext = new VercelPulledProjectContext( - VercelEnvironmentConfiguration.Empty, - project, - claims); + var projectContext = new VercelPulledProjectContext(project, claims); return new( entry, + VercelProjectNameResolver.GetServiceName(resource), new(projectName, project.ProjectId), projectContext, + VercelEnvironmentConfiguration.Empty, ManagedByAspire: true, - RemoteImageName: "app", + RemoteImageName: VercelProjectNameResolver.GetServiceName(resource), RemoteImageTag: "aspire-test", - TaggedImageReference: $"vcr.vercel.com/test-team/{projectName}/app:aspire-test"); + TaggedImageReference: $"vcr.vercel.com/test-team/{projectName}/{VercelProjectNameResolver.GetServiceName(resource)}:aspire-test"); } private static void WriteVercelProjectLink(string sourceRoot, string projectName, string projectId) @@ -3667,16 +3891,20 @@ public async Task PreparePulledProjectContextDeletesScratchLinkDirectory() var model = app.Services.GetRequiredService(); var environment = Assert.Single(model.Resources.OfType()); var context = CreatePipelineStepContext(builder, app); - var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); - var entriesByResourceName = VercelDeploymentModel.GetEntries(model, environment) + var entries = VercelDeploymentModel.GetEntries(model, environment).ToArray(); + var entry = Assert.Single(entries); + var entriesByResourceName = entries .ToDictionary(static deployment => deployment.Resource.Name, StringComparer.Ordinal); + var projectMap = await VercelDeploymentProjectGrouper.CreateMapAsync(builder.ExecutionContext, NullLogger.Instance, entries, TestContext.Current.CancellationToken); + var group = Assert.Single(projectMap.Groups); - var projectContext = await VercelDeploymentStep.PreparePulledProjectContextAsync( + var (projectContext, environmentConfigurations) = await VercelDeploymentStep.PreparePulledProjectContextAsync( context, runner, environment.GetVercelOptions(), - entry, - entriesByResourceName); + group, + entriesByResourceName, + projectMap); string expectedProjectLinkDirectory = Path.Combine(tempRoot.Path, "api", ".vercel-project"); string expectedProjectName = VercelProjectNameResolver.GetProjectName(entry); @@ -3687,8 +3915,9 @@ public async Task PreparePulledProjectContextDeletesScratchLinkDirectory() Assert.Equal("test-team", projectContext.OidcClaims.Owner); Assert.Equal("test-project", projectContext.OidcClaims.Project); Assert.Contains("\"projectId\": \"prj_test\"", projectContext.PulledProject.ProjectJsonContent, StringComparison.Ordinal); - Assert.Empty(projectContext.EnvironmentConfiguration.DeploymentEnvironmentVariables); - Assert.Empty(projectContext.EnvironmentConfiguration.ProjectEnvironmentVariables); + var environmentConfiguration = environmentConfigurations[entry.Resource.Name]; + Assert.Empty(environmentConfiguration.DeploymentEnvironmentVariables); + Assert.Empty(environmentConfiguration.ProjectEnvironmentVariables); Assert.Collection( runner.Invocations, @@ -3727,22 +3956,130 @@ public async Task WriteBuildOutputAsyncWritesProviderArtifactShape() } """, OidcToken: "unused"); + var service = new VercelDeploymentService(entry, "api", IsPublicRoot: true); + var group = new VercelDeploymentProjectGroup(service, [service]); + var projectContext = new VercelPulledProjectContext( + project, + new VercelOidcClaims("team_test", "test-team", "test-project", "prj_test")); + var preparedDeployment = new VercelPreparedDeploymentAnnotation( + entry, + "api", + new("api", "prj_test"), + projectContext, + VercelEnvironmentConfiguration.Empty, + ManagedByAspire: true, + RemoteImageName: "api", + RemoteImageTag: "aspire-test", + TaggedImageReference: "vcr.vercel.com/test-team/test-project/api:aspire-test"); + var resolvedDeployments = new[] + { + new VercelResolvedDeployment( + preparedDeployment, + new VercelImageReference( + $"vcr.vercel.com/test-team/test-project/api@{FakeVercelCliRunner.FakeImageDigest}", + FakeVercelCliRunner.FakeImageDigest)) + }; await VercelBuildOutputWriter.WriteAsync( - entry, + group, project, - $"vcr.vercel.com/test-team/test-project/app@{FakeVercelCliRunner.FakeImageDigest}", + resolvedDeployments, TestContext.Current.CancellationToken); AssertGeneratedBuildOutput(deployRoot.Path, expectedProjectName: "api"); } - private static void AssertGeneratedBuildOutput(string deployDirectory, string? expectedProjectName = null) + [Fact] + public async Task WriteBuildOutputAsyncWritesServicesAndBindings() + { + using var deployRoot = TemporaryDirectory.Create(); + var apiEntry = CreateGroupingEntry("api", sourceRoot: "/tmp/api", isExternal: true) with + { + DeployDirectory = deployRoot.Path + }; + var backendEntry = CreateGroupingEntry("backend", sourceRoot: "/tmp/backend", isExternal: false); + var apiService = new VercelDeploymentService(apiEntry, "api", IsPublicRoot: true); + var backendService = new VercelDeploymentService(backendEntry, "backend", IsPublicRoot: false); + var group = new VercelDeploymentProjectGroup(apiService, [apiService, backendService]); + var project = new VercelPulledProject( + "api", + "prj_test", + "team_test", + """ + { + "projectId": "prj_test", + "orgId": "team_test", + "projectName": "api" + } + """, + OidcToken: "unused"); + var projectContext = new VercelPulledProjectContext( + project, + new VercelOidcClaims("team_test", "test-team", "test-project", "prj_test")); + + VercelResolvedDeployment CreateResolved( + VercelDeploymentEntry entry, + string serviceName, + VercelEnvironmentConfiguration environmentConfiguration) + { + var preparedDeployment = new VercelPreparedDeploymentAnnotation( + entry, + serviceName, + new("api", "prj_test"), + projectContext, + environmentConfiguration, + ManagedByAspire: true, + RemoteImageName: serviceName, + RemoteImageTag: "aspire-test", + TaggedImageReference: $"vcr.vercel.com/test-team/test-project/{serviceName}:aspire-test"); + + return new( + preparedDeployment, + new VercelImageReference( + $"vcr.vercel.com/test-team/test-project/{serviceName}@{FakeVercelCliRunner.FakeImageDigest}", + FakeVercelCliRunner.FakeImageDigest)); + } + + await VercelBuildOutputWriter.WriteAsync( + group, + project, + [ + CreateResolved(apiEntry, "api", new([], [], [new("BACKEND_URL", "backend")])), + CreateResolved(backendEntry, "backend", VercelEnvironmentConfiguration.Empty) + ], + TestContext.Current.CancellationToken); + + string outputDirectory = Path.Combine(deployRoot.Path, ".vercel", "output"); + Assert.True(File.Exists(Path.Combine(outputDirectory, "services", "api", "functions", "index.func", ".vc-config.json"))); + Assert.True(File.Exists(Path.Combine(outputDirectory, "services", "backend", "functions", "index.func", ".vc-config.json"))); + + using var configDocument = JsonDocument.Parse(File.ReadAllText(Path.Combine(outputDirectory, "config.json"))); + var root = configDocument.RootElement; + var experimentalServices = root.GetProperty("experimentalServicesV2"); + Assert.Equal("api/", experimentalServices.GetProperty("api").GetProperty("root").GetString()); + var binding = Assert.Single(experimentalServices.GetProperty("api").GetProperty("bindings").EnumerateArray()); + Assert.Equal("service", binding.GetProperty("type").GetString()); + Assert.Equal("backend", binding.GetProperty("service").GetString()); + Assert.Equal("url", binding.GetProperty("format").GetString()); + Assert.Equal("BACKEND_URL", binding.GetProperty("env").GetString()); + Assert.Equal("backend/", experimentalServices.GetProperty("backend").GetProperty("root").GetString()); + + var services = root.GetProperty("services").EnumerateArray().OrderBy(static service => service.GetProperty("name").GetString()).ToArray(); + Assert.Equal(["api", "backend"], services.Select(static service => service.GetProperty("name").GetString()!).ToArray()); + var serviceManifestBinding = Assert.Single(services[0].GetProperty("bindings").EnumerateArray()); + Assert.Equal("backend", serviceManifestBinding.GetProperty("service").GetString()); + } + + private static void AssertGeneratedBuildOutput( + string deployDirectory, + string? expectedProjectName = null, + IReadOnlyList>? expectedEnvironmentVariables = null) { string outputDirectory = Path.Combine(deployDirectory, ".vercel", "output"); string projectJsonPath = Path.Combine(deployDirectory, ".vercel", "project.json"); string configJsonPath = Path.Combine(outputDirectory, "config.json"); - string functionConfigPath = Path.Combine(outputDirectory, "functions", "index.func", ".vc-config.json"); + string serviceConfigPath = Path.Combine(outputDirectory, "services", "api", "config.json"); + string functionConfigPath = Path.Combine(outputDirectory, "services", "api", "functions", "index.func", ".vc-config.json"); var generatedFiles = Directory.EnumerateFiles(deployDirectory, "*", SearchOption.AllDirectories) .Select(path => Path.GetRelativePath(deployDirectory, path).Replace(Path.DirectorySeparatorChar, '/')) .Order(StringComparer.Ordinal) @@ -3751,7 +4088,8 @@ private static void AssertGeneratedBuildOutput(string deployDirectory, string? e Assert.Equal( [ ".vercel/output/config.json", - ".vercel/output/functions/index.func/.vc-config.json", + ".vercel/output/services/api/config.json", + ".vercel/output/services/api/functions/index.func/.vc-config.json", ".vercel/project.json" ], generatedFiles); @@ -3772,22 +4110,48 @@ private static void AssertGeneratedBuildOutput(string deployDirectory, string? e using var configDocument = JsonDocument.Parse(File.ReadAllText(configJsonPath)); var root = configDocument.RootElement; - Assert.Equal(["version", "routes"], root.EnumerateObject().Select(static property => property.Name).ToArray()); + Assert.Equal(["version", "routes", "crons", "experimentalServicesV2", "services"], root.EnumerateObject().Select(static property => property.Name).ToArray()); Assert.Equal(3, root.GetProperty("version").GetInt32()); var routes = root.GetProperty("routes").EnumerateArray().ToArray(); Assert.Equal(2, routes.Length); Assert.Equal(["handle"], routes[0].EnumerateObject().Select(static property => property.Name).ToArray()); Assert.Equal("filesystem", routes[0].GetProperty("handle").GetString()); - Assert.Equal(["src", "dest"], routes[1].EnumerateObject().Select(static property => property.Name).ToArray()); - Assert.Equal("/(.*)", routes[1].GetProperty("src").GetString()); - Assert.Equal("/index", routes[1].GetProperty("dest").GetString()); + Assert.Equal(["src", "destination"], routes[1].EnumerateObject().Select(static property => property.Name).ToArray()); + Assert.Equal("^(?:/(.*))$", routes[1].GetProperty("src").GetString()); + var destination = routes[1].GetProperty("destination"); + Assert.Equal("api", destination.GetProperty("service").GetString()); + Assert.Equal("service", destination.GetProperty("type").GetString()); + Assert.Empty(root.GetProperty("crons").EnumerateArray()); + var experimentalServices = root.GetProperty("experimentalServicesV2"); + Assert.Equal("api/", experimentalServices.GetProperty("api").GetProperty("root").GetString()); + var serviceManifest = Assert.Single(root.GetProperty("services").EnumerateArray()); + Assert.Equal("experimentalServicesV2", serviceManifest.GetProperty("schema").GetString()); + Assert.Equal("api", serviceManifest.GetProperty("name").GetString()); + Assert.Equal("api", serviceManifest.GetProperty("root").GetString()); + + using var serviceDocument = JsonDocument.Parse(File.ReadAllText(serviceConfigPath)); + var serviceConfig = serviceDocument.RootElement; + Assert.Equal(["version", "routes", "crons"], serviceConfig.EnumerateObject().Select(static property => property.Name).ToArray()); + Assert.Equal(3, serviceConfig.GetProperty("version").GetInt32()); + var serviceRoutes = serviceConfig.GetProperty("routes").EnumerateArray().ToArray(); + Assert.Equal(2, serviceRoutes.Length); + Assert.Equal("filesystem", serviceRoutes[0].GetProperty("handle").GetString()); + Assert.Equal("/(.*)", serviceRoutes[1].GetProperty("src").GetString()); + Assert.Equal("/index", serviceRoutes[1].GetProperty("dest").GetString()); + Assert.Empty(serviceConfig.GetProperty("crons").EnumerateArray()); using var functionDocument = JsonDocument.Parse(File.ReadAllText(functionConfigPath)); var functionConfig = functionDocument.RootElement; Assert.Equal(["handler", "runtime", "environment"], functionConfig.EnumerateObject().Select(static property => property.Name).ToArray()); Assert.Equal("container", functionConfig.GetProperty("runtime").GetString()); - Assert.Equal($"vcr.vercel.com/test-team/test-project/app@{FakeVercelCliRunner.FakeImageDigest}", functionConfig.GetProperty("handler").GetString()); - Assert.Empty(functionConfig.GetProperty("environment").EnumerateObject()); + Assert.Equal($"vcr.vercel.com/test-team/test-project/api@{FakeVercelCliRunner.FakeImageDigest}", functionConfig.GetProperty("handler").GetString()); + expectedEnvironmentVariables ??= []; + var environmentVariables = functionConfig.GetProperty("environment").EnumerateObject().OrderBy(static property => property.Name).ToArray(); + Assert.Equal(expectedEnvironmentVariables.Select(static variable => variable.Key).Order(StringComparer.Ordinal), environmentVariables.Select(static variable => variable.Name)); + foreach (var expectedEnvironmentVariable in expectedEnvironmentVariables) + { + Assert.Equal(expectedEnvironmentVariable.Value, functionConfig.GetProperty("environment").GetProperty(expectedEnvironmentVariable.Key).GetString()); + } } private sealed class FakeProjectMetadata(string projectPath) : IProjectMetadata @@ -3830,6 +4194,222 @@ public void Dispose() } } + private sealed class CoordinatedVercelDeployRunner( + int expectedDeployments, + bool waitForAllDeployments, + IReadOnlyDictionary? deployResults = null) : IVercelCliRunner + { + private readonly Dictionary _linkedProjects = new(StringComparer.Ordinal); + private readonly HashSet _projects = new(StringComparer.Ordinal); + private readonly TaskCompletionSource _allDeploymentsStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _activeDeployments; + private int _activeDigestInspections; + private int _startedDeployments; + private int _maxConcurrentDeployments; + private int _maxConcurrentDigestInspections; + + public int MaxConcurrentDeployments => Volatile.Read(ref _maxConcurrentDeployments); + + public int MaxConcurrentDigestInspections => Volatile.Read(ref _maxConcurrentDigestInspections); + + public Task GetVersionAsync(CancellationToken cancellationToken) + => Task.FromResult(new VercelCliResult(0, "54.18.6", "")); + + public Task GetCurrentUserAsync(CancellationToken cancellationToken) + => Task.FromResult(new VercelCliResult(0, "test-user", "")); + + public Task ListProjectsAsync( + VercelEnvironmentOptionsAnnotation options, + string? filter, + CancellationToken cancellationToken) + { + string[] projects = _projects + .Where(project => string.IsNullOrWhiteSpace(filter) || project.Contains(filter, StringComparison.Ordinal)) + .Order(StringComparer.Ordinal) + .ToArray(); + + return Task.FromResult(new VercelCliResult(0, ProjectListJson(projects), "")); + } + + public Task AddProjectAsync( + VercelEnvironmentOptionsAnnotation options, + string projectName, + CancellationToken cancellationToken) + { + _projects.Add(projectName); + return Task.FromResult(new VercelCliResult(0, "", "")); + } + + public Task RemoveProjectAsync( + VercelEnvironmentOptionsAnnotation options, + string projectName, + CancellationToken cancellationToken) + { + _projects.Remove(projectName); + return Task.FromResult(new VercelCliResult(0, "", "")); + } + + public Task LinkProjectAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string projectNameOrId, + CancellationToken cancellationToken) + { + _linkedProjects[projectLinkDirectory] = projectNameOrId; + return Task.FromResult(new VercelCliResult(0, "", "")); + } + + public Task PullProjectSettingsAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string targetEnvironment, + CancellationToken cancellationToken) + { + string project = _linkedProjects.GetValueOrDefault(projectLinkDirectory, "api"); + string vercelDirectory = Path.Combine(projectLinkDirectory, ".vercel"); + Directory.CreateDirectory(vercelDirectory); + File.WriteAllText(Path.Combine(vercelDirectory, "project.json"), $$""" + { + "projectId": "prj_{{project}}", + "orgId": "team_{{project}}", + "projectName": "{{project}}", + "settings": { + "createdAt": 0, + "framework": null, + "devCommand": null, + "installCommand": null, + "buildCommand": null, + "outputDirectory": null, + "rootDirectory": null, + "directoryListing": false, + "nodeVersion": "22.x" + } + } + """); + File.WriteAllText(Path.Combine(vercelDirectory, $".env.{targetEnvironment}.local"), $"VERCEL_OIDC_TOKEN=\"{CreateFakeOidcToken(project)}\"{Environment.NewLine}"); + + return Task.FromResult(new VercelCliResult(0, "", "")); + } + + public Task ListProjectEnvironmentVariablesAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string targetEnvironment, + CancellationToken cancellationToken) + => Task.FromResult(new VercelCliResult(0, EnvListJson(), "")); + + public Task AddProjectEnvironmentVariableAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string name, + string targetEnvironment, + string value, + CancellationToken cancellationToken) + => Task.FromResult(new VercelCliResult(0, "", "")); + + public Task RemoveProjectEnvironmentVariableAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string name, + string targetEnvironment, + CancellationToken cancellationToken) + => Task.FromResult(new VercelCliResult(0, "", "")); + + public async Task DeployPrebuiltAsync( + VercelEnvironmentOptionsAnnotation options, + string deployDirectory, + string? projectNameOrId, + IReadOnlyList> environmentVariables, + CancellationToken cancellationToken) + { + int activeDeployments = Interlocked.Increment(ref _activeDeployments); + UpdateMax(ref _maxConcurrentDeployments, activeDeployments); + + try + { + if (Interlocked.Increment(ref _startedDeployments) == expectedDeployments) + { + _allDeploymentsStarted.TrySetResult(); + } + + if (waitForAllDeployments) + { + await _allDeploymentsStarted.Task.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false); + } + + string project = projectNameOrId ?? "api"; + if (deployResults is not null && deployResults.TryGetValue(project, out var configuredResult)) + { + return configuredResult; + } + + return new(0, $"https://{project}.vercel.app", ""); + } + finally + { + Interlocked.Decrement(ref _activeDeployments); + } + } + + public Task InspectDeploymentAsync( + VercelEnvironmentOptionsAnnotation options, + string deploymentUrl, + CancellationToken cancellationToken) + => Task.FromResult(ReadyInspectResult()); + + public Task ValidateDockerBuildxAsync(CancellationToken cancellationToken) + => Task.FromResult(new VercelCliResult(0, "", "")); + + public Task LoginToVcrAsync(string ownerId, string oidcToken, CancellationToken cancellationToken) + => Task.FromResult(new VercelCliResult(0, "", "")); + + public async Task InspectDockerImageDigestAsync(string imageReference, CancellationToken cancellationToken) + { + int activeDigestInspections = Interlocked.Increment(ref _activeDigestInspections); + UpdateMax(ref _maxConcurrentDigestInspections, activeDigestInspections); + + try + { + await Task.Delay(25, cancellationToken).ConfigureAwait(false); + return new(0, FakeVercelCliRunner.FakeImageManifestJson, ""); + } + finally + { + Interlocked.Decrement(ref _activeDigestInspections); + } + } + + private static void UpdateMax(ref int max, int value) + { + while (true) + { + int current = Volatile.Read(ref max); + if (value <= current) + { + return; + } + + if (Interlocked.CompareExchange(ref max, value, current) == current) + { + return; + } + } + } + + private static string CreateFakeOidcToken(string project) + { + string header = Base64UrlEncode("""{"alg":"none"}"""); + string payload = Base64UrlEncode($$"""{"owner_id":"team_{{project}}","owner":"test-team","project":"{{project}}","project_id":"prj_{{project}}"}"""); + return $"{header}.{payload}."; + } + + private static string Base64UrlEncode(string value) + => Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(value)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + private sealed class FakeVercelCliRunner : IVercelCliRunner { private readonly VercelCliRunner _runner;