-
Notifications
You must be signed in to change notification settings - Fork 1
RFC 0001: platform abstraction for non-Kubernetes targets (+ @tsops/vercel skeleton) #52
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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). | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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' | ||||||||||||||||||||||||||
|
Comment on lines
+63
to
+68
|
||||||||||||||||||||||||||
| 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' | |
| env: ({ secret, url }) => ({ | |
| 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: url('api', 'ingress') |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,47 @@ | ||||||
| { | ||||||
| "name": "@tsops/vercel", | ||||||
| "version": "0.0.0", | ||||||
|
||||||
| "version": "0.0.0", | |
| "version": "2.0.0", |
Copilot
AI
May 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Repo convention (see AGENTS.md) is to record public changes via Changesets. Adding a new publishable package (@tsops/vercel) looks like a public surface-area addition, but this PR doesn’t include a .changeset/* entry. Consider adding one (even if the initial release is 0.x) so the release workflow can pick it up intentionally.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The README states the frontend “calls
config.url('api', 'ingress')” and that service discovery is already type-safe, but the checked-in config/example code currently hardcodes the API URL and the Vercel integration is described as incomplete. Consider clarifying this bullet as a target-state (or linking directly to where the type-safe call exists) to avoid implying it works today.