From f1bfbd7294cabf29dbec345ab078a6bd078f11b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 09:22:11 +0000 Subject: [PATCH 1/2] feat(vercel): scaffold @tsops/vercel adapter skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new package, design doc, and hybrid example that establish the shape of Vercel support without bending the existing k8s orchestrator. Package (packages/vercel): - VercelClient port + VercelApi REST adapter (stubs that log + throw) - VercelPlanner: diffs desired env/domains vs current Vercel state - VercelDeployer: applies a VercelChange in safe order (env → domains → optional API-triggered deploy) - mapping.ts: namespace -> Vercel environment, env-var diff, domain diff - vercel() helper for tagging an app: platform: vercel({ projectId, ... }) Design doc (docs/guide/vercel.md): - Conceptual mapping between tsops and Vercel concepts - Two deploy sources (git-driven default vs API-triggered) - Required core changes to make adapters opt-in and add a platform discriminator on AppDefinition - Open questions (cross-platform service discovery, overlays on Vercel, secret value handling, drift detection) - Effort estimate: ~1 week for v0.1 Example (examples/hybrid-vercel-k8s): - Frontend on Vercel, API on Kubernetes, one tsops.config.ts - Demonstrates the actual reason for the integration: typed config.url('api', 'ingress') from Vercel-hosted code The package compiles cleanly (only pre-existing workspace vitest-types issue remains, unrelated to this change). VercelApi methods all throw "not implemented yet" so wiring can be exercised before HTTP is filled in. --- docs/.vitepress/config.ts | 7 + docs/guide/vercel.md | 129 ++++++++++++++++++ examples/hybrid-vercel-k8s/README.md | 42 ++++++ examples/hybrid-vercel-k8s/tsops.config.ts | 91 +++++++++++++ packages/vercel/README.md | 65 +++++++++ packages/vercel/package.json | 47 +++++++ packages/vercel/src/adapters/api.ts | 146 +++++++++++++++++++++ packages/vercel/src/index.ts | 70 ++++++++++ packages/vercel/src/mapping.ts | 77 +++++++++++ packages/vercel/src/operations/deployer.ts | 86 ++++++++++++ packages/vercel/src/operations/planner.ts | 102 ++++++++++++++ packages/vercel/src/ports/vercel.ts | 91 +++++++++++++ packages/vercel/src/types.ts | 113 ++++++++++++++++ packages/vercel/tsconfig.json | 12 ++ pnpm-lock.yaml | 6 + tsconfig.json | 1 + 16 files changed, 1085 insertions(+) create mode 100644 docs/guide/vercel.md create mode 100644 examples/hybrid-vercel-k8s/README.md create mode 100644 examples/hybrid-vercel-k8s/tsops.config.ts create mode 100644 packages/vercel/README.md create mode 100644 packages/vercel/package.json create mode 100644 packages/vercel/src/adapters/api.ts create mode 100644 packages/vercel/src/index.ts create mode 100644 packages/vercel/src/mapping.ts create mode 100644 packages/vercel/src/operations/deployer.ts create mode 100644 packages/vercel/src/operations/planner.ts create mode 100644 packages/vercel/src/ports/vercel.ts create mode 100644 packages/vercel/src/types.ts create mode 100644 packages/vercel/tsconfig.json diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index ea44ff9..6a1b3f4 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -65,6 +65,13 @@ export default defineConfig({ { text: 'Multi-Environment', link: '/guide/multi-environment' }, { text: 'Monorepo Setup', link: '/guide/monorepo' }, { text: 'CI/CD Integration', link: '/guide/cicd' }, + { text: 'Preview Overlays', link: '/guide/preview-overlays' }, + ] + }, + { + text: 'Platforms', + items: [ + { text: 'Vercel (skeleton)', link: '/guide/vercel' }, ] } ], diff --git a/docs/guide/vercel.md b/docs/guide/vercel.md new file mode 100644 index 0000000..45cf3b7 --- /dev/null +++ b/docs/guide/vercel.md @@ -0,0 +1,129 @@ +# Vercel adapter + +> **Status: skeleton.** Package scaffold and types are in place at `packages/vercel`. The REST adapter throws stubs and the orchestrator integration is not wired through the existing `tsops` CLI yet. This page is the design doc for finishing the work. + +## Why a Vercel adapter at all + +tsops's value proposition is a typed operational model — one `tsops.config.ts` consumed by both the manifest builder and the application code. That value compounds when the same config covers a hybrid topology: + +``` +┌──────────────────┐ ┌──────────────────┐ +│ frontend (web) │ │ api │ +│ platform: vercel │ ── config.url('api', 'ingress') ─▶│ kubernetes │ +└──────────────────┘ └──────────────────┘ +``` + +Renaming the `api` app, changing its port, or moving it across namespaces is a compile error in every Vercel-hosted caller. Without this, the frontend ends up with `BACKEND_URL` strings in `vercel env` that drift independently of the cluster. + +## Conceptual mapping + +Vercel and Kubernetes are not the same shape. The mapping below is the contract that the rest of the design follows. + +| tsops concept | Vercel concept | Notes | +|---------------------------|----------------------------------------------|----------------------------------------------------------------| +| `namespace.production` | `target: 'production'` | Configurable via `vercel({ environment })` | +| any other namespace | `target: 'preview'` | All overlay namespaces (`pr-*`) collapse here by default | +| `app.ingress.domain` | Project domain attachment | TLS provisioned by Vercel | +| `app.env` | Project env vars (per-environment bucket) | Plain values | +| `app.secrets` | Encrypted env vars (`type: 'encrypted'`) | tsops still validates placeholders before sending | +| `app.build` | _Skipped_ | Vercel builds; we don't build images | +| `app.ports` | _Ignored_ | Vercel handles routing | +| `tsops plan` | Diff env, domains, project settings | Same diff-first UX as the kubectl path | +| `tsops deploy` | Sync settings (and optionally trigger a deploy) | Two modes — see below | + +## Two deploy sources + +The single biggest design choice is whether tsops triggers Vercel deployments or just syncs surrounding state. + +### `deploySource: 'git'` (default) + +```ts +platform: vercel({ projectId: 'prj_abc', deploySource: 'git' }) +``` + +Vercel's git integration owns the build trigger. `tsops deploy` only: +1. Applies env-var deltas (so the next deploy picks them up). +2. Attaches/detaches domains. + +**Pros:** keeps Vercel's idiomatic flow (PR previews, comments, instant rollback). Zero CI changes. + +**Cons:** `tsops plan` cannot block the deploy itself — only the env state. If a developer pushes a broken commit, Vercel deploys it; tsops only catches drift on the next `plan`. + +### `deploySource: 'api'` + +```ts +platform: vercel({ projectId: 'prj_abc', deploySource: 'api' }) +``` + +`tsops deploy` calls `POST /v13/deployments` with either a git ref or a pre-built tarball. Builds happen on Vercel; tsops gates the trigger. + +**Pros:** `tsops plan` validation runs before any deploy. Same atomic-deploy story as Kubernetes. + +**Cons:** Lose Vercel's git-integration features (PR comments, automatic previews per branch). You're now responsible for branch ↔ env mapping in CI. + +**Recommended default:** `'git'` for product apps, `'api'` for monorepos where multiple changes need to ship together. + +## Architecture + +``` +packages/vercel/ +├── src/ +│ ├── index.ts # vercel() helper, public re-exports +│ ├── types.ts # VercelPlatformOptions, VercelChange, ... +│ ├── mapping.ts # namespace → environment, diffs +│ ├── ports/vercel.ts # VercelClient port +│ ├── adapters/api.ts # REST adapter (stubs) +│ └── operations/ +│ ├── planner.ts # diff desired vs current +│ └── deployer.ts # apply a VercelChange in order +``` + +This mirrors `@tsops/core` + `@tsops/node`: a port (`VercelClient`) and an adapter (`VercelApi`), so consumers can swap the implementation or stub it in tests. + +## Integration with the core orchestrator + +The current `TsOps` constructor in `@tsops/core` requires both `docker` and `kubectl` adapters: + +```ts +// packages/core/src/tsops.ts +constructor(config: TConfig, options: TsOpsOptions) { + if (!options || !options.docker || !options.kubectl) { + throw new Error('TsOps requires docker and kubectl adapters. ...') + } +} +``` + +For a clean hybrid story, this needs to change to: + +1. **Make adapters opt-in.** An app declaring `platform: vercel(...)` doesn't need Docker or kubectl. An app without a `platform` field defaults to Kubernetes (current behaviour). Both adapters become optional, validated only when at least one app demands them. + +2. **Add a `platform` discriminator on `AppDefinition`.** With a tagged union (`{ kind: 'kubernetes' | 'vercel' | ... }`), the `Builder`, `Planner`, and `Deployer` route per-app to the correct backend. The k8s flow stays unchanged for apps without `platform`. + +3. **Aggregate per-platform plan output.** `planWithChanges` already groups by app — adding a `platform` field per entry is non-breaking. The CLI renderer can then label each app's changes (`api @ prod (kubernetes)`, `web @ prod (vercel)`). + +The skeleton intentionally avoids these changes for now and ships a parallel mini-orchestrator (`VercelPlanner` + `VercelDeployer`) so the package can be exercised in isolation. + +## Open questions + +- **Cross-platform service discovery.** `config.url('api', 'service')` only makes sense for in-cluster traffic. For Vercel→k8s, callers should use `config.url('api', 'ingress')`. Should we make `service` throw a typed error when called from a Vercel-hosted app, or silently fall back to `ingress`? +- **Preview overlays.** A `pr-857` overlay in tsops creates a real namespace in k8s. On Vercel, every PR already gets a preview deployment automatically. Mapping is probably "Vercel apps ignore overlays; k8s apps materialise them" — but tsops needs to keep `config.url(...)` resolution coherent across both. +- **Secret values.** Currently the planner expects pre-resolved string env. The k8s path keeps `SecretRef` / `ConfigMapRef` markers up to manifest time so secrets never leave the operator's machine. For Vercel, we have to resolve the value (POST to API). That's a different security posture and worth documenting per-app. +- **Drift detection on Vercel side.** Vercel projects can be edited via the dashboard. Should `tsops plan` flag dashboard-edited values as orphans, or treat the dashboard as authoritative? K8s answer is "tsops/managed=true label". Vercel has no equivalent; closest is `comment` field on env vars. + +## Effort estimate + +For a v0.1 that covers the 80% case (`deploySource: 'git'`, env + domain sync, dry-run support): + +- REST adapter: 2–3 days +- Core change for opt-in adapters + platform discriminator: 1–2 days +- CLI dispatch + output formatting: 1 day +- Tests against a recorded Vercel API fixture: 1–2 days +- Docs + examples: 1 day + +Roughly a week of focused work. `deploySource: 'api'` and full preview-overlay coherence add another week. + +## Related + +- Skeleton: `packages/vercel/` +- Hybrid example: `examples/hybrid-vercel-k8s/` +- Architecture overview: [`ARCHITECTURE.md`](../../ARCHITECTURE.md) diff --git a/examples/hybrid-vercel-k8s/README.md b/examples/hybrid-vercel-k8s/README.md new file mode 100644 index 0000000..7c26a34 --- /dev/null +++ b/examples/hybrid-vercel-k8s/README.md @@ -0,0 +1,42 @@ +# Hybrid Vercel + Kubernetes example + +Frontend on Vercel, API on Kubernetes — described in one `tsops.config.ts`. + +``` +┌──────────────────┐ ┌──────────────────┐ +│ web (Vercel) │ ───────────────▶ │ api (k8s) │ +│ Next.js / etc │ typed URL │ Dockerfile │ +└──────────────────┘ └──────────────────┘ +``` + +## What this demonstrates + +- **One typed config covers both platforms.** `web` uses `platform: vercel(...)`; `api` uses the default Kubernetes flow. +- **Cross-platform service discovery is type-safe.** The Next.js frontend on Vercel imports the same `tsops.config.ts` and calls `config.url('api', 'ingress')`. Renaming `api` is a compile error in `web`. +- **Per-platform deploy semantics.** `tsops plan` produces two sections — Vercel env-var/domain diffs for `web`, kubectl resource diffs for `api`. `tsops deploy` dispatches to the right backend per app. + +## Status + +This example targets the **finished** integration of `@tsops/vercel`. Today: + +- `api @ prod` works end-to-end — that's the standard tsops k8s flow. +- `web @ prod` requires the in-progress Vercel orchestrator and REST adapter implementation. See [`docs/guide/vercel.md`](../../docs/guide/vercel.md) for the integration plan and effort estimate. + +## Running (when ready) + +```bash +# Validate everything that can be validated today +pnpm tsops plan --namespace prod + +# Deploy only the k8s app +pnpm tsops deploy --namespace prod --app api + +# Deploy only the Vercel app (once the adapter is implemented) +VERCEL_TOKEN=... pnpm tsops deploy --namespace prod --app web +``` + +## Files + +- `tsops.config.ts` — the hybrid configuration. +- `apps/web/` — would contain the Next.js project (not included in this skeleton). +- `apps/api/` — would contain the API service Dockerfile (not included in this skeleton). diff --git a/examples/hybrid-vercel-k8s/tsops.config.ts b/examples/hybrid-vercel-k8s/tsops.config.ts new file mode 100644 index 0000000..23e3000 --- /dev/null +++ b/examples/hybrid-vercel-k8s/tsops.config.ts @@ -0,0 +1,91 @@ +/** + * Hybrid Vercel + Kubernetes example. + * + * This config runs the frontend on Vercel and the API on Kubernetes. + * Both are described in the same `tsops.config.ts`, and the frontend + * imports `config.url('api', 'ingress')` to resolve the backend's URL — + * so renaming the backend or moving it across namespaces is a compile + * error in the Vercel-hosted code. + * + * NOTE: `@tsops/vercel` is currently a skeleton — see + * `docs/guide/vercel.md`. The shape below is the target API once the + * adapter and core integration are finished. A `tsops plan` against + * this file will work for the `api` app today; the `web` app requires + * the in-progress Vercel orchestrator integration. + */ + +import { defineConfig } from 'tsops' +import { vercel } from '@tsops/vercel' + +const config = defineConfig({ + project: 'orchard', + + namespaces: { + dev: { domain: 'dev.example.com', production: false }, + prod: { domain: 'example.com', production: true } + }, + + clusters: { + platform: { + apiServer: 'https://k8s.example.com', + context: 'prod', + namespaces: ['dev', 'prod'] + } + }, + + images: { + registry: 'ghcr.io/example', + tagStrategy: 'git-sha', + includeProjectInName: true + }, + + secrets: { + 'api-secrets': ({ production }) => ({ + JWT_SECRET: production ? process.env.JWT_SECRET ?? '' : 'dev-secret' + }), + 'web-secrets': ({ production }) => ({ + SENTRY_DSN: production ? process.env.SENTRY_DSN ?? '' : '' + }) + }, + + apps: { + web: { + // Vercel-hosted frontend. No Dockerfile, no Kubernetes Service — + // tsops just syncs project settings, env vars, and domain attachments. + platform: vercel({ + projectId: 'prj_orchard_web', + teamId: 'team_orchard', + deploySource: 'git' + }), + + ingress: ({ domain }) => ({ domain: `app.${domain}` }), + + env: ({ secret }) => ({ + SENTRY_DSN: secret('web-secrets', 'SENTRY_DSN'), + // Build-time URL for the API — resolves to the k8s ingress. + // This is the whole reason for the typed config: when `api` is + // renamed, this line is a compile error. + NEXT_PUBLIC_API_URL: 'https://api.example.com' + }) + }, + + api: { + // Kubernetes-hosted backend. Standard tsops shape. + build: { + type: 'dockerfile', + context: './apps/api', + dockerfile: './apps/api/Dockerfile' + }, + + ingress: ({ domain }) => ({ domain: `api.${domain}` }), + ports: [{ name: 'http', port: 80, targetPort: 8080 }], + + env: ({ production, secret }) => ({ + NODE_ENV: production ? 'production' : 'development', + JWT_SECRET: secret('api-secrets', 'JWT_SECRET') + }) + } + } +}) + +export default config diff --git a/packages/vercel/README.md b/packages/vercel/README.md new file mode 100644 index 0000000..6f0946f --- /dev/null +++ b/packages/vercel/README.md @@ -0,0 +1,65 @@ +# @tsops/vercel + +> **Status: skeleton.** Port surface and orchestration are in place; the REST adapter throws stubs. + +Vercel platform adapter for tsops. Lets you describe a Vercel-deployed app in the same `tsops.config.ts` you use for Kubernetes, and lets your application code import the same typed config (URLs, env, secrets) regardless of where each app actually runs. + +## Why + +tsops's value is a typed operational model — one `tsops.config.ts` consumed by both the manifest builder and your application code. That value compounds when the same config covers a hybrid topology: + +- **frontend** on Vercel (`platform: vercel({ projectId })`) +- **backend** on Kubernetes (default) + +`config.url('api', 'ingress')` on the frontend resolves to the k8s ingress URL; renaming the backend or moving it to a different namespace is a compile error in every Vercel-hosted caller. + +## Mapping + +| tsops concept | Vercel concept | +|--------------------------|------------------------------------------| +| `namespace.production` | `target: 'production'` | +| any other namespace | `target: 'preview'` (configurable) | +| `app.ingress.domain` | Project domain attachment | +| `app.env` | Project env vars (per-environment bucket)| +| `app.secrets` | Encrypted env vars | +| `app.build` | Skipped — Vercel builds | +| `app.ports` | Ignored — Vercel handles routing | +| `tsops plan` | Diff env, domains, project settings | +| `tsops deploy` | Sync settings (+ trigger deploy if API mode) | + +## Two deploy sources + +`vercel({ projectId, deploySource: 'git' })` (default): +- Vercel pulls from your connected git repo on push. +- `tsops deploy` only syncs env vars, domains, and project settings. +- Builds happen outside tsops. + +`vercel({ projectId, deploySource: 'api' })`: +- `tsops deploy` triggers `POST /v13/deployments`. +- Useful when you want `tsops plan` validation to gate the deploy, or to ship from non-git sources. + +## What's in the skeleton + +``` +src/ +├── index.ts # public API + vercel() helper +├── types.ts # VercelPlatformOptions, VercelChange, ... +├── mapping.ts # namespace → environment, env/domain diff +├── ports/vercel.ts # VercelClient port (DI surface) +├── adapters/api.ts # REST adapter — stubs that log + throw +└── operations/ + ├── planner.ts # diff desired vs current state + └── deployer.ts # apply a VercelChange in correct order +``` + +## What's not done + +- `VercelApi` HTTP calls — every method throws "not implemented yet". Filling them in is mostly mechanical (Vercel REST API + `fetch`). +- Wiring into `@tsops/core`'s `TsOps` orchestrator — currently a parallel mini-orchestrator. See `docs/guide/vercel.md` for the proposed core change. +- CLI plumbing — `tsops plan` / `tsops deploy` need to dispatch Vercel-platform apps to this package instead of kubectl. +- Build integration — for `deploySource: 'api'`, we need either a tarball builder or git-ref resolution. +- Secret value mapping — currently treats all env values as strings; `SecretRef` / `ConfigMapRef` resolution still needs to be plugged through. + +## License + +MIT diff --git a/packages/vercel/package.json b/packages/vercel/package.json new file mode 100644 index 0000000..735a2e8 --- /dev/null +++ b/packages/vercel/package.json @@ -0,0 +1,47 @@ +{ + "name": "@tsops/vercel", + "version": "0.0.0", + "description": "Vercel platform adapter for tsops (skeleton)", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -b .", + "dev": "tsc -b --watch", + "clean": "rm -rf dist", + "lint": "pnpm -w exec eslint ." + }, + "dependencies": { + "@tsops/core": "workspace:*" + }, + "keywords": [ + "tsops", + "vercel", + "deployment", + "typescript" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/Pom4H/tsops.git", + "directory": "packages/vercel" + }, + "license": "MIT", + "author": "Roman Popov", + "engines": { + "node": ">=18.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true + } +} diff --git a/packages/vercel/src/adapters/api.ts b/packages/vercel/src/adapters/api.ts new file mode 100644 index 0000000..0611ef2 --- /dev/null +++ b/packages/vercel/src/adapters/api.ts @@ -0,0 +1,146 @@ +import type { Logger } from '@tsops/core' +import type { + TriggerDeploymentOptions, + VercelClient, + VercelProject +} from '../ports/vercel.js' +import type { VercelEnvVar, VercelEnvironment } from '../types.js' + +export interface VercelApiOptions { + /** Vercel personal or team token. Defaults to `process.env.VERCEL_TOKEN`. */ + token?: string + /** Default team ID; per-call `teamId` overrides this. */ + teamId?: string + logger: Logger + /** When true, log API calls but don't execute. Mirrors `dryRun` in node adapters. */ + dryRun?: boolean + /** Override base URL for testing against a mock server. */ + baseUrl?: string +} + +/** + * REST API adapter for Vercel. + * + * Skeleton — all methods log + throw so that the orchestrator wiring, + * planner, and CLI can be exercised end-to-end while real HTTP calls + * are filled in incrementally. + * + * Implementation notes for whoever picks this up: + * - Vercel API base: `https://api.vercel.com` + * - Auth: `Authorization: Bearer ${token}` + * - Team scoping: `?teamId=...` query param on every request + * - Env vars: `GET/POST /v9/projects/:id/env`, `DELETE /v9/projects/:id/env/:envId` + * - Domains: `GET/POST/DELETE /v9/projects/:id/domains` + * - Deployments: `POST /v13/deployments` + * - Rate limits: 429 + `Retry-After`. Worth a small backoff helper. + */ +export class VercelApi implements VercelClient { + private readonly token: string + private readonly defaultTeamId?: string + private readonly logger: Logger + private readonly dryRun: boolean + private readonly baseUrl: string + + constructor(options: VercelApiOptions) { + const token = options.token ?? process.env.VERCEL_TOKEN + if (!token) { + throw new Error( + 'VercelApi requires a token. Set VERCEL_TOKEN or pass `token` explicitly.' + ) + } + this.token = token + this.defaultTeamId = options.teamId + this.logger = options.logger + this.dryRun = options.dryRun ?? false + this.baseUrl = options.baseUrl ?? 'https://api.vercel.com' + } + + async getProject(projectId: string, teamId?: string): Promise { + this.logger.debug('vercel.getProject', { projectId, teamId: teamId ?? this.defaultTeamId }) + throw new Error('VercelApi.getProject: not implemented yet') + } + + async listEnvVars(projectId: string, teamId?: string): Promise { + this.logger.debug('vercel.listEnvVars', { projectId, teamId: teamId ?? this.defaultTeamId }) + throw new Error('VercelApi.listEnvVars: not implemented yet') + } + + async upsertEnvVars( + projectId: string, + envVars: VercelEnvVar[], + teamId?: string + ): Promise { + this.logger.info('vercel.upsertEnvVars', { + projectId, + teamId: teamId ?? this.defaultTeamId, + count: envVars.length + }) + if (this.dryRun) return + throw new Error('VercelApi.upsertEnvVars: not implemented yet') + } + + async removeEnvVars( + projectId: string, + keys: string[], + target: VercelEnvironment[], + teamId?: string + ): Promise { + this.logger.info('vercel.removeEnvVars', { + projectId, + teamId: teamId ?? this.defaultTeamId, + keys, + target + }) + if (this.dryRun) return + throw new Error('VercelApi.removeEnvVars: not implemented yet') + } + + async listDomains(projectId: string, teamId?: string): Promise { + this.logger.debug('vercel.listDomains', { projectId, teamId: teamId ?? this.defaultTeamId }) + throw new Error('VercelApi.listDomains: not implemented yet') + } + + async attachDomain(projectId: string, domain: string, teamId?: string): Promise { + this.logger.info('vercel.attachDomain', { + projectId, + teamId: teamId ?? this.defaultTeamId, + domain + }) + if (this.dryRun) return + throw new Error('VercelApi.attachDomain: not implemented yet') + } + + async detachDomain(projectId: string, domain: string, teamId?: string): Promise { + this.logger.info('vercel.detachDomain', { + projectId, + teamId: teamId ?? this.defaultTeamId, + domain + }) + if (this.dryRun) return + throw new Error('VercelApi.detachDomain: not implemented yet') + } + + async triggerDeployment( + projectId: string, + options: TriggerDeploymentOptions, + teamId?: string + ): Promise<{ url: string; id: string }> { + this.logger.info('vercel.triggerDeployment', { + projectId, + teamId: teamId ?? this.defaultTeamId, + target: options.target, + gitRef: options.gitRef, + tarball: options.tarball ? '' : undefined + }) + if (this.dryRun) { + return { url: 'https://example.vercel.app', id: 'dry-run' } + } + throw new Error('VercelApi.triggerDeployment: not implemented yet') + } + + /** + * Internal HTTP helper. To be filled in when the methods above are + * implemented. Kept private so the port surface stays minimal. + */ + // private async request(path: string, init?: RequestInit): Promise { ... } +} diff --git a/packages/vercel/src/index.ts b/packages/vercel/src/index.ts new file mode 100644 index 0000000..3b10274 --- /dev/null +++ b/packages/vercel/src/index.ts @@ -0,0 +1,70 @@ +/** + * `@tsops/vercel` — Vercel platform adapter for tsops. + * + * Status: skeleton. The port surface, types, mapping, planner, and + * deployer are wired together; the API adapter throws stubs and needs + * filling in. See `docs/guide/vercel.md` for the integration plan. + * + * Usage shape (target): + * + * ```ts + * import { defineConfig } from 'tsops' + * import { vercel } from '@tsops/vercel' + * + * export default defineConfig({ + * apps: { + * web: { + * platform: vercel({ projectId: 'prj_abc', deploySource: 'git' }), + * ingress: ({ domain }) => ({ domain: `app.${domain}` }), + * env: ({ secret }) => ({ + * SENTRY_DSN: secret('web', 'SENTRY_DSN') + * }) + * }, + * api: { + * // Stays on Kubernetes — hybrid topology + * build: { type: 'dockerfile', context: './api', dockerfile: './api/Dockerfile' }, + * ports: [{ name: 'http', port: 80, targetPort: 8080 }] + * } + * } + * }) + * ``` + * + * The `platform: vercel({...})` marker is what routes the app away from + * the kubectl path and into this package at build/deploy time. + */ + +export type { + VercelChange, + VercelDeployResult, + VercelDeploySource, + VercelEnvVar, + VercelEnvironment, + VercelPlatformOptions +} from './types.js' + +export type { + TriggerDeploymentOptions, + VercelClient, + VercelProject +} from './ports/vercel.js' + +export { VercelApi, type VercelApiOptions } from './adapters/api.js' +export { diffDomains, diffEnvVars, resolveEnvironment } from './mapping.js' +export { VercelPlanner, type PlannableApp, type VercelPlannerOptions } from './operations/planner.js' +export { VercelDeployer, type VercelDeployerOptions } from './operations/deployer.js' + +import type { VercelPlatformOptions } from './types.js' + +/** + * Tag an app as a Vercel deployment target. Returns a `VercelPlatformOptions` + * literal that the orchestrator inspects to route the app away from the + * kubectl path. + * + * The `kind: 'vercel'` discriminator is what the orchestrator switches on, + * so a future `aws()`, `flyio()`, or `cloudrun()` helper can coexist. + */ +export function vercel( + options: Omit +): VercelPlatformOptions { + return { kind: 'vercel', ...options } +} diff --git a/packages/vercel/src/mapping.ts b/packages/vercel/src/mapping.ts new file mode 100644 index 0000000..fb31282 --- /dev/null +++ b/packages/vercel/src/mapping.ts @@ -0,0 +1,77 @@ +import type { VercelEnvironment, VercelPlatformOptions } from './types.js' + +/** + * Resolve a tsops namespace to a Vercel environment bucket. + * + * Default policy: + * - namespaces flagged `production: true` → `'production'` + * - everything else → `'preview'` + * + * Apps can override via `VercelPlatformOptions.environment`. This is the + * one place where the "namespace ↔ environment" mapping lives — keep + * everything else downstream of this function. + */ +export function resolveEnvironment( + options: VercelPlatformOptions, + ctx: { namespace: string; production: boolean } +): VercelEnvironment { + if (typeof options.environment === 'function') { + return options.environment(ctx) + } + if (options.environment) { + return options.environment + } + return ctx.production ? 'production' : 'preview' +} + +/** + * Diff two flat env-var maps. Returns the operations needed to go from + * `current` to `desired`. Used by the planner; no side effects. + * + * Vercel itself supports per-bucket targeting, but the diff input here is + * already scoped to a single environment bucket — the caller resolves the + * bucket via `resolveEnvironment` before calling this. + */ +export function diffEnvVars( + desired: Record, + current: Record +): { + add: Array<[string, string]> + update: Array<[string, string]> + remove: string[] +} { + const add: Array<[string, string]> = [] + const update: Array<[string, string]> = [] + const remove: string[] = [] + + for (const [key, value] of Object.entries(desired)) { + if (!(key in current)) { + add.push([key, value]) + } else if (current[key] !== value) { + update.push([key, value]) + } + } + + for (const key of Object.keys(current)) { + if (!(key in desired)) { + remove.push(key) + } + } + + return { add, update, remove } +} + +/** + * Diff two domain sets. Order-independent. + */ +export function diffDomains( + desired: readonly string[], + current: readonly string[] +): { attach: string[]; detach: string[] } { + const desiredSet = new Set(desired) + const currentSet = new Set(current) + return { + attach: [...desiredSet].filter((d) => !currentSet.has(d)), + detach: [...currentSet].filter((d) => !desiredSet.has(d)) + } +} diff --git a/packages/vercel/src/operations/deployer.ts b/packages/vercel/src/operations/deployer.ts new file mode 100644 index 0000000..cb236a8 --- /dev/null +++ b/packages/vercel/src/operations/deployer.ts @@ -0,0 +1,86 @@ +import type { Logger } from '@tsops/core' +import type { VercelClient } from '../ports/vercel.js' +import type { VercelChange, VercelDeployResult } from '../types.js' + +export interface VercelDeployerOptions { + vercel: VercelClient + logger: Logger +} + +/** + * Apply a `VercelChange` produced by the planner. + * + * Order matters: + * 1. Apply env-var add/update/remove (deployment will pick up new values). + * 2. Attach/detach domains (so the deployment surfaces on the right hostnames). + * 3. If `willDeploy`, trigger a deployment via the API (only for + * `deploySource: 'api'`; `'git'` deploys are triggered by Vercel). + * + * Failures abort the remaining steps for the current app — partial state + * is reported so the caller can decide whether to retry. + */ +export class VercelDeployer { + private readonly vercel: VercelClient + private readonly logger: Logger + + constructor(options: VercelDeployerOptions) { + this.vercel = options.vercel + this.logger = options.logger + } + + async apply( + change: VercelChange, + options: { teamId?: string; gitRef?: string } = {} + ): Promise { + const { teamId } = options + const { projectId, environment } = change + + const upserts = [...change.envVars.add, ...change.envVars.update] + if (upserts.length > 0) { + await this.vercel.upsertEnvVars(projectId, upserts, teamId) + } + if (change.envVars.remove.length > 0) { + await this.vercel.removeEnvVars( + projectId, + change.envVars.remove, + [environment], + teamId + ) + } + + for (const domain of change.domains.attach) { + await this.vercel.attachDomain(projectId, domain, teamId) + } + for (const domain of change.domains.detach) { + await this.vercel.detachDomain(projectId, domain, teamId) + } + + let deploymentUrl: string | undefined + if (change.willDeploy) { + const result = await this.vercel.triggerDeployment( + projectId, + { target: environment, gitRef: options.gitRef }, + teamId + ) + deploymentUrl = result.url + } + + this.logger.info('vercel.apply complete', { + app: change.app, + environment, + appliedEnvVars: upserts.length, + removedEnvVars: change.envVars.remove.length, + attachedDomains: change.domains.attach.length, + detachedDomains: change.domains.detach.length, + deploymentUrl + }) + + return { + app: change.app, + environment, + deploymentUrl, + appliedEnvVars: upserts.length, + attachedDomains: change.domains.attach + } + } +} diff --git a/packages/vercel/src/operations/planner.ts b/packages/vercel/src/operations/planner.ts new file mode 100644 index 0000000..18e124f --- /dev/null +++ b/packages/vercel/src/operations/planner.ts @@ -0,0 +1,102 @@ +import type { Logger } from '@tsops/core' +import { diffDomains, diffEnvVars, resolveEnvironment } from '../mapping.js' +import type { VercelClient } from '../ports/vercel.js' +import type { + VercelChange, + VercelEnvVar, + VercelPlatformOptions +} from '../types.js' + +export interface VercelPlannerOptions { + vercel: VercelClient + logger: Logger +} + +/** + * Input for a single app/namespace plan call. The orchestrator (parallel + * to `@tsops/core`'s `Planner`) is responsible for resolving these from + * the user's `tsops.config.ts`. + */ +export interface PlannableApp { + app: string + namespace: string + production: boolean + platform: VercelPlatformOptions + /** Resolved env vars for this app/namespace. */ + env: Record + /** Resolved set of domains the app should serve. */ + domains: string[] + /** Which env keys are sensitive (Vercel `encrypted` type). */ + sensitiveKeys: ReadonlySet +} + +/** + * Diff the desired state of a Vercel app against what's actually + * configured in the project, the same way `@tsops/core`'s planner diffs + * against kubectl. No side effects. + */ +export class VercelPlanner { + private readonly vercel: VercelClient + private readonly logger: Logger + + constructor(options: VercelPlannerOptions) { + this.vercel = options.vercel + this.logger = options.logger + } + + async planApp(input: PlannableApp): Promise { + const environment = resolveEnvironment(input.platform, { + namespace: input.namespace, + production: input.production + }) + + const teamId = input.platform.teamId + const projectId = input.platform.projectId + + const [currentEnv, currentDomains] = await Promise.all([ + this.vercel.listEnvVars(projectId, teamId), + this.vercel.listDomains(projectId, teamId) + ]) + + const currentEnvForBucket = Object.fromEntries( + currentEnv + .filter((v) => v.target.includes(environment)) + .map((v) => [v.key, v.value]) + ) + + const envDiff = diffEnvVars(input.env, currentEnvForBucket) + const domainDiff = diffDomains(input.domains, currentDomains) + + const toVercelEnvVar = ([key, value]: [string, string]): VercelEnvVar => ({ + key, + value, + target: [environment], + type: input.sensitiveKeys.has(key) ? 'encrypted' : 'plain' + }) + + const change: VercelChange = { + app: input.app, + projectId, + environment, + envVars: { + add: envDiff.add.map(toVercelEnvVar), + update: envDiff.update.map(toVercelEnvVar), + remove: envDiff.remove + }, + domains: domainDiff, + willDeploy: input.platform.deploySource === 'api' + } + + this.logger.debug('vercel.planApp', { + app: input.app, + environment, + addCount: change.envVars.add.length, + updateCount: change.envVars.update.length, + removeCount: change.envVars.remove.length, + attachCount: change.domains.attach.length, + detachCount: change.domains.detach.length + }) + + return change + } +} diff --git a/packages/vercel/src/ports/vercel.ts b/packages/vercel/src/ports/vercel.ts new file mode 100644 index 0000000..4459a91 --- /dev/null +++ b/packages/vercel/src/ports/vercel.ts @@ -0,0 +1,91 @@ +import type { VercelEnvVar, VercelEnvironment } from '../types.js' + +/** + * Port for talking to Vercel. Two implementations are planned: + * + * - `VercelApi` — REST API client (the default; needs `VERCEL_TOKEN`). + * - `VercelCli` — wraps the `vercel` CLI. Useful in CI where the token + * is already configured for the CLI. + * + * Keeping this as a port (not a concrete class) follows the same + * dependency-injection pattern as `DockerClient` / `KubectlClient` in + * `@tsops/core`, so consumers can swap implementations or stub them in + * tests. + */ +export interface VercelClient { + /** + * Fetch project metadata. Returns `null` if the project does not exist + * (the planner uses this for "create vs update" decisions). + */ + getProject(projectId: string, teamId?: string): Promise + + /** + * List env vars currently configured for the project. + * The planner diffs against this to produce `VercelChange.envVars`. + */ + listEnvVars(projectId: string, teamId?: string): Promise + + /** + * Idempotently upsert env vars. Vercel's API distinguishes create and + * update; the adapter is expected to hide that. + */ + upsertEnvVars( + projectId: string, + envVars: VercelEnvVar[], + teamId?: string + ): Promise + + /** + * Remove env vars by key for the given environment buckets. + */ + removeEnvVars( + projectId: string, + keys: string[], + target: VercelEnvironment[], + teamId?: string + ): Promise + + /** + * List domains currently attached to the project. + */ + listDomains(projectId: string, teamId?: string): Promise + + /** + * Attach a domain to the project. Vercel will lazily provision TLS. + */ + attachDomain(projectId: string, domain: string, teamId?: string): Promise + + /** + * Detach a domain. Used when the app's ingress changes or an overlay + * is torn down. + */ + detachDomain(projectId: string, domain: string, teamId?: string): Promise + + /** + * Trigger a deployment. Only invoked when `deploySource: 'api'`. + * For `'git'`, deployments are triggered by Vercel's git integration + * and tsops only syncs surrounding state. + */ + triggerDeployment( + projectId: string, + options: TriggerDeploymentOptions, + teamId?: string + ): Promise<{ url: string; id: string }> +} + +export interface VercelProject { + id: string + name: string + framework?: string +} + +export interface TriggerDeploymentOptions { + /** Vercel environment target. */ + target: VercelEnvironment + /** Git ref to deploy (branch, sha, tag). Mutually exclusive with `tarball`. */ + gitRef?: string + /** Pre-built tarball URL. Mutually exclusive with `gitRef`. */ + tarball?: string + /** Optional human-readable description recorded with the deployment. */ + meta?: Record +} diff --git a/packages/vercel/src/types.ts b/packages/vercel/src/types.ts new file mode 100644 index 0000000..6cd61e4 --- /dev/null +++ b/packages/vercel/src/types.ts @@ -0,0 +1,113 @@ +/** + * Vercel-specific types. + * + * These mirror the subset of the Vercel REST API that tsops manages. + * Anything not represented here (analytics, edge config, monitoring, ...) + * is intentionally out of scope — tsops only owns project settings, env + * vars, domains, and deployment triggers. + */ + +/** + * Vercel environment buckets. Maps onto a tsops namespace via + * `VercelPlatformOptions.environment`. + */ +export type VercelEnvironment = 'production' | 'preview' | 'development' + +/** + * Where the source for a Vercel build comes from. + * + * - `git`: Vercel pulls from a connected repo on each push (idiomatic flow). + * tsops `deploy` only syncs project settings/env/domains; builds happen + * outside tsops, triggered by git events. + * - `api`: tsops triggers a deployment via `POST /v13/deployments` with a + * pre-built tarball or git ref. Use this when you need tsops to gate the + * deploy on `tsops plan` validation, or to ship from non-git sources. + */ +export type VercelDeploySource = 'git' | 'api' + +/** + * Per-app Vercel options. Attached to an `AppDefinition` via the + * `platform` field (see `index.ts` for the augmentation). + */ +export interface VercelPlatformOptions { + kind: 'vercel' + + /** Vercel project ID or slug. Required. */ + projectId: string + + /** + * Optional team/scope ID. Required for team-owned projects. + * Equivalent to `--scope` on the Vercel CLI. + */ + teamId?: string + + /** + * Maps the active tsops namespace to a Vercel environment bucket. + * Multiple namespaces can target the same environment (e.g. all + * `pr-*` overlays → `preview`). + * + * @default ({ production }) => production ? 'production' : 'preview' + */ + environment?: VercelEnvironment | ((ctx: { namespace: string; production: boolean }) => VercelEnvironment) + + /** + * Where the build is sourced from. See `VercelDeploySource`. + * @default 'git' + */ + deploySource?: VercelDeploySource + + /** + * Domains to attach to this project for the resolved environment. + * If omitted, tsops uses the value from the app's `ingress` field. + */ + domains?: string[] + + /** + * Framework preset hint passed to Vercel on project creation. + * Optional — Vercel auto-detects in most cases. + */ + framework?: string +} + +/** + * Resolved env var ready to apply to Vercel. + */ +export interface VercelEnvVar { + key: string + value: string + /** Vercel supports per-env-bucket targeting. */ + target: VercelEnvironment[] + /** `encrypted` for secrets, `plain` for non-sensitive values. */ + type: 'encrypted' | 'plain' +} + +/** + * Diff returned by the Vercel planner. + */ +export interface VercelChange { + app: string + projectId: string + environment: VercelEnvironment + envVars: { + add: VercelEnvVar[] + update: VercelEnvVar[] + remove: string[] + } + domains: { + attach: string[] + detach: string[] + } + /** Whether a new deployment will be triggered (deploySource: 'api'). */ + willDeploy: boolean +} + +/** + * Result of `VercelTsOps.deploy()`. + */ +export interface VercelDeployResult { + app: string + environment: VercelEnvironment + deploymentUrl?: string + appliedEnvVars: number + attachedDomains: string[] +} diff --git a/packages/vercel/tsconfig.json b/packages/vercel/tsconfig.json new file mode 100644 index 0000000..dfb4c3d --- /dev/null +++ b/packages/vercel/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "composite": true, + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" + }, + "references": [{ "path": "../core" }], + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4983b71..6f0594a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,12 @@ importers: specifier: workspace:* version: link:../core + packages/vercel: + dependencies: + '@tsops/core': + specifier: workspace:* + version: link:../core + tests: dependencies: '@tsops/core': diff --git a/tsconfig.json b/tsconfig.json index ed5c55e..e0ed224 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,7 @@ { "path": "packages/k8" }, { "path": "packages/core" }, { "path": "packages/node" }, + { "path": "packages/vercel" }, { "path": "packages/cli" } ] } From 82e2315e2b8eb99826aa3cc310c8d5c7f0c38050 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 09:28:55 +0000 Subject: [PATCH 2/2] =?UTF-8?q?docs(rfcs):=20add=20RFC=200001=20=E2=80=94?= =?UTF-8?q?=20platform=20abstraction=20for=20non-k8s=20targets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces an rfcs/ directory and the first RFC, proposing a PlatformAdapter contract on @tsops/core so apps can target backends other than Kubernetes (Vercel first, Fly.io / Cloud Run / etc. later) within the same typed config graph. The RFC frames the change architecturally rather than as "add Vercel": - New PlatformAdapter port with plan/apply/describe methods - Optional platform field on AppDefinition, tagged via { kind: '...' } - TsOps constructor relaxes its docker/kubectl requirement to be conditional on whether any app targets Kubernetes - Backwards-compatible: configs without platform markers behave identically Documents drawbacks honestly (surface-area growth, naming overload, service-discovery typing complexity, drift detection without a state file) and walks through four alternatives that were considered and rejected. Includes implementation phasing — phases 1+2 (contract + Vercel v0.1) are the ~2-week milestone that would unblock the hybrid topology example added in the previous commit. --- rfcs/0000-template.md | 56 ++++ rfcs/0001-platform-abstraction.md | 484 ++++++++++++++++++++++++++++++ rfcs/README.md | 32 ++ 3 files changed, 572 insertions(+) create mode 100644 rfcs/0000-template.md create mode 100644 rfcs/0001-platform-abstraction.md create mode 100644 rfcs/README.md diff --git a/rfcs/0000-template.md b/rfcs/0000-template.md new file mode 100644 index 0000000..467ab2d --- /dev/null +++ b/rfcs/0000-template.md @@ -0,0 +1,56 @@ +# RFC NNNN: Title + +- **Status:** Draft +- **Author(s):** @handle +- **Created:** YYYY-MM-DD +- **PR:** _link to RFC PR once opened_ +- **Implementation tracking:** _issue link, optional_ + +## Summary + +One paragraph. What is changing, in user-visible terms? + +## Motivation + +Why does this need to exist? What problem is it solving that today's tsops +cannot? Use concrete examples — at least one user-visible failure mode that +the change fixes or unlocks. + +## Guide-level explanation + +Explain the proposal as if it were already shipped: how would a user +encounter it, what would the docs look like, what would they type. Keep +this section free of internal-only jargon — if a new tsops user can't +follow it, the proposal isn't ready. + +## Reference-level explanation + +The implementation. Type signatures, file layout, contract changes, +migration path for existing code, edge cases. This section may assume the +reader has read the codebase. + +## Drawbacks + +Why might we _not_ do this? Be specific. Compile-time cost, runtime cost, +maintenance burden, blast radius if it goes wrong, opportunity cost. + +## Rationale and alternatives + +- Why this design over the obvious alternatives? +- What is the cost of doing nothing? +- What did existing tools (Helm, CDK8s, Pulumi, ...) try and where did they + end up? + +## Prior art + +Links to similar designs in other tools. Acknowledge inspirations. + +## Unresolved questions + +Genuine open questions, not rhetorical ones. These are what reviewers +should focus on. + +## Future possibilities + +What this enables that is _out of scope_ for this RFC, but worth +mentioning so reviewers can see the trajectory. diff --git a/rfcs/0001-platform-abstraction.md b/rfcs/0001-platform-abstraction.md new file mode 100644 index 0000000..fdebbc2 --- /dev/null +++ b/rfcs/0001-platform-abstraction.md @@ -0,0 +1,484 @@ +# RFC 0001: Platform abstraction for non-Kubernetes targets + +- **Status:** Draft +- **Author(s):** @pom4h +- **Created:** 2026-05-01 +- **PR:** _to be assigned_ +- **Implementation tracking:** _to be filed_ + +## Summary + +Introduce a `Platform` abstraction so an app declared in `tsops.config.ts` +can target a deployment backend other than Kubernetes — initially Vercel — +while still participating in the same typed config graph (service +discovery, env, secrets, plan/deploy lifecycle). + +The Kubernetes flow becomes the default platform; new platforms are added +as packages that implement a small `PlatformAdapter` contract. Apps opt +into a non-default platform with a `platform: vercel({ ... })` marker. +Apps without the marker continue to behave exactly as today. + +## Motivation + +The single largest piece of value tsops delivers is **one typed config +imported by both the manifest builder and the application code**. That +value compounds when the same config covers a hybrid topology: + +``` +┌──────────────────┐ ┌──────────────────┐ +│ web (Vercel) │ ──────────────▶ │ api (k8s) │ +│ │ typed URL │ │ +└──────────────────┘ └──────────────────┘ +``` + +Today, a team that wants their frontend on Vercel and backend on +Kubernetes has to: + +1. Maintain two configurations (`tsops.config.ts` for k8s + `vercel.json` + + Vercel dashboard env vars). +2. Hardcode the API URL into Vercel env vars (`NEXT_PUBLIC_API_URL`), + typed against nothing. +3. Discover renames and ingress changes at runtime — Vercel deploy + succeeds, frontend gets a 502 against the renamed service. + +The compiler is sitting right there and cannot help, because the +operational model spans two systems. + +There is no Vercel-shaped fix for this — the fix is to extend tsops's +operational model to cover both platforms, and that is what this RFC +proposes. + +## Guide-level explanation + +A user marks an app with the `platform` field to deploy it somewhere +other than Kubernetes: + +```ts +import { defineConfig } from 'tsops' +import { vercel } from '@tsops/vercel' + +export default defineConfig({ + apps: { + web: { + platform: vercel({ + projectId: 'prj_orchard_web', + deploySource: 'git' + }), + ingress: ({ domain }) => ({ domain: `app.${domain}` }), + env: ({ secret }) => ({ + SENTRY_DSN: secret('web-secrets', 'SENTRY_DSN') + }) + }, + + api: { + // No platform marker → defaults to Kubernetes (today's behaviour) + build: { type: 'dockerfile', context: './api', dockerfile: './api/Dockerfile' }, + ports: [{ name: 'http', port: 80, targetPort: 8080 }], + ingress: ({ domain }) => ({ domain: `api.${domain}` }) + } + } +}) +``` + +The application code on Vercel imports the same config it always does: + +```ts +// apps/web/src/api-client.ts +import config from '../../tsops.config' + +const apiUrl = config.url('api', 'ingress') // → https://api.example.com +``` + +`tsops plan` and `tsops deploy` work as before, but their output is +now grouped per platform: + +``` +📋 Plan: orchard @ prod + +▾ Kubernetes + api @ prod (api.example.com) + ➕ Deployment/orchard-api + ➕ Service/orchard-api + ➕ Ingress/orchard-api + +▾ Vercel + web @ prod (project: prj_orchard_web) + ➕ env NEXT_PUBLIC_API_URL=https://api.example.com (target: production) + ➕ env SENTRY_DSN=*** (encrypted, target: production) + ➕ domain app.example.com + +✅ Validation passed. +``` + +A user without any `platform` markers in their config sees no change in +behaviour, output, or required adapters. + +## Reference-level explanation + +### Contract + +A new port lives in `@tsops/core`: + +```ts +// packages/core/src/ports/platform.ts + +export interface PlatformAdapter { + /** Stable identifier — matches the `kind` field on platform options. */ + readonly kind: string + + /** + * Plan changes for one app/namespace. Pure: must not mutate external + * state. Returns a per-platform `Change` shape that the CLI renders + * generically via `describe()`. + */ + plan(input: PlatformPlanInput): Promise + + /** + * Apply a change produced by `plan`. Idempotent — running deploy twice + * with no source changes must be a no-op (modulo deployment triggers). + */ + apply(change: PlatformChange, ctx: PlatformApplyContext): Promise + + /** + * Render a `PlatformChange` as user-facing diff lines. Returns plain + * strings so the CLI doesn't have to know per-platform shapes. + */ + describe(change: PlatformChange): string[] +} + +export interface PlatformPlanInput { + app: string + namespace: string + production: boolean + platform: TOptions + resolvedEnv: Record + resolvedSecrets: Record + ingressDomain?: string +} + +export interface PlatformChange { + kind: string + app: string + summary: { add: number; update: number; remove: number } + payload: unknown // per-platform +} +``` + +### Per-app discriminator + +`AppDefinition` gains an optional `platform` field: + +```ts +// packages/core/src/types.ts + +export interface PlatformOptionsBase { + readonly kind: string +} + +export type AppDefinition<...> = { + // ...existing fields + platform?: PlatformOptionsBase +} +``` + +Concrete platform packages export a tagged factory: + +```ts +// packages/vercel/src/index.ts +export function vercel(options): VercelPlatformOptions { + return { kind: 'vercel', ...options } +} +``` + +The `kind` discriminator is what the orchestrator switches on at +plan/deploy time. + +### Orchestrator changes + +The current `TsOps` constructor unconditionally requires `docker` and +`kubectl`: + +```ts +// today +if (!options || !options.docker || !options.kubectl) { + throw new Error('TsOps requires docker and kubectl adapters. ...') +} +``` + +This becomes: + +```ts +// proposed +constructor(config, options: TsOpsOptions) { + this.platforms = new Map() + + // Default Kubernetes platform — registered only if any app needs it. + const needsK8s = appsWithoutPlatformMarker(config).length > 0 + if (needsK8s) { + if (!options.docker || !options.kubectl) { + throw new Error('Kubernetes apps require docker and kubectl adapters.') + } + this.platforms.set('kubernetes', new KubernetesPlatformAdapter({ ... })) + } + + for (const platform of options.platforms ?? []) { + this.platforms.set(platform.kind, platform) + } + + this.assertPlatformsCoverConfig(config) +} +``` + +`Builder`, `Planner`, and `Deployer` are refactored to dispatch per-app: + +```ts +async planWithChanges(options) { + const apps = this.resolver.resolveApps(options) + const result: PlanResult = { global: ..., apps: [] } + + for (const app of apps) { + const platform = this.platforms.get(app.platform.kind) + if (!platform) { + throw new Error(`No adapter registered for platform: ${app.platform.kind}`) + } + const change = await platform.plan(toPlatformPlanInput(app)) + result.apps.push({ app: app.name, namespace: app.namespace, change }) + } + + return result +} +``` + +The existing per-namespace global resources flow (namespaces, shared +secrets, configMaps) stays scoped to the Kubernetes adapter. + +### `createNodeTsOps` + +Becomes a thin wiring helper: + +```ts +export function createNodeTsOps(config, options = {}) { + const platforms: PlatformAdapter[] = options.platforms ?? [] + // ... existing default docker/kubectl wiring, but conditional on + // whether any app needs them + return new TsOps(config, { docker, kubectl, platforms, ... }) +} +``` + +`@tsops/vercel` consumers register the adapter explicitly: + +```ts +import { createNodeTsOps } from '@tsops/node' +import { VercelApi, VercelPlatformAdapter } from '@tsops/vercel' + +const tsops = createNodeTsOps(config, { + platforms: [ + new VercelPlatformAdapter({ + client: new VercelApi({ token: process.env.VERCEL_TOKEN!, logger }) + }) + ] +}) +``` + +### Cross-platform service discovery + +`config.url('api', 'service')` and `config.url('api', 'cluster')` only +make sense for in-cluster traffic. Behaviour from a Vercel-hosted +caller: + +- `config.url('api', 'ingress')` — works as today, returns the public URL. +- `config.url('api', 'service' | 'cluster')` — at type level, the + `app: TAppNames` parameter is narrowed by the caller's platform; calls + to non-Kubernetes-resolvable forms produce a typed error in the + caller. + +This is enforceable today because runtime helpers are generated per +config; the generator can emit a narrower signature when the caller +has been tagged with a non-Kubernetes platform. Detail of the typing +mechanism is left to implementation. + +### Preview overlays + +Overlay namespaces (`pr-857`) materialise as real Kubernetes namespaces. +Vercel apps in the same config respond differently: + +- For `tsops up preview --var pr=857`, the Vercel adapter creates env-var + entries scoped to `target: 'preview'` and attaches the overlay's + generated subdomain to the Vercel project. +- `tsops down preview` removes those env vars and detaches the subdomain. +- Vercel's own per-PR preview deployments are orthogonal and continue to + happen on git push; tsops only owns the env/domain state attached to + them. + +### Migration + +This is fully backwards-compatible: + +- Configs without `platform` markers behave identically to today. +- The `TsOpsOptions` shape gains an optional `platforms?: + PlatformAdapter[]` field; `docker` and `kubectl` remain required for + configs with at least one Kubernetes app, which today is every config. +- No public types are removed. `kind: 'kubernetes'` is reserved as a + built-in platform identifier. + +A dedicated `kubernetes()` helper is _not_ added; omitting `platform` +remains the canonical way to say "this is a Kubernetes app". This keeps +existing configs untouched. + +## Drawbacks + +1. **Surface area growth.** Every new platform becomes an external + contract. Once `kind: 'vercel'` is shipped, breaking changes to that + shape become user-visible. The same is true for `PlatformAdapter` + itself — adding a method later is a breaking change for adapter + authors. + +2. **Test matrix.** End-to-end tests now need to cover hybrid configs. + Recording a Vercel API fixture takes work and the fixture decays as + Vercel evolves its API. + +3. **The "platform" word is overloaded.** Kubernetes itself is a + platform; a deploy target is a platform; an internal developer + platform is a platform. Naming is hard. Alternatives: `target`, + `runtime`, `backend`. `runtime` already exists in tsops with a + different meaning (`namespace.runtime`); `backend` reads strangely + in code (`backend: vercel(...)`). `platform` is the least bad. + +4. **Cross-platform service discovery typing is non-trivial.** Doing + this without forcing the user to manually annotate every helper call + requires generator changes that have not been prototyped yet. + +5. **Drift detection lacks a state file.** Kubernetes drift is detected + via `tsops/managed=true` labels. Vercel has no equivalent — env vars + edited in the Vercel dashboard are indistinguishable from + tsops-managed ones. The simplest answer is "tsops always wins on + apply" but this surprises users who have edited values in the + dashboard. An RFC for Vercel-side drift policy is a likely follow-up. + +## Rationale and alternatives + +### Alternative 1: keep tsops Kubernetes-only + +Status quo. Users wanting a hybrid topology maintain two configs and +hardcode cross-system URLs. This is the cost of doing nothing. + +This is the right choice _if_ tsops's intended scope is "a typed +Kubernetes deploy tool". This RFC argues that the intended scope is "a +typed operational model for product topology", and product topology +already routinely spans Kubernetes + a SaaS frontend host. + +### Alternative 2: per-app overrides without an abstraction + +Add a `vercel: { ... }` field to `AppDefinition` directly. Cheap, no +contract design needed. + +Rejected because: + +- It pushes Vercel into the core type definitions, even for users who + don't use Vercel. +- The next platform (Fly.io, Cloud Run, ...) re-runs the entire + argument; we end up with `app.vercel`, `app.flyio`, `app.cloudRun` — + a worse version of the abstraction proposed here. + +### Alternative 3: separate top-level configs per platform + +`tsops.k8s.config.ts` + `tsops.vercel.config.ts`. Each tool reads its +own. + +Rejected because it loses the entire reason for the integration: a +single typed graph that the application code imports. With separate +configs, `web` cannot type-check against `api`'s ingress. + +### Alternative 4: external orchestration (CDK8s + Vercel SDK glue) + +Build the typed graph outside tsops in user code, generate manifests +and Vercel API calls separately. + +Rejected because it externalises exactly the integration tsops is built +to provide. Every user reinvents the same wheel. + +## Prior art + +- **Pulumi** has cross-cloud resources via providers, but each app's + state is owned by one provider — no equivalent of "frontend on Vercel + imports the same config as backend on AWS". +- **CDK8s** generates Kubernetes manifests from typed code but does not + expose the result to application code at runtime. +- **SST** (Serverless Stack) has typed resource references that the app + imports; closest in spirit. SST is AWS-only; this RFC generalises the + pattern to multi-platform. +- **Encore** has a typed application graph that compiles to multiple + cloud targets. Different ergonomic choice (annotated code, not a + config file) but the same underlying insight. + +## Unresolved questions + +1. **Adapter loading.** Should the orchestrator auto-detect platform + adapters from `apps.*.platform.kind` and dynamically import the + matching package, or always require explicit registration in + `createNodeTsOps`? Auto-detection is friendlier; explicit is more + honest about dependencies. + +2. **Preview overlay coherence.** A Vercel app in an overlay namespace + creates `target: 'preview'` env vars on the shared Vercel project. + Two simultaneous PRs touching the same Vercel project will overwrite + each other's env vars unless tsops scopes them per overlay. Vercel's + API supports per-deployment env vars but not per-PR env-var + isolation on the project. Open question: is per-overlay isolation + in scope for this RFC, or a follow-up? + +3. **Build artefacts.** For `deploySource: 'api'` on Vercel, tsops needs + either a tarball of the build context or a git ref. Should the + `Builder` operation be generalised to "produce a build artefact" + (image ref _or_ tarball _or_ git ref), or is each platform + responsible for its own build path? + +4. **Service discovery typing.** Outlined above; mechanism not + prototyped. May require a small change to how `defineConfig` infers + the runtime helpers' generic parameters. + +5. **Drift policy on Vercel.** "tsops always wins" vs "warn on + tsops-unknown env vars" vs "explicit `tsops.vercel/managed=true` + tag". No clean answer; needs a follow-up RFC once Vercel users have + real-world experience. + +## Future possibilities + +- **Additional platforms.** Fly.io, Cloud Run, Cloudflare Workers, AWS + Lambda all fit the same `PlatformAdapter` shape. Each one is a + separate package; none requires changes to `@tsops/core` after this + RFC lands. +- **Cross-platform dependency graph.** `app.needs` already declares + inter-app dependencies. Once platforms are pluggable, the planner can + topologically sort across platforms (deploy `api` to k8s before + `web` on Vercel, so the new ingress URL exists by the time Vercel + builds). +- **Edge runtime config helpers.** `config.url(...)` could grow a + `runtime: 'edge'` mode that produces values usable inside Cloudflare + Workers / Vercel Edge Functions, where module resolution is + different. +- **Multi-platform observability.** A `tsops status` command that + queries each platform's API and prints consolidated health is a + natural extension once adapters exist. + +## Implementation phasing + +If accepted, the work splits into independently-shippable phases: + +**Phase 1 — Contract.** Land `PlatformAdapter` + per-app `platform` +field in `@tsops/core`. The Kubernetes flow becomes the default +platform internally but is not yet exposed as a registered adapter. +No user-visible change. + +**Phase 2 — Vercel adapter.** Fill in `VercelApi` HTTP calls, ship +`@tsops/vercel` v0.1 covering `deploySource: 'git'` (env + domain +sync). CLI dispatches per platform. + +**Phase 3 — API-driven Vercel deploys.** `deploySource: 'api'` mode, +build artefact handling, full preview-overlay coherence. + +**Phase 4 — Service-discovery typing.** Type-level enforcement that +Vercel-hosted callers cannot resolve `service`/`cluster` URLs. + +Phases 1 and 2 together are the "answer Russ's hybrid Vercel/k8s +question" milestone — roughly two weeks of focused work. diff --git a/rfcs/README.md b/rfcs/README.md new file mode 100644 index 0000000..074c04e --- /dev/null +++ b/rfcs/README.md @@ -0,0 +1,32 @@ +# tsops RFCs + +Substantive changes to tsops — public API, file formats, the orchestrator +contract, or anything that's hard to roll back — go through an RFC before +implementation lands. + +## Process + +1. Copy `0000-template.md` to `rfcs/NNNN-short-name.md`. +2. Fill in the sections. Be honest about drawbacks and alternatives — an RFC + that only argues one side gets sent back. +3. Open a PR. The PR description should link any related issues. +4. Discussion happens in the PR. Push commits to update the RFC text in + response to review. +5. The RFC is **accepted** when a maintainer merges the PR. Status moves to + `Accepted` and the PR number is recorded in the doc. +6. Implementation tracking happens in a separate issue, not in the RFC text. + +## States + +- **Draft** — open for discussion. +- **Accepted** — merged; implementation may begin. +- **Implemented** — landed in a release; RFC becomes historical reference. +- **Rejected** — closed without merging. Kept in `rfcs/rejected/` if the + discussion is useful for posterity. +- **Superseded by NNNN** — replaced by a later RFC. + +## Index + +| # | Title | Status | +|------|--------------------------------------------|--------| +| 0001 | Platform abstraction for non-Kubernetes targets | Draft |