From f59c43da16d705e59b3f85cbf7eeba3fdacda409 Mon Sep 17 00:00:00 2001 From: simpros <38320875+simpros@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:53:47 +0200 Subject: [PATCH 01/10] Preview governance: TTL, idle teardown, caps and connection budget (#241) --- .env.example | 13 + apps/cli/src/commands/deploy-core.ts | 5 + apps/cli/src/commands/deploy-outcome.ts | 9 + apps/cli/src/commands/forge-note.ts | 3 + apps/cli/src/yaml.ts | 29 +- .../migration.sql | 3 + .../snapshot.json | 351 +++++++++++++++++ .../20261002113921_lush_solo/migration.sql | 2 + .../20261002113921_lush_solo/snapshot.json | 371 ++++++++++++++++++ apps/server/src/config.test.ts | 6 + apps/server/src/config.ts | 73 ++++ apps/server/src/http/app.ts | 8 + apps/server/src/http/deploy.ts | 83 ++++ apps/server/src/http/health-deploy.test.ts | 6 +- apps/server/src/http/introspection.test.ts | 3 + apps/server/src/http/none-deploy.test.ts | 3 + .../server/src/http/preview-lifecycle.test.ts | 7 +- apps/server/src/http/routes.ts | 7 +- apps/server/src/index.ts | 3 + apps/server/src/infrastructure/db/schema.ts | 5 + apps/server/src/preview/bring-up.ts | 26 +- apps/server/src/preview/lifecycle.ts | 195 ++++++++- apps/server/src/preview/locks.test.ts | 23 ++ apps/server/src/preview/locks.ts | 33 +- apps/server/src/preview/snapshot.ts | 9 + apps/server/src/preview/types.ts | 20 + apps/server/src/sweep/governance.test.ts | 115 ++++++ apps/server/src/sweep/live-ports.ts | 21 +- apps/server/src/sweep/reconcile.ts | 124 +++++- apps/server/src/sweep/start.ts | 6 + compose.env.example | 13 + docker-compose.yml | 6 + docs/adopting-a-repo.md | 2 + docs/operator-deploy.md | 37 +- docs/previews.md | 33 ++ packages/preview-env/src/governance.test.ts | 91 +++++ packages/preview-env/src/governance.ts | 164 ++++++++ packages/preview-env/src/index.ts | 11 + 38 files changed, 1898 insertions(+), 21 deletions(-) create mode 100644 apps/server/drizzle/20261002113607_acoustic_deathstrike/migration.sql create mode 100644 apps/server/drizzle/20261002113607_acoustic_deathstrike/snapshot.json create mode 100644 apps/server/drizzle/20261002113921_lush_solo/migration.sql create mode 100644 apps/server/drizzle/20261002113921_lush_solo/snapshot.json create mode 100644 apps/server/src/preview/locks.test.ts create mode 100644 apps/server/src/sweep/governance.test.ts create mode 100644 packages/preview-env/src/governance.test.ts create mode 100644 packages/preview-env/src/governance.ts diff --git a/.env.example b/.env.example index cf96f7ec..7adfe728 100644 --- a/.env.example +++ b/.env.example @@ -101,3 +101,16 @@ SPROUT_OTLP_HEADERS= SPROUT_PREVIEW_PORT_DEFAULT=8080 SPROUT_SEED_TIMEOUT=180 SPROUT_PORT=7331 + +# Preview governance (all off by default: unbounded until PR close). +# Durations accept e.g. 30m, 2h, 7d, or off. Caps accept a positive integer or off. +# SPROUT_PREVIEW_TTL=off +# SPROUT_PREVIEW_IDLE_TEARDOWN=off +# SPROUT_MAX_PREVIEWS_PER_REPO=off +# SPROUT_MAX_PREVIEWS=off +# Connection budget: expected per-preview connections vs the instance ceiling. +# Measured: ~12 idle connections per preview app at default pool sizes, so +# ~8 previews fill a 100-slot instance with zero traffic. The figure is only +# accurate for apps that honour the injected cap. +# SPROUT_PREVIEW_MAX_DB_CONNECTIONS=off +# SPROUT_POSTGRES_MAX_CONNECTIONS=100 diff --git a/apps/cli/src/commands/deploy-core.ts b/apps/cli/src/commands/deploy-core.ts index 47c9b40d..438df92e 100644 --- a/apps/cli/src/commands/deploy-core.ts +++ b/apps/cli/src/commands/deploy-core.ts @@ -57,6 +57,8 @@ export type DeployRequest = { mail?: MailSpec; auth?: PreviewAuthSpec; reseed?: boolean; + ttl?: string; + idle_teardown?: string; }; /** A type that cannot carry `services` makes "leave" the default instead of a forgotten field. */ @@ -323,6 +325,9 @@ export function buildDeployRequest( if (yaml.preview.labels) body.labels = { ...yaml.preview.labels }; if (yaml.preview.volumes && yaml.preview.volumes.length > 0) body.volumes = [...yaml.preview.volumes]; + if (yaml.preview.ttl !== undefined) body.ttl = yaml.preview.ttl; + if (yaml.preview.idle_teardown !== undefined) + body.idle_teardown = yaml.preview.idle_teardown; if (yaml.db) body.db = yaml.db; if (yaml.mail) body.mail = { ...yaml.mail }; if (yaml.preview.auth) body.auth = { ...yaml.preview.auth }; diff --git a/apps/cli/src/commands/deploy-outcome.ts b/apps/cli/src/commands/deploy-outcome.ts index 62882e6e..03908fd4 100644 --- a/apps/cli/src/commands/deploy-outcome.ts +++ b/apps/cli/src/commands/deploy-outcome.ts @@ -7,6 +7,8 @@ export type DeploySnapshotFields = { mail_from?: string | null; last_error?: string | null; last_error_detail?: string | null; + expires_at?: string | null; + last_activity_at?: string | null; }; /** Settled deploy presentation shared by core, poll, and forge-note. */ @@ -14,6 +16,7 @@ export type DeploySettled = { previewUrl: string; mailboxUrl?: string; mailFrom?: string; + expiresAt?: string; }; export type DeployOutcome = @@ -46,12 +49,15 @@ export function deployOutcome(data: DeploySnapshotFields): DeployOutcome { typeof data.mailbox_url === "string" ? data.mailbox_url.trim() : ""; const fromRaw = typeof data.mail_from === "string" ? data.mail_from.trim() : ""; + const expiresRaw = + typeof data.expires_at === "string" ? data.expires_at.trim() : ""; return { kind: "ready", settled: { previewUrl: data.preview_url, ...(mailboxRaw !== "" ? { mailboxUrl: mailboxRaw } : {}), ...(fromRaw !== "" ? { mailFrom: fromRaw } : {}), + ...(expiresRaw !== "" ? { expiresAt: expiresRaw } : {}), }, }; } @@ -66,4 +72,7 @@ export function printSettled(io: CliIo, settled: DeploySettled): void { if (settled.mailFrom) { io.stdout(`mail_from=${settled.mailFrom}`); } + if (settled.expiresAt) { + io.stdout(`expires_at=${settled.expiresAt}`); + } } diff --git a/apps/cli/src/commands/forge-note.ts b/apps/cli/src/commands/forge-note.ts index 8403c06c..8f0503a9 100644 --- a/apps/cli/src/commands/forge-note.ts +++ b/apps/cli/src/commands/forge-note.ts @@ -24,6 +24,7 @@ export function buildPreviewNote(input: { previewUrl: string; mailboxUrl?: string; mailFrom?: string; + expiresAt?: string; sha?: string; prId: number; reset?: { actor: string; at: string }; @@ -49,6 +50,7 @@ export function buildPreviewNote(input: { `- Access: gated — reviewers need a link: \`sprout access ${input.prId}\``, ); } + if (input.expiresAt) lines.push(`- Expires: ${input.expiresAt}`); const short = shortSha(input.sha); if (short) lines.push(`- Commit: \`${short}\``); lines.push("- Health: healthy"); @@ -396,6 +398,7 @@ export async function publishPreviewNote( previewUrl: note.previewUrl, ...(note.mailboxUrl ? { mailboxUrl: note.mailboxUrl } : {}), ...(note.mailFrom ? { mailFrom: note.mailFrom } : {}), + ...(note.expiresAt ? { expiresAt: note.expiresAt } : {}), sha: identity.commitSha, prId: identity.prId, ...(reset ? { reset } : {}), diff --git a/apps/cli/src/yaml.ts b/apps/cli/src/yaml.ts index f8f5b87a..d27257df 100644 --- a/apps/cli/src/yaml.ts +++ b/apps/cli/src/yaml.ts @@ -2,6 +2,7 @@ import { authSpecIssueMessage, dbRolesIssueMessage, dbSpecIssueMessage, + governanceIssueMessage, isServicePort, labelIssueMessage, mailSpecIssueMessage, @@ -11,6 +12,7 @@ import { parseMailSpec, parsePreviewAuthSpec, parsePreviewEnvForProvider, + parsePreviewGovernanceField, parsePreviewVolumes, parseServiceEnvMap, previewVolumeIssueMessage, @@ -79,6 +81,8 @@ export type SproutYaml = { labels?: PreviewLabels; volumes?: string[]; auth?: PreviewAuthSpec; + ttl?: string; + idle_teardown?: string; }; db?: DbSpec; mail?: MailSpec; @@ -88,7 +92,7 @@ export type SproutYaml = { }; const TOP_KEYS = new Set(["slug", "preview", "health", "build", "seed", "db", "mail"]); -const PREVIEW_KEYS = new Set(["hostname", "env", "app_env", "services", "labels", "volumes", "auth"]); +const PREVIEW_KEYS = new Set(["hostname", "env", "app_env", "services", "labels", "volumes", "auth", "ttl", "idle_teardown"]); const HEALTH_KEYS = new Set(["path", "interval", "timeout", "expect"]); const SERVICE_KEYS = new Set(["name", "image", "hostname", "path", "port", "env", "labels"]); const DOCKERFILE_KEYS = new Set(["dockerfile"]); @@ -404,6 +408,18 @@ function parsePreviewVolumesField( }; } +function parseGovernanceField( + raw: unknown, + path: "preview.ttl" | "preview.idle_teardown", +): Result { + const kind = path === "preview.ttl" ? "ttl" : "idle_teardown"; + const parsed = parsePreviewGovernanceField(raw, kind); + if (!parsed.ok) { + return { ok: false, error: governanceIssueMessage(path, parsed.issue) }; + } + return { ok: true, value: parsed.value }; +} + function parseServices( raw: unknown, ): Result { @@ -552,6 +568,14 @@ export function parseSproutYaml(raw: string): Result { ); if (!volumes.ok) return volumes; + const ttl = parseGovernanceField(parsed.preview.ttl, "preview.ttl"); + if (!ttl.ok) return ttl; + const idleTeardown = parseGovernanceField( + parsed.preview.idle_teardown, + "preview.idle_teardown", + ); + if (!idleTeardown.ok) return idleTeardown; + const build = parseDockerfileBlock(parsed.build, "build", "Dockerfile"); if (!build.ok) return build; @@ -576,6 +600,9 @@ export function parseSproutYaml(raw: string): Result { if (labels.value) value.preview.labels = labels.value; if (auth.value) value.preview.auth = auth.value; if (volumes.value.length > 0) value.preview.volumes = volumes.value; + if (ttl.value !== undefined) value.preview.ttl = ttl.value; + if (idleTeardown.value !== undefined) + value.preview.idle_teardown = idleTeardown.value; if (db.value) value.db = db.value; if (mail.value) value.mail = mail.value; if (build.value) value.build = build.value; diff --git a/apps/server/drizzle/20261002113607_acoustic_deathstrike/migration.sql b/apps/server/drizzle/20261002113607_acoustic_deathstrike/migration.sql new file mode 100644 index 00000000..c051c53a --- /dev/null +++ b/apps/server/drizzle/20261002113607_acoustic_deathstrike/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE `previews` ADD `last_activity_at` text;--> statement-breakpoint +ALTER TABLE `previews` ADD `expires_at` text;--> statement-breakpoint +ALTER TABLE `previews` ADD `expiry_reason` text; \ No newline at end of file diff --git a/apps/server/drizzle/20261002113607_acoustic_deathstrike/snapshot.json b/apps/server/drizzle/20261002113607_acoustic_deathstrike/snapshot.json new file mode 100644 index 00000000..43d49d53 --- /dev/null +++ b/apps/server/drizzle/20261002113607_acoustic_deathstrike/snapshot.json @@ -0,0 +1,351 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "e0a78feb-0129-4236-bc9c-cd12a06e9748", + "prevIds": [ + "97b2e433-186f-471b-9702-2b49f6488d71" + ], + "ddl": [ + { + "name": "api_tokens", + "entityType": "tables" + }, + { + "name": "previews", + "entityType": "tables" + }, + { + "name": "repos", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_hash", + "entityType": "columns", + "table": "api_tokens" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "scope", + "entityType": "columns", + "table": "api_tokens" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "canonical_repo_id", + "entityType": "columns", + "table": "api_tokens" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "api_tokens" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revoked_at", + "entityType": "columns", + "table": "api_tokens" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "canonical_repo_id", + "entityType": "columns", + "table": "previews" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "pr_id", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "db_name", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'postgres'", + "generated": null, + "name": "db_provider", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "hostname", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "app_image", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "container_id", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seeded_at", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seeded_seed_image", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_error", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_error_detail", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "failure_family", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "bring_up_plan", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seed_log", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "mail_from", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_activity_at", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expiry_reason", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "canonical_id", + "entityType": "columns", + "table": "repos" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "repos" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "repos" + }, + { + "columns": [ + "canonical_repo_id", + "pr_id" + ], + "nameExplicit": false, + "name": "previews_pk", + "entityType": "pks", + "table": "previews" + }, + { + "columns": [ + "token_hash" + ], + "nameExplicit": false, + "name": "api_tokens_pk", + "table": "api_tokens", + "entityType": "pks" + }, + { + "columns": [ + "canonical_id" + ], + "nameExplicit": false, + "name": "repos_pk", + "table": "repos", + "entityType": "pks" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/apps/server/drizzle/20261002113921_lush_solo/migration.sql b/apps/server/drizzle/20261002113921_lush_solo/migration.sql new file mode 100644 index 00000000..ec88de82 --- /dev/null +++ b/apps/server/drizzle/20261002113921_lush_solo/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE `previews` ADD `ttl_ms` integer;--> statement-breakpoint +ALTER TABLE `previews` ADD `idle_ms` integer; \ No newline at end of file diff --git a/apps/server/drizzle/20261002113921_lush_solo/snapshot.json b/apps/server/drizzle/20261002113921_lush_solo/snapshot.json new file mode 100644 index 00000000..1a07ffe4 --- /dev/null +++ b/apps/server/drizzle/20261002113921_lush_solo/snapshot.json @@ -0,0 +1,371 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "bd46096d-d926-4068-bc49-27a1d0232fb7", + "prevIds": [ + "e0a78feb-0129-4236-bc9c-cd12a06e9748" + ], + "ddl": [ + { + "name": "api_tokens", + "entityType": "tables" + }, + { + "name": "previews", + "entityType": "tables" + }, + { + "name": "repos", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_hash", + "entityType": "columns", + "table": "api_tokens" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "scope", + "entityType": "columns", + "table": "api_tokens" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "canonical_repo_id", + "entityType": "columns", + "table": "api_tokens" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "api_tokens" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revoked_at", + "entityType": "columns", + "table": "api_tokens" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "canonical_repo_id", + "entityType": "columns", + "table": "previews" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "pr_id", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "db_name", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'postgres'", + "generated": null, + "name": "db_provider", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "hostname", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "app_image", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "container_id", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seeded_at", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seeded_seed_image", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_error", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_error_detail", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "failure_family", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "bring_up_plan", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seed_log", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "mail_from", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_activity_at", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expiry_reason", + "entityType": "columns", + "table": "previews" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ttl_ms", + "entityType": "columns", + "table": "previews" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "idle_ms", + "entityType": "columns", + "table": "previews" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "canonical_id", + "entityType": "columns", + "table": "repos" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "repos" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "repos" + }, + { + "columns": [ + "canonical_repo_id", + "pr_id" + ], + "nameExplicit": false, + "name": "previews_pk", + "entityType": "pks", + "table": "previews" + }, + { + "columns": [ + "token_hash" + ], + "nameExplicit": false, + "name": "api_tokens_pk", + "table": "api_tokens", + "entityType": "pks" + }, + { + "columns": [ + "canonical_id" + ], + "nameExplicit": false, + "name": "repos_pk", + "table": "repos", + "entityType": "pks" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index 2aad89e0..e728d09e 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -78,6 +78,12 @@ function clearGatewayEnv(): void { delete process.env.SPROUT_OTLP_ENDPOINT; delete process.env.SPROUT_OTLP_HEADERS; delete process.env.DO_NOT_TRACK; + delete process.env.SPROUT_PREVIEW_TTL; + delete process.env.SPROUT_PREVIEW_IDLE_TEARDOWN; + delete process.env.SPROUT_MAX_PREVIEWS_PER_REPO; + delete process.env.SPROUT_MAX_PREVIEWS; + delete process.env.SPROUT_PREVIEW_MAX_DB_CONNECTIONS; + delete process.env.SPROUT_POSTGRES_MAX_CONNECTIONS; } afterEach(() => { diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 4627046f..8ee4da57 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -4,6 +4,10 @@ import type { } from "./app-deployment/labels.ts"; import type { TelemetryState } from "./telemetry/destination.ts"; import { formatOtlpDestination } from "./telemetry/destination.ts"; +import { + parseGatewayCap, + parseGatewayDurationMs, +} from "@sprout/preview-env"; import { DEFAULT_MAIL_FROM_DOMAIN } from "@sprout/preview-env"; import { GITHUB_HOSTS } from "./forge/kind.ts"; import { @@ -73,6 +77,15 @@ export const DASHBOARD_ENV_KEYS = [ "SPROUT_DASHBOARD_PASSWORD", ] as const; +export const GOVERNANCE_ENV_KEYS = [ + "SPROUT_PREVIEW_TTL", + "SPROUT_PREVIEW_IDLE_TEARDOWN", + "SPROUT_MAX_PREVIEWS_PER_REPO", + "SPROUT_MAX_PREVIEWS", + "SPROUT_PREVIEW_MAX_DB_CONNECTIONS", + "SPROUT_POSTGRES_MAX_CONNECTIONS", +] as const; + export const GATEWAY_ENV_DOC_KEYS: readonly string[] = [ ...REQUIRED_ENV, ...POSTGRES_REQUIRED_ENV, @@ -81,6 +94,7 @@ export const GATEWAY_ENV_DOC_KEYS: readonly string[] = [ ...Object.keys(OPTIONAL_ENV_DEFAULTS), ...OPTIONAL_STRING_ENV, ...DASHBOARD_ENV_KEYS, + ...GOVERNANCE_ENV_KEYS, "SPROUT_ADMIN_TOKEN", "SPROUT_STATE_DB_PATH", "SPROUT_ADMIN_TOKEN_PATH", @@ -151,6 +165,18 @@ export type Config = { telemetry: TelemetryState; /** Operator-owned trace export; active exactly when endpoint is set. */ otlp: OtlpConfig; + /** Null/undefined means off (unbounded); manifest preview.ttl overrides. */ + previewTtlMs?: number | null; + /** Null/undefined means off; measured from the last successful deploy. */ + previewIdleMs?: number | null; + /** Null/undefined means off (unbounded). */ + maxPreviewsPerRepo?: number | null; + /** Null/undefined means off (unbounded). */ + maxPreviews?: number | null; + /** Expected per-preview DB connections; null/undefined means no budget. */ + previewMaxDbConnections?: number | null; + /** Instance ceiling for the budget arithmetic; null/undefined means no budget. */ + postgresMaxConnections?: number | null; }; function parsePositiveInt( @@ -592,9 +618,50 @@ export function loadConfig(): Config { traefikForwardAuth: parseTraefikForwardAuth(), telemetry, otlp: parseOtlpConfig(), + previewTtlMs: parseGatewayDurationMs( + "SPROUT_PREVIEW_TTL", + process.env.SPROUT_PREVIEW_TTL, + ), + previewIdleMs: parseGatewayDurationMs( + "SPROUT_PREVIEW_IDLE_TEARDOWN", + process.env.SPROUT_PREVIEW_IDLE_TEARDOWN, + ), + maxPreviewsPerRepo: parseGatewayCap( + "SPROUT_MAX_PREVIEWS_PER_REPO", + process.env.SPROUT_MAX_PREVIEWS_PER_REPO, + ), + maxPreviews: parseGatewayCap( + "SPROUT_MAX_PREVIEWS", + process.env.SPROUT_MAX_PREVIEWS, + ), + previewMaxDbConnections: parseGatewayCap( + "SPROUT_PREVIEW_MAX_DB_CONNECTIONS", + process.env.SPROUT_PREVIEW_MAX_DB_CONNECTIONS, + ), + postgresMaxConnections: parseGatewayCap( + "SPROUT_POSTGRES_MAX_CONNECTIONS", + process.env.SPROUT_POSTGRES_MAX_CONNECTIONS, + ), }; } +/** Loud boot warning when the gateway runs unbounded. */ +export function governanceUnboundedWarning(config: Config): string | null { + if ( + (config.previewTtlMs ?? null) === null && + (config.previewIdleMs ?? null) === null && + (config.maxPreviewsPerRepo ?? null) === null && + (config.maxPreviews ?? null) === null + ) { + return ( + "preview governance is unbounded (SPROUT_PREVIEW_TTL, " + + "SPROUT_PREVIEW_IDLE_TEARDOWN, SPROUT_MAX_PREVIEWS_PER_REPO and " + + "SPROUT_MAX_PREVIEWS are all off): previews live until PR close" + ); + } + return null; +} + export function missingPostgresEnv( postgres: PostgresConfig | undefined, ): string[] { @@ -664,6 +731,12 @@ export function configSummary(config: Config): Record { gitlabToken: config.gitlabToken === "" ? "[unset]" : "[set]", extraGitlabHosts: config.extraGitlabHosts.size, ttlHours: config.ttlHours, + previewTtlMs: config.previewTtlMs ?? "[off]", + previewIdleMs: config.previewIdleMs ?? "[off]", + maxPreviewsPerRepo: config.maxPreviewsPerRepo ?? "[off]", + maxPreviews: config.maxPreviews ?? "[off]", + previewMaxDbConnections: config.previewMaxDbConnections ?? "[off]", + postgresMaxConnections: config.postgresMaxConnections ?? "[off]", sweepCron: config.sweepCron, previewPortDefault: config.previewPortDefault, seedTimeout: config.seedTimeout, diff --git a/apps/server/src/http/app.ts b/apps/server/src/http/app.ts index 00033b08..eeb6590e 100644 --- a/apps/server/src/http/app.ts +++ b/apps/server/src/http/app.ts @@ -30,5 +30,13 @@ export function startServer(deps: ServerDeps) { extraGitlabHosts: deps.config.extraGitlabHosts, telemetry: deps.telemetry ?? NO_TELEMETRY, ...(deps.tracesPlugin ? { tracesPlugin: deps.tracesPlugin } : {}), + governance: { + previewTtlMs: deps.config.previewTtlMs, + previewIdleMs: deps.config.previewIdleMs, + maxPreviewsPerRepo: deps.config.maxPreviewsPerRepo, + maxPreviews: deps.config.maxPreviews, + previewMaxDbConnections: deps.config.previewMaxDbConnections, + postgresMaxConnections: deps.config.postgresMaxConnections, + }, }).listen(deps.config.port); } diff --git a/apps/server/src/http/deploy.ts b/apps/server/src/http/deploy.ts index caf61370..f1c21d51 100644 --- a/apps/server/src/http/deploy.ts +++ b/apps/server/src/http/deploy.ts @@ -2,6 +2,7 @@ import { authSpecIssueMessage, dbRolesIssueMessage, dbSpecIssueMessage, + governanceIssueMessage, isServicePort, labelIssueMessage, mailIntent, @@ -13,12 +14,14 @@ import { parseMailSpec, parsePreviewAuthSpec, parsePreviewEnvForProvider, + parsePreviewGovernanceField, parsePreviewVolumes, parseServiceEnvMap, previewAuthMode, previewVolumeIssueMessage, requiresDatabase, resolveDbRoles, + resolveGovernanceMs, resolveHealthSpec, seedRequiresDatabaseMessage, validateHostname, @@ -42,7 +45,11 @@ import { previewAuthNotConfiguredDetail, } from "../config.ts"; import { + checkConnectionBudget, + checkPreviewCaps, + resolveEffectiveGovernanceMs, teardownPreview, + type GovernanceConfig, type LifecycleDeps, type PreviewSnapshot, } from "../preview/lifecycle.ts"; @@ -125,6 +132,8 @@ export const deployBody = t.Object({ labels: t.Optional(t.Record(t.String(), t.String())), volumes: t.Optional(t.Array(t.String())), reseed: t.Optional(t.Boolean()), + ttl: t.Optional(t.String({ minLength: 1 })), + idle_teardown: t.Optional(t.String({ minLength: 1 })), }); export const teardownBody = t.Object({ @@ -156,6 +165,8 @@ export type DeployBody = { labels?: PreviewLabels; volumes?: string[]; reseed?: boolean; + ttl?: string; + idle_teardown?: string; }; export type DeploySpecs = { @@ -477,6 +488,36 @@ export function resolvePreviewVolumesRequest( }; } +export function resolveGovernanceRequest( + body: Pick, +): + | { ok: true; value: { ttl?: string; idle_teardown?: string } } + | { ok: false; error: string; detail?: string } { + const ttl = parsePreviewGovernanceField(body.ttl, "ttl"); + if (!ttl.ok) { + return { + ok: false, + error: "invalid_ttl", + detail: governanceIssueMessage("preview.ttl", ttl.issue), + }; + } + const idle = parsePreviewGovernanceField(body.idle_teardown, "idle_teardown"); + if (!idle.ok) { + return { + ok: false, + error: "invalid_idle_teardown", + detail: governanceIssueMessage("preview.idle_teardown", idle.issue), + }; + } + return { + ok: true, + value: { + ...(ttl.value !== undefined ? { ttl: ttl.value } : {}), + ...(idle.value !== undefined ? { idle_teardown: idle.value } : {}), + }, + }; +} + export type TeardownBody = { canonical_repo_id: string; pr_id: number; @@ -502,6 +543,7 @@ export function deploy( deps: LifecycleDeps & { materialization: PreviewMaterializationCtx; telemetry: TelemetryDeployHook; + governance?: GovernanceConfig; }, ) { return async ({ @@ -572,6 +614,45 @@ export function deploy( if (!volumes.ok) { return unprocessable(set, volumes); } + const governance = resolveGovernanceRequest(body); + if (!governance.ok) { + return unprocessable(set, governance); + } + const gatewayGov: GovernanceConfig = { + previewTtlMs: deps.governance?.previewTtlMs ?? null, + previewIdleMs: deps.governance?.previewIdleMs ?? null, + maxPreviewsPerRepo: deps.governance?.maxPreviewsPerRepo ?? null, + maxPreviews: deps.governance?.maxPreviews ?? null, + previewMaxDbConnections: + deps.governance?.previewMaxDbConnections ?? null, + postgresMaxConnections: deps.governance?.postgresMaxConnections ?? null, + }; + const caps = await checkPreviewCaps( + deps.db, + { repo: target.value.repo, prId: target.value.prId }, + gatewayGov, + ); + if (!caps.ok) { + set.status = caps.status; + return caps.detail + ? { error: caps.error, detail: caps.detail } + : { error: caps.error }; + } + const budget = await checkConnectionBudget( + deps.db, + { repo: target.value.repo, prId: target.value.prId }, + gatewayGov, + ); + if (!budget.ok) { + set.status = budget.status; + return budget.detail + ? { error: budget.error, detail: budget.detail } + : { error: budget.error }; + } + const governanceMs = resolveEffectiveGovernanceMs( + governance.value, + gatewayGov, + ); const plan = resolvePreviewPlan(deps.materialization, { spec: deploySpecs.value.spec, slug: body.slug, @@ -624,6 +705,8 @@ export function deploy( : {}), plan, reseed: body.reseed === true, + governance: governance.value, + governanceMs, ...(deps.materialization.traefikTls !== undefined ? { traefikTls: deps.materialization.traefikTls } : {}), diff --git a/apps/server/src/http/health-deploy.test.ts b/apps/server/src/http/health-deploy.test.ts index a961565b..b5b16a1a 100644 --- a/apps/server/src/http/health-deploy.test.ts +++ b/apps/server/src/http/health-deploy.test.ts @@ -139,7 +139,7 @@ describe("POST /v1/deploy health polling", () => { const { deployToken } = await setup(); const res = await postDeploy(deployToken, deployBody()); expect(res.settleStatus).toBe(200); - expect(res.body).toEqual({ + expect(res.body).toMatchObject({ ok: true, canonical_repo_id: REPO, pr_id: 42, @@ -148,6 +148,7 @@ describe("POST /v1/deploy health polling", () => { hostname: "pr-42.myapp.preview.example.com", status: "running", preview_url: "https://pr-42.myapp.preview.example.com", + expiry_reason: null, }); const [row] = await testApp!.db @@ -266,6 +267,9 @@ describe("POST /v1/deploy health polling", () => { hostname: "pr-7.myapp.preview.example.com", status: "provisioning", created_at: expect.any(String), + last_activity_at: null, + expires_at: null, + expiry_reason: null, }, ]); }); diff --git a/apps/server/src/http/introspection.test.ts b/apps/server/src/http/introspection.test.ts index 88f8e1fe..3f83c251 100644 --- a/apps/server/src/http/introspection.test.ts +++ b/apps/server/src/http/introspection.test.ts @@ -103,6 +103,9 @@ describe("GET /v1/previews", () => { hostname: "pr-42.myapp.preview.example.com", status: "running", created_at: expect.any(String), + last_activity_at: expect.any(String), + expires_at: null, + expiry_reason: null, }, ]); }); diff --git a/apps/server/src/http/none-deploy.test.ts b/apps/server/src/http/none-deploy.test.ts index 253be445..3aa1b9e4 100644 --- a/apps/server/src/http/none-deploy.test.ts +++ b/apps/server/src/http/none-deploy.test.ts @@ -151,6 +151,9 @@ describe("none previews", () => { hostname: "pr-42.myapp.preview.example.com", status: "running", created_at: expect.any(String), + last_activity_at: expect.any(String), + expires_at: null, + expiry_reason: null, }, ]); }); diff --git a/apps/server/src/http/preview-lifecycle.test.ts b/apps/server/src/http/preview-lifecycle.test.ts index 60e90b17..c6cb7e13 100644 --- a/apps/server/src/http/preview-lifecycle.test.ts +++ b/apps/server/src/http/preview-lifecycle.test.ts @@ -146,7 +146,7 @@ describe("POST /v1/deploy", () => { const { deployToken } = await setup(); const res = await postDeploy(deployToken, deployBody()); expect(res.settleStatus).toBe(200); - expect(res.body).toEqual({ + expect(res.body).toMatchObject({ ok: true, canonical_repo_id: REPO, pr_id: 42, @@ -155,7 +155,12 @@ describe("POST /v1/deploy", () => { hostname: "pr-42.myapp.preview.example.com", status: "running", preview_url: "https://pr-42.myapp.preview.example.com", + expiry_reason: null, }); + expect((res.body as { expires_at?: unknown }).expires_at ?? null).toBeNull(); + expect( + typeof (res.body as { last_activity_at?: unknown }).last_activity_at, + ).toBe("string"); expect(fakePreviewDb!.created).toEqual(["sprout_myapp_pr42"]); const [row] = await testApp!.db diff --git a/apps/server/src/http/routes.ts b/apps/server/src/http/routes.ts index d8338260..5dd8ea52 100644 --- a/apps/server/src/http/routes.ts +++ b/apps/server/src/http/routes.ts @@ -4,7 +4,10 @@ import type { PreviewAppOps } from "../app-deployment/ops.ts"; import type { StateDb } from "../infrastructure/db/client.ts"; import type { DashboardConfig } from "../config.ts"; import type { PreviewDbRouter } from "../preview-db/routing.ts"; -import type { LifecycleDeps } from "../preview/lifecycle.ts"; +import type { + GovernanceConfig, + LifecycleDeps, +} from "../preview/lifecycle.ts"; import type { PreviewDataVolumes } from "../preview/data-volumes.ts"; import type { PreviewMaterializationCtx } from "../preview/runtime.ts"; import type { TelemetryDeployHook } from "../telemetry/contract.ts"; @@ -41,6 +44,7 @@ export type RouteDeps = { extraGitlabHosts?: ReadonlySet; telemetry: TelemetryDeployHook; tracesPlugin?: TracesHandle["plugin"]; + governance?: GovernanceConfig; }; function stubNotImplemented({ @@ -63,6 +67,7 @@ export function createRoutes(deps: RouteDeps) { ...lifecycle, materialization: deps.materialization, telemetry: deps.telemetry, + ...(deps.governance !== undefined ? { governance: deps.governance } : {}), }; const mailboxUrl = deps.materialization.mail?.uiUrl; const accessDeps = { diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index c0177ab2..39e4e881 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -2,6 +2,7 @@ import { bindPreviewOps } from "./app-deployment/ops.ts"; import { bootstrapAdminToken } from "./auth/bootstrap-admin.ts"; import { configSummary, + governanceUnboundedWarning, loadConfig, } from "./config.ts"; import { createDockerEngineClient } from "./docker/engine.ts"; @@ -19,6 +20,8 @@ import { createTraces } from "./telemetry/traces.ts"; const config = loadConfig(); console.log("sprout starting", configSummary(config)); +const unbounded = governanceUnboundedWarning(config); +if (unbounded) console.warn(unbounded); const traces = createTraces(config); diff --git a/apps/server/src/infrastructure/db/schema.ts b/apps/server/src/infrastructure/db/schema.ts index 1f95753d..46cfe740 100644 --- a/apps/server/src/infrastructure/db/schema.ts +++ b/apps/server/src/infrastructure/db/schema.ts @@ -33,6 +33,11 @@ export const previews = sqliteTable( authSecret: text("auth_secret"), authBasicUser: text("auth_basic_user"), authBasicPassword: text("auth_basic_password"), + lastActivityAt: text("last_activity_at"), + expiresAt: text("expires_at"), + expiryReason: text("expiry_reason"), + ttlMs: integer("ttl_ms"), + idleMs: integer("idle_ms"), }, (table) => [ primaryKey({ columns: [table.canonicalRepoId, table.prId] }), diff --git a/apps/server/src/preview/bring-up.ts b/apps/server/src/preview/bring-up.ts index 61680dc2..1f479573 100644 --- a/apps/server/src/preview/bring-up.ts +++ b/apps/server/src/preview/bring-up.ts @@ -1,4 +1,5 @@ import type { DbProvider } from "@sprout/preview-env"; +import { computeExpiresAtMs } from "@sprout/preview-env"; import type { PreviewAppOps } from "../app-deployment/ops.ts"; import { resolvePreviewAccessLabels } from "../app-deployment/preview-auth.ts"; import { extractPullDetail } from "../docker/pull-failure.ts"; @@ -174,9 +175,25 @@ async function syncPreviewServices( } } +function expiryForActivity( + activityIso: string, + governanceMs?: { ttlMs: number | null; idleMs: number | null }, +): string | null { + if (!governanceMs) return null; + const baseMs = Date.parse(activityIso); + if (!Number.isFinite(baseMs)) return null; + const expiresMs = computeExpiresAtMs( + baseMs, + governanceMs.ttlMs, + governanceMs.idleMs, + ); + return expiresMs === null ? null : new Date(expiresMs).toISOString(); +} + async function closeRunning( deps: LifecycleDeps, row: PreviewRow, + input?: ProvisionInput, ): Promise> { const now = utcIsoNow(); const updated = await updatePreviewRow( @@ -186,6 +203,11 @@ async function closeRunning( status: "running", bringUpPlan: null, ...clearLastError, + lastActivityAt: now, + expiresAt: expiryForActivity(now, input?.governanceMs), + ttlMs: input?.governanceMs?.ttlMs ?? null, + idleMs: input?.governanceMs?.idleMs ?? null, + expiryReason: null, updatedAt: now, }, "preview_row_missing_on_running", @@ -217,7 +239,7 @@ async function syncThenCloseRunning( services: input.services, }); if (!synced.ok) return synced; - return closeRunning(deps, row); + return closeRunning(deps, row, input); } async function finishAfterPromote( @@ -226,7 +248,7 @@ async function finishAfterPromote( input: ProvisionInput, ): Promise> { if (input.services === undefined) { - return closeRunning(deps, row); + return closeRunning(deps, row, input); } return syncThenCloseRunning(deps, row, input); } diff --git a/apps/server/src/preview/lifecycle.ts b/apps/server/src/preview/lifecycle.ts index df2f327d..3fab3266 100644 --- a/apps/server/src/preview/lifecycle.ts +++ b/apps/server/src/preview/lifecycle.ts @@ -1,9 +1,19 @@ import { and, eq, ne } from "drizzle-orm"; import { previewAuthMode } from "@sprout/preview-env"; +import { + connectionBudgetDetail, + previewLimitDetail, + resolveGovernanceMs, +} from "@sprout/preview-env"; import type { StateDb } from "../infrastructure/db/client.ts"; import { previews } from "../infrastructure/db/schema.ts"; import { completeBringUp, pullImagesOutsideLock } from "./bring-up.ts"; -import { withDbNameLock, withPreviewLock } from "./locks.ts"; +import { + isPreviewLocked, + tryWithPreviewLock, + withDbNameLock, + withPreviewLock, +} from "./locks.ts"; import { markPreviewFailed } from "./mark-failed.ts"; import { desiredPreviewAuthState } from "./auth.ts"; import { @@ -35,6 +45,8 @@ export type { PreviewRow }; export type { BringUpPlan, DisplayPreviewStatus, + GovernanceConfig, + GovernanceInput, LifecycleDeps, PreviewSnapshot, PreviewStatus, @@ -44,7 +56,12 @@ export type { TeardownInput, TeardownSnapshot, } from "./types.ts"; -export { withPreviewLock, withDbNameLock } from "./locks.ts"; +export { + isPreviewLocked, + tryWithPreviewLock, + withDbNameLock, + withPreviewLock, +} from "./locks.ts"; export { markPreviewFailed, markStickyPreviewFailed, @@ -427,6 +444,7 @@ async function destroyPreviewRow( deps: TeardownDeps, existing: PreviewRow, disposition: DestroyDisposition, + expiryReason?: string, ): Promise> { const repo = existing.canonicalRepoId; const prId = existing.prId; @@ -469,6 +487,17 @@ async function destroyPreviewRow( .set({ status: "removed", containerId: null, + // Expiry clears the seed watermark so the next deploy provisions + // fresh and the seed re-runs; PR-close teardown keeps the same + // tombstone shape with the reason recorded for the read surface. + ...(expiryReason !== undefined + ? { + seededAt: null, + seededSeedImage: null, + expiryReason, + expiresAt: null, + } + : {}), updatedAt: utcIsoNow(), }) .where( @@ -592,12 +621,172 @@ export function removePreview( const status = parsePreviewStatus(existing.status); if (!status.ok) return status; - const result = await destroyPreviewRow(deps, existing, "tombstone"); + const result = await destroyPreviewRow( + deps, + existing, + "tombstone", + input.expiryReason, + ); if (!result.ok) return result; return { ok: true, value: true }; }); } +/** Sweep expiry never steals a preview mid-deploy: skip when the lock is held. */ +export function tryRemovePreview( + deps: TeardownDeps, + input: RemovePreviewInput, +): Promise> { + return tryWithPreviewLock(input.repo, input.prId, async () => { + const existing = await getPreviewRow(deps.db, input.repo, input.prId); + if (!existing || existing.status === "removed") { + return { ok: true as const, value: false }; + } + if (existing.dbName !== input.expectedDbName) { + return { ok: true as const, value: false }; + } + if (existing.createdAt !== input.expectedCreatedAt) { + return { ok: true as const, value: false }; + } + const status = parsePreviewStatus(existing.status); + if (!status.ok) return status; + const result = await destroyPreviewRow( + deps, + existing, + "tombstone", + input.expiryReason, + ); + if (!result.ok) return result; + return { ok: true as const, value: true }; + }).then((attempt) => { + if (!attempt.acquired) return { ok: true as const, value: false }; + return attempt.value!; + }); +} + +export function resolveEffectiveGovernanceMs( + manifest: { ttl?: string; idle_teardown?: string } | undefined, + gateway: { + previewTtlMs?: number | null; + previewIdleMs?: number | null; + }, +): { ttlMs: number | null; idleMs: number | null } { + return { + ttlMs: resolveGovernanceMs(manifest?.ttl, gateway.previewTtlMs ?? null), + idleMs: resolveGovernanceMs( + manifest?.idle_teardown, + gateway.previewIdleMs ?? null, + ), + }; +} + +async function countActivePreviews(db: StateDb): Promise { + const rows = await db + .select({ prId: previews.prId }) + .from(previews) + .where(ne(previews.status, "removed")); + return rows.length; +} + +async function countRepoPreviews( + db: StateDb, + repo: string, +): Promise { + const rows = await db + .select({ prId: previews.prId }) + .from(previews) + .where(and(eq(previews.canonicalRepoId, repo), ne(previews.status, "removed"))); + return rows.length; +} + +/** + * Caps fail fast and loudly: no container and no database is created when + * the deploy would exceed a configured cap. Refreshing an existing preview + * never counts against the cap. + */ +export async function checkPreviewCaps( + db: StateDb, + input: { repo: string; prId: number }, + caps: { + maxPreviewsPerRepo?: number | null; + maxPreviews?: number | null; + }, +): Promise> { + const existing = await getPreviewRow(db, input.repo, input.prId); + if (existing && existing.status !== "removed") return { ok: true, value: true }; + const perRepo = caps.maxPreviewsPerRepo ?? null; + if (perRepo !== null) { + const count = await countRepoPreviews(db, input.repo); + if (count >= perRepo) { + return { + ok: false, + status: 429, + error: "preview_limit_reached", + detail: previewLimitDetail({ + cap: "SPROUT_MAX_PREVIEWS_PER_REPO", + value: perRepo, + count, + }), + }; + } + } + const total = caps.maxPreviews ?? null; + if (total !== null) { + const count = await countActivePreviews(db); + if (count >= total) { + return { + ok: false, + status: 429, + error: "preview_limit_reached", + detail: previewLimitDetail({ + cap: "SPROUT_MAX_PREVIEWS", + value: total, + count, + }), + }; + } + } + return { ok: true, value: true }; +} + +/** + * Connection budget: (active + 1) x per-preview vs the instance ceiling. + * The per-preview figure is only accurate for apps that honour the injected + * cap; see docs/operator-deploy.md for the measured arithmetic. + */ +export async function checkConnectionBudget( + db: StateDb, + input: { repo: string; prId: number }, + budget: { + previewMaxDbConnections?: number | null; + postgresMaxConnections?: number | null; + }, +): Promise> { + const perPreview = budget.previewMaxDbConnections ?? null; + const ceiling = budget.postgresMaxConnections ?? null; + if (perPreview === null || ceiling === null) { + return { ok: true, value: true }; + } + const existing = await getPreviewRow(db, input.repo, input.prId); + const active = await countActivePreviews(db); + const isNew = !existing || existing.status === "removed"; + const projected = (active + (isNew ? 1 : 0)) * perPreview; + if (projected > ceiling) { + return { + ok: false, + status: 429, + error: "preview_connection_budget_exceeded", + detail: connectionBudgetDetail({ + projected, + ceiling, + perPreview, + active, + }), + }; + } + return { ok: true, value: true }; +} + /** Shared claim-check-then-act unit behind both orphan droppers. */ async function dropUnlessClaimed( hasClaimant: () => Promise, diff --git a/apps/server/src/preview/locks.test.ts b/apps/server/src/preview/locks.test.ts new file mode 100644 index 00000000..75f8ca71 --- /dev/null +++ b/apps/server/src/preview/locks.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { tryWithPreviewLock, withPreviewLock } from "./locks.ts"; + +describe("per-preview lock", () => { + test("try-lock skips when a deploy holds the lock", async () => { + const order: string[] = []; + await withPreviewLock("repo", 1, async () => { + order.push("deploy-start"); + const attempt = await tryWithPreviewLock("repo", 1, async () => { + order.push("sweep-ran"); + return true; + }); + expect(attempt.acquired).toBe(false); + order.push("deploy-end"); + }); + expect(order).toEqual(["deploy-start", "deploy-end"]); + }); + + test("try-lock acquires when free", async () => { + const attempt = await tryWithPreviewLock("repo", 2, async () => 42); + expect(attempt).toEqual({ acquired: true, value: 42 }); + }); +}); diff --git a/apps/server/src/preview/locks.ts b/apps/server/src/preview/locks.ts index 36c05e4e..0cd0b28c 100644 --- a/apps/server/src/preview/locks.ts +++ b/apps/server/src/preview/locks.ts @@ -1,9 +1,14 @@ /** Control-plane mutations serialize per (repo, prId) via an in-process queue. */ const previewLocks = new Map>(); +const previewLockCounts = new Map(); /** Catalog DROP/CREATE serialize per dbName so sweep cannot race provision. */ const dbNameLocks = new Map>(); +function previewKey(repo: string, prId: number): string { + return `${repo}\0${prId}`; +} + function withKeyedLock( locks: Map>, key: string, @@ -26,7 +31,33 @@ export function withPreviewLock( prId: number, fn: () => Promise, ): Promise { - return withKeyedLock(previewLocks, `${repo}\0${prId}`, fn); + const key = previewKey(repo, prId); + previewLockCounts.set(key, (previewLockCounts.get(key) ?? 0) + 1); + const run = withKeyedLock(previewLocks, key, fn); + const release = () => { + const next = (previewLockCounts.get(key) ?? 1) - 1; + if (next <= 0) previewLockCounts.delete(key); + else previewLockCounts.set(key, next); + }; + run.then(release, release); + return run; +} + +/** Non-blocking variant: returns acquired:false when a deploy holds the lock. */ +export function tryWithPreviewLock( + repo: string, + prId: number, + fn: () => Promise, +): Promise<{ acquired: boolean; value?: T }> { + if (isPreviewLocked(repo, prId)) return Promise.resolve({ acquired: false }); + return withPreviewLock(repo, prId, fn).then((value) => ({ + acquired: true, + value, + })); +} + +export function isPreviewLocked(repo: string, prId: number): boolean { + return (previewLockCounts.get(previewKey(repo, prId)) ?? 0) > 0; } export function withDbNameLock( diff --git a/apps/server/src/preview/snapshot.ts b/apps/server/src/preview/snapshot.ts index 1304689c..d15f502d 100644 --- a/apps/server/src/preview/snapshot.ts +++ b/apps/server/src/preview/snapshot.ts @@ -52,6 +52,9 @@ export function previewSnapshotFromRow(row: PreviewRow): PreviewSnapshot { ...(row.lastErrorDetail != null ? { last_error_detail: row.lastErrorDetail } : {}), + last_activity_at: row.lastActivityAt ?? null, + expires_at: row.expiresAt ?? null, + expiry_reason: row.expiryReason ?? null, }; } @@ -82,6 +85,9 @@ export type ListedPreview = { mailbox_url?: string; mail_from?: string; mail_from_name?: string; + last_activity_at: string | null; + expires_at: string | null; + expiry_reason: string | null; }; /** @@ -103,6 +109,9 @@ export function presentListedPreview( hostname: snap.hostname, status, created_at: row.createdAt, + last_activity_at: row.lastActivityAt ?? null, + expires_at: row.expiresAt ?? null, + expiry_reason: row.expiryReason ?? null, ...(snap.mail_from !== undefined ? { mail_from: snap.mail_from } : {}), ...(snap.mail_from_name !== undefined ? { mail_from_name: snap.mail_from_name } diff --git a/apps/server/src/preview/types.ts b/apps/server/src/preview/types.ts index 01e2eb72..569594f5 100644 --- a/apps/server/src/preview/types.ts +++ b/apps/server/src/preview/types.ts @@ -97,6 +97,20 @@ export type PhaseTimer = { record(phase: PreviewPhase, ms: number): void; }; +export type GovernanceInput = { + ttl?: string; + idle_teardown?: string; +}; + +export type GovernanceConfig = { + previewTtlMs?: number | null; + previewIdleMs?: number | null; + maxPreviewsPerRepo?: number | null; + maxPreviews?: number | null; + previewMaxDbConnections?: number | null; + postgresMaxConnections?: number | null; +}; + export type ProvisionInput = { repo: string; prId: number; @@ -114,6 +128,8 @@ export type ProvisionInput = { traefikForwardAuth?: TraefikForwardAuth; auth?: PreviewAuthSpec; previewAuth?: PreviewAuthConfig; + governance?: GovernanceInput; + governanceMs?: { ttlMs: number | null; idleMs: number | null }; }; export type TeardownInput = { @@ -126,6 +142,7 @@ export type RemovePreviewInput = { prId: number; expectedDbName: string | null; expectedCreatedAt: string; + expiryReason?: string; }; export type PreviewSnapshot = { @@ -142,6 +159,9 @@ export type PreviewSnapshot = { mail_from_name?: string; last_error?: string; last_error_detail?: string; + last_activity_at?: string | null; + expires_at?: string | null; + expiry_reason?: string | null; }; export type TeardownSnapshot = { diff --git a/apps/server/src/sweep/governance.test.ts b/apps/server/src/sweep/governance.test.ts new file mode 100644 index 00000000..07581216 --- /dev/null +++ b/apps/server/src/sweep/governance.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, setSystemTime, test, afterEach } from "bun:test"; +import { + planGovernanceExpiry, + runSweepPass, + type SweepPreview, +} from "./reconcile.ts"; + +function preview(over: Partial = {}): SweepPreview { + return { + canonicalRepoId: "https://github.com/acme/widgets", + prId: 5, + slug: "widgets", + dbName: "sprout_widgets_pr5", + createdAt: "2026-09-02T12:00:00.000Z", + createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), + status: "running", + ...over, + }; +} + +describe("governance expiry", () => { + afterEach(() => setSystemTime()); + + test("expires by stored expires_at with ttl reason", () => { + setSystemTime(new Date("2026-09-10T12:00:00.000Z")); + const base = Date.parse("2026-09-02T12:00:00.000Z"); + const p = preview({ + lastActivityMs: base, + ttlMs: 7 * 86400_000, + idleMs: null, + expiresAtMs: base + 7 * 86400_000, + }); + // now is past 7d + setSystemTime(new Date(base + 8 * 86400_000)); + expect(planGovernanceExpiry(p, Date.now())).toBe("sweep:ttl-expired"); + }); + + test("expires by idle with idle reason", () => { + const base = Date.parse("2026-09-02T12:00:00.000Z"); + const p = preview({ + lastActivityMs: base, + ttlMs: null, + idleMs: 2 * 3600_000, + expiresAtMs: base + 2 * 3600_000, + }); + expect( + planGovernanceExpiry(p, base + 3 * 3600_000), + ).toBe("sweep:idle-expired"); + expect(planGovernanceExpiry(p, base + 3600_000)).toBeNull(); + }); + + test("off at either level disables (null means keep)", () => { + const base = Date.parse("2026-09-02T12:00:00.000Z"); + const p = preview({ + lastActivityMs: base, + ttlMs: null, + idleMs: null, + expiresAtMs: null, + }); + expect(planGovernanceExpiry(p, base + 30 * 86400_000)).toBeNull(); + }); + + test("sweep removes expired preview without forge call", async () => { + const base = Date.parse("2026-09-02T12:00:00.000Z"); + setSystemTime(new Date(base + 8 * 86400_000)); + const deletions: unknown[] = []; + const forgeCalls: string[] = []; + const result = await runSweepPass({ + listPreviews: async () => [ + preview({ + lastActivityMs: base, + ttlMs: 7 * 86400_000, + idleMs: null, + expiresAtMs: base + 7 * 86400_000, + }), + ], + listCatalogDatabases: async () => [], + listDataVolumes: async () => [], + listPreviewContainers: async () => [], + listOpenPrIds: async (repo) => { + forgeCalls.push(repo); + return [5]; + }, + drop: async (d) => { + deletions.push(d); + return true; + }, + ttlHours: 72 * 365, + }); + expect(deletions).toEqual([ + expect.objectContaining({ reason: "sweep:ttl-expired", prId: 5 }), + ]); + expect(forgeCalls).toEqual([]); + expect(result.deletions).toHaveLength(1); + }); + + test("sweep logs over-cap state", async () => { + setSystemTime(new Date("2026-09-03T12:00:00.000Z")); + const logs: string[] = []; + await runSweepPass({ + listPreviews: async () => [preview(), preview({ prId: 6 })], + listCatalogDatabases: async () => [], + listDataVolumes: async () => [], + listPreviewContainers: async () => [], + listOpenPrIds: async () => [5, 6], + drop: async () => true, + ttlHours: 72 * 365, + governance: { maxPreviews: 1, maxPreviewsPerRepo: 1 }, + log: (m) => { + logs.push(m); + }, + }); + expect(logs.some((m) => m.includes("SPROUT_MAX_PREVIEWS"))).toBe(true); + }); +}); diff --git a/apps/server/src/sweep/live-ports.ts b/apps/server/src/sweep/live-ports.ts index ee041cb9..7948d719 100644 --- a/apps/server/src/sweep/live-ports.ts +++ b/apps/server/src/sweep/live-ports.ts @@ -8,6 +8,7 @@ import { dropOrphanDatabase, dropOrphanDataVolume, removePreview, + tryRemovePreview, type TeardownDeps, } from "../preview/lifecycle.ts"; import type { PreviewDbRouter } from "../preview-db/routing.ts"; @@ -24,6 +25,7 @@ export type LiveSweepDeps = { dataVolumes: PreviewDataVolumes; forge: ForgeClient; ttlHours: number; + governance?: SweepPorts["governance"]; log?: SweepPorts["log"]; }; @@ -40,15 +42,20 @@ async function removeControlPlane( deps: LiveSweepDeps, deletion: Extract< SweepDeletion, - { reason: "sweep:ttl-expired" | "sweep:pr-not-open" } + { reason: "sweep:ttl-expired" | "sweep:idle-expired" | "sweep:pr-not-open" } >, + useTryLock = false, ): Promise { - const result = await removePreview(teardownDeps(deps), { + const input = { repo: deletion.canonicalRepoId, prId: deletion.prId, expectedDbName: deletion.dbName, expectedCreatedAt: deletion.createdAt, - }); + expiryReason: deletion.reason, + }; + const result = useTryLock + ? await tryRemovePreview(teardownDeps(deps), input) + : await removePreview(teardownDeps(deps), input); if (!result.ok) throw new Error(result.error); return result.value; } @@ -56,6 +63,7 @@ async function removeControlPlane( export function createLiveSweepPorts(deps: LiveSweepDeps): SweepPorts { return { ttlHours: deps.ttlHours, + ...(deps.governance !== undefined ? { governance: deps.governance } : {}), log: deps.log, listPreviews: async () => { const rows = await deps.db.select().from(previews); @@ -75,6 +83,10 @@ export function createLiveSweepPorts(deps: LiveSweepDeps): SweepPorts { createdAt: row.createdAt, createdAtMs, status: row.status, + lastActivityMs: parseUnambiguousUtcMs(row.lastActivityAt ?? ""), + expiresAtMs: parseUnambiguousUtcMs(row.expiresAt ?? ""), + ttlMs: row.ttlMs ?? null, + idleMs: row.idleMs ?? null, }); } return out; @@ -109,7 +121,8 @@ async function dropDeletion( ): Promise { switch (deletion.reason) { case "sweep:ttl-expired": - return removeControlPlane(deps, deletion); + case "sweep:idle-expired": + return removeControlPlane(deps, deletion, true); case "sweep:pr-not-open": { const open = await deps.forge.listOpenPrIds(deletion.canonicalRepoId); if (open.includes(deletion.prId)) return false; diff --git a/apps/server/src/sweep/reconcile.ts b/apps/server/src/sweep/reconcile.ts index b623974f..900bcd5b 100644 --- a/apps/server/src/sweep/reconcile.ts +++ b/apps/server/src/sweep/reconcile.ts @@ -4,6 +4,7 @@ import { isForgeApiError } from "../forge/types.ts"; export type SweepReason = | "sweep:pr-not-open" | "sweep:ttl-expired" + | "sweep:idle-expired" | "sweep:orphan-db" | "sweep:orphan-container" | "sweep:orphan-data-volume"; @@ -20,11 +21,15 @@ export type SweepPreview = { createdAt: string; createdAtMs: number | null; status: string; + lastActivityMs?: number | null; + expiresAtMs?: number | null; + ttlMs?: number | null; + idleMs?: number | null; }; export type SweepDeletion = | { - reason: "sweep:pr-not-open" | "sweep:ttl-expired"; + reason: "sweep:pr-not-open" | "sweep:ttl-expired" | "sweep:idle-expired"; canonicalRepoId: string; prId: number; slug: string; @@ -47,6 +52,12 @@ export type SweepPorts = { /** True if resources were removed; false if the plan was stale. */ drop: (deletion: SweepDeletion) => Promise; ttlHours: number; + governance?: { + maxPreviewsPerRepo?: number | null; + maxPreviews?: number | null; + previewMaxDbConnections?: number | null; + postgresMaxConnections?: number | null; + }; log?: (message: string, deletion?: SweepDeletion) => void; }; @@ -171,17 +182,28 @@ export async function runSweepPass(ports: SweepPorts): Promise ); } - const cutoff = Date.now() - ports.ttlHours * 60 * 60 * 1000; + const nowMs = Date.now(); + const cutoff = nowMs - ports.ttlHours * 60 * 60 * 1000; const previewKeys = new Set(); const remainingPreviews: SweepPreview[] = []; - const ttlDeletions: SweepDeletion[] = []; + const expiryDeletions: SweepDeletion[] = []; for (const preview of previews) { if (preview.status === "removed") continue; previewKeys.add(`${preview.slug}:${preview.prId}`); - if (preview.createdAtMs !== null && preview.createdAtMs < cutoff) { - ttlDeletions.push({ + const governanceExpiry = planGovernanceExpiry(preview, nowMs); + if (governanceExpiry) { + expiryDeletions.push({ + reason: governanceExpiry, + canonicalRepoId: preview.canonicalRepoId, + prId: preview.prId, + slug: preview.slug, + dbName: preview.dbName, + createdAt: preview.createdAt, + }); + } else if (preview.createdAtMs !== null && preview.createdAtMs < cutoff) { + expiryDeletions.push({ reason: "sweep:ttl-expired", canonicalRepoId: preview.canonicalRepoId, prId: preview.prId, @@ -194,6 +216,8 @@ export async function runSweepPass(ports: SweepPorts): Promise } } + logOverCap(ports, previews); + const orphanDeletions = planOrphans({ previewKeys, catalog, @@ -246,9 +270,97 @@ export async function runSweepPass(ports: SweepPorts): Promise const deletions = await dropSettled( ports, - [...ttlDeletions, ...orphanDeletions, ...prNotOpenDeletions], + [...expiryDeletions, ...orphanDeletions, ...prNotOpenDeletions], (d) => `deleted (${d.reason})`, ); return { forgeRepoFailures, deletions }; } + +function activityBaseMs(preview: SweepPreview): number | null { + if ((preview.lastActivityMs ?? null) !== null) + return preview.lastActivityMs ?? null; + return preview.createdAtMs; +} + +/** + * Governance expiry from the stored per-preview deadlines. The cheap activity + * signal is the last successful deploy (including reseed/reset); see + * docs/previews.md. Returns the reason to expire with, or null to keep. + */ +export function planGovernanceExpiry( + preview: SweepPreview, + nowMs: number, +): "sweep:ttl-expired" | "sweep:idle-expired" | null { + const expiresAtMs = preview.expiresAtMs ?? null; + const ttlMs = preview.ttlMs ?? null; + const idleMs = preview.idleMs ?? null; + if (expiresAtMs !== null) { + if (nowMs < expiresAtMs) return null; + const base = activityBaseMs(preview); + if (base === null) return "sweep:ttl-expired"; + const ttlDeadline = ttlMs !== null ? base + ttlMs : null; + const idleDeadline = idleMs !== null ? base + idleMs : null; + const ttlExpired = ttlDeadline !== null && nowMs >= ttlDeadline; + const idleExpired = idleDeadline !== null && nowMs >= idleDeadline; + if (ttlExpired && idleExpired) { + return ttlDeadline! <= idleDeadline! + ? "sweep:ttl-expired" + : "sweep:idle-expired"; + } + if (ttlExpired) return "sweep:ttl-expired"; + if (idleExpired) return "sweep:idle-expired"; + return "sweep:ttl-expired"; + } + // Back-compat for rows written before governance columns existed: derive + // from last activity (or createdAt) plus stored per-preview durations. + const base = activityBaseMs(preview); + if (base === null) return null; + if (ttlMs !== null && nowMs >= base + ttlMs) { + return "sweep:ttl-expired"; + } + if (idleMs !== null && nowMs >= base + idleMs) { + return "sweep:idle-expired"; + } + return null; +} + +function logOverCap( + ports: SweepPorts, + previews: SweepPreview[], +): void { + const gov = ports.governance; + if (!gov) return; + const live = previews.filter((p) => p.status !== "removed"); + const maxTotal = gov.maxPreviews ?? null; + if (maxTotal !== null && live.length > maxTotal) { + ports.log?.( + `sweep over cap SPROUT_MAX_PREVIEWS (${live.length} > ${maxTotal})`, + ); + } + const perRepo = gov.maxPreviewsPerRepo ?? null; + if (perRepo !== null) { + const byRepo = new Map(); + for (const p of live) { + byRepo.set(p.canonicalRepoId, (byRepo.get(p.canonicalRepoId) ?? 0) + 1); + } + for (const [repo, count] of byRepo) { + if (count > perRepo) { + ports.log?.( + `sweep over cap SPROUT_MAX_PREVIEWS_PER_REPO ${repo} (${count} > ${perRepo})`, + ); + } + } + } + const perPreview = gov.previewMaxDbConnections ?? null; + const ceiling = gov.postgresMaxConnections ?? null; + if (perPreview !== null && ceiling !== null) { + const projected = live.length * perPreview; + if (projected > ceiling) { + ports.log?.( + `sweep over connection budget (projected ${projected} > ceiling ${ceiling}; ` + + `${live.length} previews x ${perPreview} per preview)`, + ); + } + } +} diff --git a/apps/server/src/sweep/start.ts b/apps/server/src/sweep/start.ts index da61508c..f436e037 100644 --- a/apps/server/src/sweep/start.ts +++ b/apps/server/src/sweep/start.ts @@ -27,6 +27,12 @@ export function startGatewaySweep(deps: { dataVolumes: deps.dataVolumes, forge, ttlHours: deps.config.ttlHours, + governance: { + maxPreviewsPerRepo: deps.config.maxPreviewsPerRepo, + maxPreviews: deps.config.maxPreviews, + previewMaxDbConnections: deps.config.previewMaxDbConnections, + postgresMaxConnections: deps.config.postgresMaxConnections, + }, log: (message, deletion) => { if (deletion) console.log(message, deletion); else console.log(message); diff --git a/compose.env.example b/compose.env.example index c20148dc..c64d4afa 100644 --- a/compose.env.example +++ b/compose.env.example @@ -100,3 +100,16 @@ SPROUT_GITHUB_TOKEN= SPROUT_GITLAB_TOKEN= # Optional self-managed GitLab: host=gitlab (e.g. git.example.com=gitlab) # SPROUT_FORGE_HOSTS= + +# Preview governance: the self-serve local stack ships safe (7d TTL on). +# Gateway code defaults are off (unbounded); compose sets 7d so a new +# operator is safe without configuring anything. Manifest preview.ttl / +# preview.idle_teardown override these per repo; off at either level disables. +SPROUT_PREVIEW_TTL=7d +# SPROUT_PREVIEW_IDLE_TEARDOWN=off +# SPROUT_MAX_PREVIEWS_PER_REPO=off +# SPROUT_MAX_PREVIEWS=off +# Connection budget: expected per-preview connections vs the instance ceiling. +# Measured: ~12 idle connections per preview app at default pool sizes. +# SPROUT_PREVIEW_MAX_DB_CONNECTIONS=12 +# SPROUT_POSTGRES_MAX_CONNECTIONS=100 diff --git a/docker-compose.yml b/docker-compose.yml index ee609256..daf8f585 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -118,6 +118,12 @@ services: SPROUT_GITHUB_TOKEN: ${SPROUT_GITHUB_TOKEN:-} SPROUT_GITLAB_TOKEN: ${SPROUT_GITLAB_TOKEN:-} SPROUT_FORGE_HOSTS: ${SPROUT_FORGE_HOSTS:-} + SPROUT_PREVIEW_TTL: ${SPROUT_PREVIEW_TTL:-7d} + SPROUT_PREVIEW_IDLE_TEARDOWN: ${SPROUT_PREVIEW_IDLE_TEARDOWN:-} + SPROUT_MAX_PREVIEWS_PER_REPO: ${SPROUT_MAX_PREVIEWS_PER_REPO:-} + SPROUT_MAX_PREVIEWS: ${SPROUT_MAX_PREVIEWS:-} + SPROUT_PREVIEW_MAX_DB_CONNECTIONS: ${SPROUT_PREVIEW_MAX_DB_CONNECTIONS:-} + SPROUT_POSTGRES_MAX_CONNECTIONS: ${SPROUT_POSTGRES_MAX_CONNECTIONS:-} SPROUT_ADMIN_TOKEN: ${SPROUT_ADMIN_TOKEN:-} SPROUT_STATE_DB_PATH: ${SPROUT_STATE_DB_PATH:-/data/sprout.db} SPROUT_ADMIN_TOKEN_PATH: ${SPROUT_ADMIN_TOKEN_PATH:-/data/admin-token} diff --git a/docs/adopting-a-repo.md b/docs/adopting-a-repo.md index 9937704f..76d218d3 100644 --- a/docs/adopting-a-repo.md +++ b/docs/adopting-a-repo.md @@ -41,6 +41,8 @@ pointers live in [CLI reference](cli-reference.md#test-coverage-maintainers). | `preview.services[].labels` | no | — | Adopter container labels for that service container only; same key at both levels resolves to the per-service value (see [Preview labels](previews.md#preview-labels-adopter-supplied-container-labels)). | | `preview.volumes` | no | — | Per-preview named volumes for files the app writes at runtime (see [Previews](previews.md#preview-app-data-volumes)). | | `preview.auth` | no | `none` | Per-preview access gate: `none` (open), `basic` (gateway-generated credential), or `link` (shareable revocable links). `link` requires the gateway capability or the deploy fails with `preview_auth_not_configured`. See [Previews](previews.md#preview-access). | +| `preview.ttl` | no | gateway `SPROUT_PREVIEW_TTL` (off) | Preview lifetime from the last successful deploy (e.g. `7d`, `2h`, `30m`, or `off`). Overrides the gateway default; `off` disables. See [Previews](previews.md#preview-ttl-and-idle-teardown). | +| `preview.idle_teardown` | no | gateway `SPROUT_PREVIEW_IDLE_TEARDOWN` (off) | Idle lifetime from the last successful deploy (same grammar as `ttl`). Overrides the gateway default; `off` disables. See [Previews](previews.md#preview-ttl-and-idle-teardown). | | `db.provider` | no | `postgres` | Preview database provider: `postgres` (shared instance), `sqlite` (named volume), or `none` (no database). See [Previews](previews.md#sqlite-previews) and [Previews](previews.md#no-database-previews). | | `db.roles` | no | derived: `dual` when `preview.env` remaps `PGAPPUSER` / `PGAPPPASSWORD`, else `single` | Postgres credential axis: `single` (owner only) or `dual` (owner + restricted companion). Rejected on `sqlite` / `none`. Explicit `single` plus a companion remap fails fast. | | `mail` | no | opportunistic (follows the gateway) | `enabled` (require mail) or `none` (opt out). See [Previews](previews.md#email-from-a-preview). | diff --git a/docs/operator-deploy.md b/docs/operator-deploy.md index 6bc584a1..640232d3 100644 --- a/docs/operator-deploy.md +++ b/docs/operator-deploy.md @@ -574,16 +574,51 @@ need non-defaults): | Variable | Default | |---|---| | `SPROUT_PORT` | `7331` | -| `SPROUT_TTL_HOURS` | `72` | +| `SPROUT_TTL_HOURS` | `72` (legacy creation-age sweep; new governance below is off by default) | | `SPROUT_SWEEP_CRON` | `*/30 * * * *` | | `SPROUT_TELEMETRY` | `on` | | `SPROUT_PREVIEW_PORT_DEFAULT` | `8080` | | `SPROUT_SEED_TIMEOUT` | `180` | +| `SPROUT_PREVIEW_TTL` | `off` (`7d` in the self-serve compose stack) | +| `SPROUT_PREVIEW_IDLE_TEARDOWN` | `off` | +| `SPROUT_MAX_PREVIEWS_PER_REPO` | `off` | +| `SPROUT_MAX_PREVIEWS` | `off` | +| `SPROUT_PREVIEW_MAX_DB_CONNECTIONS` | `off` | +| `SPROUT_POSTGRES_MAX_CONNECTIONS` | `off` (`100` in the compose example) | `SPROUT_SWEEP_CRON` is a cron expression (local gateway time); an invalid value fails gateway boot. The first sweep pass lands on the next boundary — never at boot. +Unbounded by default: with TTL/idle/caps all `off` the gateway keeps every +preview until PR close and logs a loud boot warning. The self-serve compose +stack sets `SPROUT_PREVIEW_TTL=7d` so a new operator is safe without +configuring anything; an existing deployment upgrades with no new required +env and identical behaviour apart from that warning. + +### Preview caps and connection budget + +Caps fail fast and loudly: exceeding `SPROUT_MAX_PREVIEWS` or +`SPROUT_MAX_PREVIEWS_PER_REPO` fails the deploy with `preview_limit_reached` +naming the cap, its value, and the current count — no container and no +database is created. The sweep logs over-cap state but never deletes to fix +it; close a PR or raise the cap. + +Connection arithmetic the gateway enforces instead of discovering afterwards: + +`projected = (active previews + 1) × SPROUT_PREVIEW_MAX_DB_CONNECTIONS` +against `SPROUT_POSTGRES_MAX_CONNECTIONS`. A deploy beyond the projection +fails with `preview_connection_budget_exceeded`. + +Measured case: each preview app holds two long-lived pools at default size, +so ~12 idle connections per preview; ~8 previews fill a 100-slot Postgres +instance with zero traffic (`remaining connection slots are reserved for +SUPERUSER`, SQLSTATE 53300). Set `SPROUT_PREVIEW_MAX_DB_CONNECTIONS=12` and +`SPROUT_POSTGRES_MAX_CONNECTIONS=100` to enforce that shape. The figure is +only accurate for apps that honour the injected cap — a repo that raises its +pool sizes bypasses the budget, and the gateway must not assume otherwise. +Raising `max_connections` is headroom, not a bound. + For HTTPS behind an external Traefik, set entrypoints (and optionally certresolver) to that proxy's names. Example Coolify-shaped values (operator choice, not sprout defaults): `SPROUT_TRAEFIK_ENTRYPOINTS=https` and diff --git a/docs/previews.md b/docs/previews.md index 7775cae7..bdad0a2b 100644 --- a/docs/previews.md +++ b/docs/previews.md @@ -23,6 +23,39 @@ This page owns the lifecycle: database providers, seeding, services, mail. Synchronize re-deploys keep the same database; only a reset wipes it. +## Preview TTL and idle teardown + +A preview that nobody closes still disappears on a bound. Two manifest keys, +both optional durations (`30m`, `2h`, `7d`) or `off`: + +```yaml +preview: + hostname: "pr-{pr_id}.myapp.preview.example.com" + ttl: 7d + idle_teardown: 2h +``` + +- `preview.ttl` — lifetime measured from the last successful deploy; a push + (or `ci reset` / `ci reseed`) refreshes the deadline. +- `preview.idle_teardown` — idle lifetime measured from the same signal. + +The activity signal is exactly one thing: the last successful deploy, +including reseed and reset. There is no per-preview access-log pipeline in +this release and no edge traffic accounting — a quiet preview that receives +HTTP traffic but no deploy still expires. The gateway's sweep +(`SPROUT_SWEEP_CRON`, in-process `Bun.cron`) removes expired previews +through the same path `teardown` uses (stop container, drop database, clear +`seeded_at` so the next deploy re-runs the seed), holding the per-preview +lock so a preview mid-deploy is never expired. The expiry reason +(`sweep:ttl-expired` / `sweep:idle-expired`) is stored on the row and +`GET /v1/previews` plus the single-preview read return `expires_at`, +`last_activity_at`, and the reason; the CI note carries an `- Expires:` line. + +Manifest keys override the gateway defaults (`SPROUT_PREVIEW_TTL`, +`SPROUT_PREVIEW_IDLE_TEARDOWN`); `off` at the effective level disables that +bound. With nothing configured the gateway is unbounded apart from PR-close +teardown and logs a loud boot warning. + ## Preview database roles (`db.roles`) Postgres previews run with one (`single`) or two (`dual`) database diff --git a/packages/preview-env/src/governance.test.ts b/packages/preview-env/src/governance.test.ts new file mode 100644 index 00000000..2d9ee793 --- /dev/null +++ b/packages/preview-env/src/governance.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test"; +import { + computeExpiresAtMs, + connectionProjection, + parseGatewayCap, + parseGatewayDurationMs, + parsePreviewGovernanceField, + resolveGovernanceMs, +} from "./governance.ts"; + +describe("preview governance durations", () => { + test("parses manifest ttl/idle with off", () => { + expect(parsePreviewGovernanceField("7d", "ttl")).toEqual({ + ok: true, + value: "7d", + }); + expect(parsePreviewGovernanceField("off", "ttl")).toEqual({ + ok: true, + value: "off", + }); + expect(parsePreviewGovernanceField("OFF", "idle_teardown")).toEqual({ + ok: true, + value: "off", + }); + expect(parsePreviewGovernanceField(undefined, "ttl")).toEqual({ + ok: true, + value: undefined, + }); + }); + + test("rejects malformed durations with named errors", () => { + const bad = parsePreviewGovernanceField("forever", "ttl"); + expect(bad.ok).toBe(false); + if (!bad.ok) expect(bad.issue.code).toBe("invalid_ttl"); + const idleBad = parsePreviewGovernanceField("7x", "idle_teardown"); + expect(idleBad.ok).toBe(false); + }); + + test("gateway duration accepts off and rejects garbage", () => { + expect(parseGatewayDurationMs("SPROUT_PREVIEW_TTL", undefined)).toBeNull(); + expect(parseGatewayDurationMs("SPROUT_PREVIEW_TTL", "off")).toBeNull(); + expect(parseGatewayDurationMs("SPROUT_PREVIEW_TTL", "7d")).toBe( + 7 * 24 * 60 * 60 * 1000, + ); + expect(parseGatewayDurationMs("SPROUT_PREVIEW_TTL", "2h")).toBe( + 2 * 60 * 60 * 1000, + ); + expect(() => + parseGatewayDurationMs("SPROUT_PREVIEW_TTL", "forever"), + ).toThrow("Invalid SPROUT_PREVIEW_TTL"); + }); + + test("gateway caps accept off and reject garbage", () => { + expect(parseGatewayCap("SPROUT_MAX_PREVIEWS", undefined)).toBeNull(); + expect(parseGatewayCap("SPROUT_MAX_PREVIEWS", "off")).toBeNull(); + expect(parseGatewayCap("SPROUT_MAX_PREVIEWS", "10")).toBe(10); + expect(() => parseGatewayCap("SPROUT_MAX_PREVIEWS", "0")).toThrow( + "Invalid SPROUT_MAX_PREVIEWS", + ); + }); + + test("manifest overrides gateway and off disables", () => { + expect(resolveGovernanceMs("7d", 2 * 3600_000)).toBe(7 * 86400_000); + expect(resolveGovernanceMs("off", 2 * 3600_000)).toBeNull(); + expect(resolveGovernanceMs(undefined, 2 * 3600_000)).toBe(2 * 3600_000); + expect(resolveGovernanceMs(undefined, null)).toBeNull(); + }); + + test("expiry is the earlier of ttl and idle", () => { + const base = 1_000_000; + expect(computeExpiresAtMs(base, 7 * 86400_000, null)).toBe( + base + 7 * 86400_000, + ); + expect(computeExpiresAtMs(base, 7 * 86400_000, 2 * 3600_000)).toBe( + base + 2 * 3600_000, + ); + expect(computeExpiresAtMs(base, null, null)).toBeNull(); + }); + + test("connection projection refuses beyond the ceiling", () => { + expect( + connectionProjection({ activePreviews: 7, perPreview: 12, ceiling: 100 }), + ).toEqual({ projected: 96, over: false }); + expect( + connectionProjection({ activePreviews: 8, perPreview: 12, ceiling: 100 }), + ).toEqual({ projected: 108, over: true }); + expect( + connectionProjection({ activePreviews: 8, perPreview: null, ceiling: 100 }), + ).toEqual({ projected: null, over: false }); + }); +}); diff --git a/packages/preview-env/src/governance.ts b/packages/preview-env/src/governance.ts new file mode 100644 index 00000000..082981d0 --- /dev/null +++ b/packages/preview-env/src/governance.ts @@ -0,0 +1,164 @@ +const DURATION_RE = /^(\d+)(s|m|h|d)$/; + +function parseDurationMs(raw: string): number | null { + const trimmed = raw.trim(); + const match = DURATION_RE.exec(trimmed); + if (!match) return null; + const amount = Number(match[1]); + if (!Number.isSafeInteger(amount) || amount <= 0) return null; + const unit = match[2]; + const factor = + unit === "s" + ? 1000 + : unit === "m" + ? 60 * 1000 + : unit === "h" + ? 60 * 60 * 1000 + : 24 * 60 * 60 * 1000; + const ms = amount * factor; + if (!Number.isSafeInteger(ms) || ms <= 0) return null; + return ms; +} + +type GovernanceFieldIssue = { + code: "invalid_ttl" | "invalid_idle_teardown"; + raw: string; +}; + +export function governanceIssueMessage( + path: string, + issue: GovernanceFieldIssue, +): string { + return `${path} is invalid (expected e.g. 7d, 2h, 30m or off, got ${JSON.stringify(issue.raw)})`; +} + +/** + * Manifest-level parse: undefined means inherit the gateway default, "off" + * disables, otherwise a duration string. The honest cheap activity signal is + * the last successful deploy (including reseed/reset) — see docs/previews.md. + */ +export function parsePreviewGovernanceField( + raw: unknown, + kind: "ttl" | "idle_teardown", +): + | { ok: true; value: string | undefined } + | { ok: false; issue: GovernanceFieldIssue } { + if (raw === undefined) return { ok: true, value: undefined }; + if (typeof raw !== "string") { + return { + ok: false, + issue: { + code: kind === "ttl" ? "invalid_ttl" : "invalid_idle_teardown", + raw: String(raw), + }, + }; + } + const trimmed = raw.trim(); + if (trimmed === "") { + return { + ok: false, + issue: { + code: kind === "ttl" ? "invalid_ttl" : "invalid_idle_teardown", + raw, + }, + }; + } + if (trimmed.toLowerCase() === "off") return { ok: true, value: "off" }; + if (parseDurationMs(trimmed) === null) { + return { + ok: false, + issue: { + code: kind === "ttl" ? "invalid_ttl" : "invalid_idle_teardown", + raw, + }, + }; + } + return { ok: true, value: trimmed }; +} + +/** Gateway-level parse: empty/off means unbounded, otherwise a duration. */ +export function parseGatewayDurationMs( + envName: string, + raw: string | undefined, +): number | null { + const trimmed = raw?.trim() ?? ""; + if (trimmed === "" || trimmed.toLowerCase() === "off") return null; + const ms = parseDurationMs(trimmed); + if (ms === null) { + throw new Error( + `Invalid ${envName}: expected a duration (e.g. 7d, 2h, 30m) or off, got ${JSON.stringify(raw ?? "")}`, + ); + } + return ms; +} + +export function parseGatewayCap( + envName: string, + raw: string | undefined, +): number | null { + const trimmed = raw?.trim() ?? ""; + if (trimmed === "" || trimmed.toLowerCase() === "off") return null; + const value = Number(trimmed); + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`Invalid ${envName}: must be a positive integer or off`); + } + return value; +} + +/** Manifest override wins; "off" at the effective level disables. */ +export function resolveGovernanceMs( + manifestRaw: string | undefined, + gatewayMs: number | null, +): number | null { + if (manifestRaw !== undefined) { + if (manifestRaw.trim().toLowerCase() === "off") return null; + const ms = parseDurationMs(manifestRaw); + if (ms === null) throw new Error(`invalid governance duration ${manifestRaw}`); + return ms; + } + return gatewayMs; +} + +export function computeExpiresAtMs( + lastActivityMs: number, + ttlMs: number | null, + idleMs: number | null, +): number | null { + const deadlines: number[] = []; + if (ttlMs !== null) deadlines.push(lastActivityMs + ttlMs); + if (idleMs !== null) deadlines.push(lastActivityMs + idleMs); + if (deadlines.length === 0) return null; + return Math.min(...deadlines); +} + +export function connectionProjection(input: { + activePreviews: number; + perPreview: number | null; + ceiling: number | null; +}): { projected: number | null; over: boolean } { + if (input.perPreview === null || input.ceiling === null) { + return { projected: null, over: false }; + } + const projected = (input.activePreviews + 1) * input.perPreview; + return { projected, over: projected > input.ceiling }; +} + +export function previewLimitDetail(input: { + cap: string; + value: number; + count: number; +}): string { + return `${input.cap} limit reached (limit ${input.value}, current ${input.count})`; +} + +export function connectionBudgetDetail(input: { + projected: number; + ceiling: number; + perPreview: number; + active: number; +}): string { + return ( + `preview connection budget exceeded (projected ${input.projected} > ceiling ${input.ceiling}; ` + + `${input.active} active previews + 1 x ${input.perPreview} per preview)` + ); +} diff --git a/packages/preview-env/src/index.ts b/packages/preview-env/src/index.ts index 66075d3a..fdccd637 100644 --- a/packages/preview-env/src/index.ts +++ b/packages/preview-env/src/index.ts @@ -111,6 +111,17 @@ export { type PreviewVolumeIssue, } from "./volumes.ts"; +export { + computeExpiresAtMs, + connectionBudgetDetail, + governanceIssueMessage, + parseGatewayCap, + parseGatewayDurationMs, + parsePreviewGovernanceField, + previewLimitDetail, + resolveGovernanceMs, +} from "./governance.ts"; + export { DEFAULT_MAIL_FROM_DOMAIN, deriveMailFromName, From 3229aefd3cef30aac782a33d96870f66119e86c5 Mon Sep 17 00:00:00 2001 From: simpros <38320875+simpros@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:53:04 +0200 Subject: [PATCH 02/10] address review feedback on #265 Require closeRunning input so the close bring-up path refreshes governance instead of wiping it; derive sweep expiry from activity plus stored durations and drop the stored-deadline branch; extract preview/admission as the single deploy admission gate over one row load; share connectionProjection/governanceStatus between deploy and sweep; remove the dead ProvisionInput.governance field; dedupe removePreview/tryRemovePreview and type the try-lock honestly; unify on one required-field GovernanceConfig from preview-env. --- apps/server/src/config.test.ts | 18 ++ apps/server/src/config.ts | 32 ++- apps/server/src/http/app.ts | 9 +- apps/server/src/http/deploy.ts | 44 +--- apps/server/src/preview/admission.ts | 100 +++++++++ apps/server/src/preview/bring-up.ts | 10 +- apps/server/src/preview/lifecycle.ts | 211 +++---------------- apps/server/src/preview/locks.ts | 7 +- apps/server/src/preview/runtime.mail.test.ts | 12 ++ apps/server/src/preview/types.ts | 15 +- apps/server/src/sweep/governance.test.ts | 24 ++- apps/server/src/sweep/reconcile.ts | 96 ++++----- apps/server/src/sweep/start.ts | 7 +- packages/preview-env/src/governance.test.ts | 22 ++ packages/preview-env/src/governance.ts | 70 ++++++ packages/preview-env/src/index.ts | 7 +- 16 files changed, 376 insertions(+), 308 deletions(-) create mode 100644 apps/server/src/preview/admission.ts diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index e728d09e..c5f98fa3 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -398,6 +398,12 @@ describe("loadConfig", () => { dashboard: { enabled: false, user: "", password: "" }, telemetry: { enabled: true, endpoint: "", auth: "" }, otlp: { endpoint: "", headers: {} }, + previewTtlMs: null, + previewIdleMs: null, + maxPreviewsPerRepo: null, + maxPreviews: null, + previewMaxDbConnections: null, + postgresMaxConnections: null, }); expect(summary.githubToken).toBe("[unset]"); expect(summary.gitlabToken).toBe("[unset]"); @@ -441,6 +447,12 @@ describe("loadConfig", () => { }, telemetry: { enabled: true, endpoint: "", auth: "" }, otlp: { endpoint: "", headers: {} }, + previewTtlMs: null, + previewIdleMs: null, + maxPreviewsPerRepo: null, + maxPreviews: null, + previewMaxDbConnections: null, + postgresMaxConnections: null, }); expect(String(summary.previewPostgresUrl)).not.toContain("sekrit"); @@ -482,6 +494,12 @@ describe("loadConfig", () => { dashboard: { enabled: false, user: "", password: "" }, telemetry: { enabled: true, endpoint: "", auth: "" }, otlp: { endpoint: "", headers: {} }, + previewTtlMs: null, + previewIdleMs: null, + maxPreviewsPerRepo: null, + maxPreviews: null, + previewMaxDbConnections: null, + postgresMaxConnections: null, }); expect(summary.registryPullAuthHosts).toBe(0); expect(summary.registryPullAuthFallback).toBe("[unset]"); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 8ee4da57..ec029725 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -165,6 +165,18 @@ export type Config = { telemetry: TelemetryState; /** Operator-owned trace export; active exactly when endpoint is set. */ otlp: OtlpConfig; + /** Null means off (unbounded); manifest preview.ttl overrides. */ + previewTtlMs: number | null; + /** Null means off; measured from the last successful deploy. */ + previewIdleMs: number | null; + /** Null means off (unbounded). */ + maxPreviewsPerRepo: number | null; + /** Null means off (unbounded). */ + maxPreviews: number | null; + /** Expected per-preview DB connections; null means no budget. */ + previewMaxDbConnections: number | null; + /** Instance ceiling for the budget arithmetic; null means no budget. */ + postgresMaxConnections: number | null; /** Null/undefined means off (unbounded); manifest preview.ttl overrides. */ previewTtlMs?: number | null; /** Null/undefined means off; measured from the last successful deploy. */ @@ -177,6 +189,18 @@ export type Config = { previewMaxDbConnections?: number | null; /** Instance ceiling for the budget arithmetic; null/undefined means no budget. */ postgresMaxConnections?: number | null; + /** Null means off (unbounded); manifest preview.ttl overrides. */ + previewTtlMs: number | null; + /** Null means off; measured from the last successful deploy. */ + previewIdleMs: number | null; + /** Null means off (unbounded). */ + maxPreviewsPerRepo: number | null; + /** Null means off (unbounded). */ + maxPreviews: number | null; + /** Expected per-preview DB connections; null means no budget. */ + previewMaxDbConnections: number | null; + /** Instance ceiling for the budget arithmetic; null means no budget. */ + postgresMaxConnections: number | null; }; function parsePositiveInt( @@ -648,10 +672,10 @@ export function loadConfig(): Config { /** Loud boot warning when the gateway runs unbounded. */ export function governanceUnboundedWarning(config: Config): string | null { if ( - (config.previewTtlMs ?? null) === null && - (config.previewIdleMs ?? null) === null && - (config.maxPreviewsPerRepo ?? null) === null && - (config.maxPreviews ?? null) === null + config.previewTtlMs === null && + config.previewIdleMs === null && + config.maxPreviewsPerRepo === null && + config.maxPreviews === null ) { return ( "preview governance is unbounded (SPROUT_PREVIEW_TTL, " + diff --git a/apps/server/src/http/app.ts b/apps/server/src/http/app.ts index eeb6590e..83e4f832 100644 --- a/apps/server/src/http/app.ts +++ b/apps/server/src/http/app.ts @@ -30,13 +30,6 @@ export function startServer(deps: ServerDeps) { extraGitlabHosts: deps.config.extraGitlabHosts, telemetry: deps.telemetry ?? NO_TELEMETRY, ...(deps.tracesPlugin ? { tracesPlugin: deps.tracesPlugin } : {}), - governance: { - previewTtlMs: deps.config.previewTtlMs, - previewIdleMs: deps.config.previewIdleMs, - maxPreviewsPerRepo: deps.config.maxPreviewsPerRepo, - maxPreviews: deps.config.maxPreviews, - previewMaxDbConnections: deps.config.previewMaxDbConnections, - postgresMaxConnections: deps.config.postgresMaxConnections, - }, + governance: deps.config, }).listen(deps.config.port); } diff --git a/apps/server/src/http/deploy.ts b/apps/server/src/http/deploy.ts index f1c21d51..c5f3aa7b 100644 --- a/apps/server/src/http/deploy.ts +++ b/apps/server/src/http/deploy.ts @@ -21,12 +21,12 @@ import { previewVolumeIssueMessage, requiresDatabase, resolveDbRoles, - resolveGovernanceMs, resolveHealthSpec, seedRequiresDatabaseMessage, validateHostname, type DbRolesMode, type DbSpec, + type GovernanceManifest, type HealthRequest, type MailSpec, type PreviewAuthSpec, @@ -44,10 +44,8 @@ import { postgresNotConfiguredDetail, previewAuthNotConfiguredDetail, } from "../config.ts"; +import { checkDeployAdmission } from "../preview/admission.ts"; import { - checkConnectionBudget, - checkPreviewCaps, - resolveEffectiveGovernanceMs, teardownPreview, type GovernanceConfig, type LifecycleDeps, @@ -491,7 +489,7 @@ export function resolvePreviewVolumesRequest( export function resolveGovernanceRequest( body: Pick, ): - | { ok: true; value: { ttl?: string; idle_teardown?: string } } + | { ok: true; value: GovernanceManifest } | { ok: false; error: string; detail?: string } { const ttl = parsePreviewGovernanceField(body.ttl, "ttl"); if (!ttl.ok) { @@ -618,41 +616,14 @@ export function deploy( if (!governance.ok) { return unprocessable(set, governance); } - const gatewayGov: GovernanceConfig = { - previewTtlMs: deps.governance?.previewTtlMs ?? null, - previewIdleMs: deps.governance?.previewIdleMs ?? null, - maxPreviewsPerRepo: deps.governance?.maxPreviewsPerRepo ?? null, - maxPreviews: deps.governance?.maxPreviews ?? null, - previewMaxDbConnections: - deps.governance?.previewMaxDbConnections ?? null, - postgresMaxConnections: deps.governance?.postgresMaxConnections ?? null, - }; - const caps = await checkPreviewCaps( + const admission = await checkDeployAdmission( deps.db, { repo: target.value.repo, prId: target.value.prId }, - gatewayGov, - ); - if (!caps.ok) { - set.status = caps.status; - return caps.detail - ? { error: caps.error, detail: caps.detail } - : { error: caps.error }; - } - const budget = await checkConnectionBudget( - deps.db, - { repo: target.value.repo, prId: target.value.prId }, - gatewayGov, - ); - if (!budget.ok) { - set.status = budget.status; - return budget.detail - ? { error: budget.error, detail: budget.detail } - : { error: budget.error }; - } - const governanceMs = resolveEffectiveGovernanceMs( + deps.governance, governance.value, - gatewayGov, ); + if (!admission.ok) return mapResult(admission, set); + const governanceMs = admission.value; const plan = resolvePreviewPlan(deps.materialization, { spec: deploySpecs.value.spec, slug: body.slug, @@ -705,7 +676,6 @@ export function deploy( : {}), plan, reseed: body.reseed === true, - governance: governance.value, governanceMs, ...(deps.materialization.traefikTls !== undefined ? { traefikTls: deps.materialization.traefikTls } diff --git a/apps/server/src/preview/admission.ts b/apps/server/src/preview/admission.ts new file mode 100644 index 00000000..ea066d32 --- /dev/null +++ b/apps/server/src/preview/admission.ts @@ -0,0 +1,100 @@ +import { ne } from "drizzle-orm"; +import { + connectionBudgetDetail, + connectionProjection, + governanceStatus, + previewLimitDetail, + resolveEffectiveGovernanceMs, + type GovernanceConfig, + type GovernanceManifest, + type GovernanceStatus, +} from "@sprout/preview-env"; +import type { StateDb } from "../infrastructure/db/client.ts"; +import { previews } from "../infrastructure/db/schema.ts"; +import type { Result } from "./result.ts"; + +export type DeployAdmission = { ttlMs: number | null; idleMs: number | null }; + +/** + * Single admission gate for deploy: per-repo/total caps plus the connection + * budget, over one load of the live rows. Refreshing an existing preview + * never counts against the caps. Returns the effective governance. + */ +export async function checkDeployAdmission( + db: StateDb, + input: { repo: string; prId: number }, + gov: GovernanceConfig | undefined, + manifest: GovernanceManifest | undefined, +): Promise> { + const rows = await db + .select({ + canonicalRepoId: previews.canonicalRepoId, + prId: previews.prId, + }) + .from(previews) + .where(ne(previews.status, "removed")); + const isNew = !rows.some( + (row) => row.canonicalRepoId === input.repo && row.prId === input.prId, + ); + const status: GovernanceStatus = governanceStatus(rows, gov); + + if (isNew) { + const perRepo = gov?.maxPreviewsPerRepo ?? null; + if (perRepo !== null) { + const count = status.byRepo.get(input.repo) ?? 0; + if (count >= perRepo) { + return { + ok: false, + status: 429, + error: "preview_limit_reached", + detail: previewLimitDetail({ + cap: "SPROUT_MAX_PREVIEWS_PER_REPO", + value: perRepo, + count, + }), + }; + } + } + const total = gov?.maxPreviews ?? null; + if (total !== null && status.total >= total) { + return { + ok: false, + status: 429, + error: "preview_limit_reached", + detail: previewLimitDetail({ + cap: "SPROUT_MAX_PREVIEWS", + value: total, + count: status.total, + }), + }; + } + } + + const perPreview = gov?.previewMaxDbConnections ?? null; + const ceiling = gov?.postgresMaxConnections ?? null; + if (perPreview !== null && ceiling !== null) { + const budget = connectionProjection({ + activePreviews: isNew ? status.total : status.total - 1, + perPreview, + ceiling, + }); + if (budget.over && budget.projected !== null) { + return { + ok: false, + status: 429, + error: "preview_connection_budget_exceeded", + detail: connectionBudgetDetail({ + projected: budget.projected, + ceiling, + perPreview, + active: status.total, + }), + }; + } + } + + return { + ok: true, + value: resolveEffectiveGovernanceMs(manifest, gov), + }; +} diff --git a/apps/server/src/preview/bring-up.ts b/apps/server/src/preview/bring-up.ts index 1f479573..42764b29 100644 --- a/apps/server/src/preview/bring-up.ts +++ b/apps/server/src/preview/bring-up.ts @@ -193,7 +193,7 @@ function expiryForActivity( async function closeRunning( deps: LifecycleDeps, row: PreviewRow, - input?: ProvisionInput, + input: ProvisionInput, ): Promise> { const now = utcIsoNow(); const updated = await updatePreviewRow( @@ -204,9 +204,9 @@ async function closeRunning( bringUpPlan: null, ...clearLastError, lastActivityAt: now, - expiresAt: expiryForActivity(now, input?.governanceMs), - ttlMs: input?.governanceMs?.ttlMs ?? null, - idleMs: input?.governanceMs?.idleMs ?? null, + expiresAt: expiryForActivity(now, input.governanceMs), + ttlMs: input.governanceMs?.ttlMs ?? null, + idleMs: input.governanceMs?.idleMs ?? null, expiryReason: null, updatedAt: now, }, @@ -443,7 +443,7 @@ export async function completeBringUp( case "sync_close": return syncThenCloseRunning(deps, row, input); case "close": - return closeRunning(deps, row); + return closeRunning(deps, row, input); case "full_replace": return ensureThenAttach(deps, row, input); } diff --git a/apps/server/src/preview/lifecycle.ts b/apps/server/src/preview/lifecycle.ts index 3fab3266..19c98bae 100644 --- a/apps/server/src/preview/lifecycle.ts +++ b/apps/server/src/preview/lifecycle.ts @@ -1,10 +1,5 @@ import { and, eq, ne } from "drizzle-orm"; import { previewAuthMode } from "@sprout/preview-env"; -import { - connectionBudgetDetail, - previewLimitDetail, - resolveGovernanceMs, -} from "@sprout/preview-env"; import type { StateDb } from "../infrastructure/db/client.ts"; import { previews } from "../infrastructure/db/schema.ts"; import { completeBringUp, pullImagesOutsideLock } from "./bring-up.ts"; @@ -42,11 +37,10 @@ import type { } from "./types.ts"; export type { PreviewRow }; +export type { GovernanceConfig } from "@sprout/preview-env"; export type { BringUpPlan, DisplayPreviewStatus, - GovernanceConfig, - GovernanceInput, LifecycleDeps, PreviewSnapshot, PreviewStatus, @@ -602,34 +596,41 @@ export function purgePreview( }); } -export function removePreview( +async function removePreviewUnlocked( deps: TeardownDeps, input: RemovePreviewInput, ): Promise> { - return withPreviewLock(input.repo, input.prId, async () => { - const existing = await getPreviewRow(deps.db, input.repo, input.prId); - if (!existing || existing.status === "removed") { - return { ok: true, value: false }; - } - if (existing.dbName !== input.expectedDbName) { - return { ok: true, value: false }; - } - if (existing.createdAt !== input.expectedCreatedAt) { - return { ok: true, value: false }; - } + const existing = await getPreviewRow(deps.db, input.repo, input.prId); + if (!existing || existing.status === "removed") { + return { ok: true, value: false }; + } + if (existing.dbName !== input.expectedDbName) { + return { ok: true, value: false }; + } + if (existing.createdAt !== input.expectedCreatedAt) { + return { ok: true, value: false }; + } - const status = parsePreviewStatus(existing.status); - if (!status.ok) return status; + const status = parsePreviewStatus(existing.status); + if (!status.ok) return status; - const result = await destroyPreviewRow( - deps, - existing, - "tombstone", - input.expiryReason, - ); - if (!result.ok) return result; - return { ok: true, value: true }; - }); + const result = await destroyPreviewRow( + deps, + existing, + "tombstone", + input.expiryReason, + ); + if (!result.ok) return result; + return { ok: true, value: true }; +} + +export function removePreview( + deps: TeardownDeps, + input: RemovePreviewInput, +): Promise> { + return withPreviewLock(input.repo, input.prId, () => + removePreviewUnlocked(deps, input), + ); } /** Sweep expiry never steals a preview mid-deploy: skip when the lock is held. */ @@ -637,156 +638,14 @@ export function tryRemovePreview( deps: TeardownDeps, input: RemovePreviewInput, ): Promise> { - return tryWithPreviewLock(input.repo, input.prId, async () => { - const existing = await getPreviewRow(deps.db, input.repo, input.prId); - if (!existing || existing.status === "removed") { - return { ok: true as const, value: false }; - } - if (existing.dbName !== input.expectedDbName) { - return { ok: true as const, value: false }; - } - if (existing.createdAt !== input.expectedCreatedAt) { - return { ok: true as const, value: false }; - } - const status = parsePreviewStatus(existing.status); - if (!status.ok) return status; - const result = await destroyPreviewRow( - deps, - existing, - "tombstone", - input.expiryReason, - ); - if (!result.ok) return result; - return { ok: true as const, value: true }; - }).then((attempt) => { + return tryWithPreviewLock(input.repo, input.prId, () => + removePreviewUnlocked(deps, input), + ).then((attempt) => { if (!attempt.acquired) return { ok: true as const, value: false }; - return attempt.value!; + return attempt.value; }); } -export function resolveEffectiveGovernanceMs( - manifest: { ttl?: string; idle_teardown?: string } | undefined, - gateway: { - previewTtlMs?: number | null; - previewIdleMs?: number | null; - }, -): { ttlMs: number | null; idleMs: number | null } { - return { - ttlMs: resolveGovernanceMs(manifest?.ttl, gateway.previewTtlMs ?? null), - idleMs: resolveGovernanceMs( - manifest?.idle_teardown, - gateway.previewIdleMs ?? null, - ), - }; -} - -async function countActivePreviews(db: StateDb): Promise { - const rows = await db - .select({ prId: previews.prId }) - .from(previews) - .where(ne(previews.status, "removed")); - return rows.length; -} - -async function countRepoPreviews( - db: StateDb, - repo: string, -): Promise { - const rows = await db - .select({ prId: previews.prId }) - .from(previews) - .where(and(eq(previews.canonicalRepoId, repo), ne(previews.status, "removed"))); - return rows.length; -} - -/** - * Caps fail fast and loudly: no container and no database is created when - * the deploy would exceed a configured cap. Refreshing an existing preview - * never counts against the cap. - */ -export async function checkPreviewCaps( - db: StateDb, - input: { repo: string; prId: number }, - caps: { - maxPreviewsPerRepo?: number | null; - maxPreviews?: number | null; - }, -): Promise> { - const existing = await getPreviewRow(db, input.repo, input.prId); - if (existing && existing.status !== "removed") return { ok: true, value: true }; - const perRepo = caps.maxPreviewsPerRepo ?? null; - if (perRepo !== null) { - const count = await countRepoPreviews(db, input.repo); - if (count >= perRepo) { - return { - ok: false, - status: 429, - error: "preview_limit_reached", - detail: previewLimitDetail({ - cap: "SPROUT_MAX_PREVIEWS_PER_REPO", - value: perRepo, - count, - }), - }; - } - } - const total = caps.maxPreviews ?? null; - if (total !== null) { - const count = await countActivePreviews(db); - if (count >= total) { - return { - ok: false, - status: 429, - error: "preview_limit_reached", - detail: previewLimitDetail({ - cap: "SPROUT_MAX_PREVIEWS", - value: total, - count, - }), - }; - } - } - return { ok: true, value: true }; -} - -/** - * Connection budget: (active + 1) x per-preview vs the instance ceiling. - * The per-preview figure is only accurate for apps that honour the injected - * cap; see docs/operator-deploy.md for the measured arithmetic. - */ -export async function checkConnectionBudget( - db: StateDb, - input: { repo: string; prId: number }, - budget: { - previewMaxDbConnections?: number | null; - postgresMaxConnections?: number | null; - }, -): Promise> { - const perPreview = budget.previewMaxDbConnections ?? null; - const ceiling = budget.postgresMaxConnections ?? null; - if (perPreview === null || ceiling === null) { - return { ok: true, value: true }; - } - const existing = await getPreviewRow(db, input.repo, input.prId); - const active = await countActivePreviews(db); - const isNew = !existing || existing.status === "removed"; - const projected = (active + (isNew ? 1 : 0)) * perPreview; - if (projected > ceiling) { - return { - ok: false, - status: 429, - error: "preview_connection_budget_exceeded", - detail: connectionBudgetDetail({ - projected, - ceiling, - perPreview, - active, - }), - }; - } - return { ok: true, value: true }; -} - /** Shared claim-check-then-act unit behind both orphan droppers. */ async function dropUnlessClaimed( hasClaimant: () => Promise, diff --git a/apps/server/src/preview/locks.ts b/apps/server/src/preview/locks.ts index 0cd0b28c..a25dc1c1 100644 --- a/apps/server/src/preview/locks.ts +++ b/apps/server/src/preview/locks.ts @@ -48,10 +48,11 @@ export function tryWithPreviewLock( repo: string, prId: number, fn: () => Promise, -): Promise<{ acquired: boolean; value?: T }> { - if (isPreviewLocked(repo, prId)) return Promise.resolve({ acquired: false }); +): Promise<{ acquired: true; value: T } | { acquired: false }> { + if (isPreviewLocked(repo, prId)) + return Promise.resolve({ acquired: false as const }); return withPreviewLock(repo, prId, fn).then((value) => ({ - acquired: true, + acquired: true as const, value, })); } diff --git a/apps/server/src/preview/runtime.mail.test.ts b/apps/server/src/preview/runtime.mail.test.ts index 16f0be96..45605c80 100644 --- a/apps/server/src/preview/runtime.mail.test.ts +++ b/apps/server/src/preview/runtime.mail.test.ts @@ -243,6 +243,12 @@ describe("buildMaterializationCtx", () => { previewPortDefault: 8080, seedTimeout: 180, port: 7331, + previewTtlMs: null, + previewIdleMs: null, + maxPreviewsPerRepo: null, + maxPreviews: null, + previewMaxDbConnections: null, + postgresMaxConnections: null, previewAuth: { secret: "sekrit", address: "http://gateway:7331/v1/internal/preview-auth", @@ -269,6 +275,12 @@ describe("buildMaterializationCtx", () => { dashboard: { enabled: false, user: "", password: "" }, telemetry: { enabled: true, endpoint: "", auth: "" }, otlp: { endpoint: "", headers: {} }, + previewTtlMs: null, + previewIdleMs: null, + maxPreviewsPerRepo: null, + maxPreviews: null, + previewMaxDbConnections: null, + postgresMaxConnections: null, }); expect(bare.previewAuth).toBeUndefined(); }); diff --git a/apps/server/src/preview/types.ts b/apps/server/src/preview/types.ts index 569594f5..3bc337b3 100644 --- a/apps/server/src/preview/types.ts +++ b/apps/server/src/preview/types.ts @@ -97,19 +97,7 @@ export type PhaseTimer = { record(phase: PreviewPhase, ms: number): void; }; -export type GovernanceInput = { - ttl?: string; - idle_teardown?: string; -}; - -export type GovernanceConfig = { - previewTtlMs?: number | null; - previewIdleMs?: number | null; - maxPreviewsPerRepo?: number | null; - maxPreviews?: number | null; - previewMaxDbConnections?: number | null; - postgresMaxConnections?: number | null; -}; +export type { GovernanceConfig } from "@sprout/preview-env"; export type ProvisionInput = { repo: string; @@ -128,7 +116,6 @@ export type ProvisionInput = { traefikForwardAuth?: TraefikForwardAuth; auth?: PreviewAuthSpec; previewAuth?: PreviewAuthConfig; - governance?: GovernanceInput; governanceMs?: { ttlMs: number | null; idleMs: number | null }; }; diff --git a/apps/server/src/sweep/governance.test.ts b/apps/server/src/sweep/governance.test.ts index 07581216..130b96f6 100644 --- a/apps/server/src/sweep/governance.test.ts +++ b/apps/server/src/sweep/governance.test.ts @@ -60,6 +60,21 @@ describe("governance expiry", () => { expect(planGovernanceExpiry(p, base + 30 * 86400_000)).toBeNull(); }); + test("stale stored deadline does not outlive a refresh", () => { + const base = Date.parse("2026-09-02T12:00:00.000Z"); + const refreshed = base + 8 * 86400_000; + const stale = preview({ + lastActivityMs: refreshed, + ttlMs: 7 * 86400_000, + idleMs: null, + expiresAtMs: base + 7 * 86400_000, + }); + expect(planGovernanceExpiry(stale, base + 8 * 86400_000)).toBeNull(); + expect( + planGovernanceExpiry(stale, refreshed + 7 * 86400_000), + ).toBe("sweep:ttl-expired"); + }); + test("sweep removes expired preview without forge call", async () => { const base = Date.parse("2026-09-02T12:00:00.000Z"); setSystemTime(new Date(base + 8 * 86400_000)); @@ -105,7 +120,14 @@ describe("governance expiry", () => { listOpenPrIds: async () => [5, 6], drop: async () => true, ttlHours: 72 * 365, - governance: { maxPreviews: 1, maxPreviewsPerRepo: 1 }, + governance: { + previewTtlMs: null, + previewIdleMs: null, + maxPreviews: 1, + maxPreviewsPerRepo: 1, + previewMaxDbConnections: null, + postgresMaxConnections: null, + }, log: (m) => { logs.push(m); }, diff --git a/apps/server/src/sweep/reconcile.ts b/apps/server/src/sweep/reconcile.ts index 900bcd5b..cb278fcd 100644 --- a/apps/server/src/sweep/reconcile.ts +++ b/apps/server/src/sweep/reconcile.ts @@ -1,4 +1,5 @@ -import type { DataVolumeRef } from "@sprout/preview-env"; +import type { DataVolumeRef, GovernanceConfig } from "@sprout/preview-env"; +import { governanceStatus } from "@sprout/preview-env"; import { isForgeApiError } from "../forge/types.ts"; export type SweepReason = @@ -52,12 +53,7 @@ export type SweepPorts = { /** True if resources were removed; false if the plan was stale. */ drop: (deletion: SweepDeletion) => Promise; ttlHours: number; - governance?: { - maxPreviewsPerRepo?: number | null; - maxPreviews?: number | null; - previewMaxDbConnections?: number | null; - postgresMaxConnections?: number | null; - }; + governance?: GovernanceConfig; log?: (message: string, deletion?: SweepDeletion) => void; }; @@ -284,42 +280,36 @@ function activityBaseMs(preview: SweepPreview): number | null { } /** - * Governance expiry from the stored per-preview deadlines. The cheap activity - * signal is the last successful deploy (including reseed/reset); see - * docs/previews.md. Returns the reason to expire with, or null to keep. + * Governance expiry derived from the last activity plus the stored + * per-preview durations. The stored expires_at is write-only for the read + * surface: deriving here keeps a stale deadline from outliving a refresh. + * The cheap activity signal is the last successful deploy (including + * reseed/reset); see docs/previews.md. Returns the reason, or null to keep. */ export function planGovernanceExpiry( preview: SweepPreview, nowMs: number, ): "sweep:ttl-expired" | "sweep:idle-expired" | null { - const expiresAtMs = preview.expiresAtMs ?? null; - const ttlMs = preview.ttlMs ?? null; - const idleMs = preview.idleMs ?? null; - if (expiresAtMs !== null) { - if (nowMs < expiresAtMs) return null; - const base = activityBaseMs(preview); - if (base === null) return "sweep:ttl-expired"; - const ttlDeadline = ttlMs !== null ? base + ttlMs : null; - const idleDeadline = idleMs !== null ? base + idleMs : null; - const ttlExpired = ttlDeadline !== null && nowMs >= ttlDeadline; - const idleExpired = idleDeadline !== null && nowMs >= idleDeadline; - if (ttlExpired && idleExpired) { - return ttlDeadline! <= idleDeadline! - ? "sweep:ttl-expired" - : "sweep:idle-expired"; - } - if (ttlExpired) return "sweep:ttl-expired"; - if (idleExpired) return "sweep:idle-expired"; - return "sweep:ttl-expired"; - } - // Back-compat for rows written before governance columns existed: derive - // from last activity (or createdAt) plus stored per-preview durations. const base = activityBaseMs(preview); if (base === null) return null; - if (ttlMs !== null && nowMs >= base + ttlMs) { + const ttlDeadline = + preview.ttlMs != null ? base + preview.ttlMs : null; + const idleDeadline = + preview.idleMs != null ? base + preview.idleMs : null; + if ( + ttlDeadline !== null && + idleDeadline !== null && + nowMs >= ttlDeadline && + nowMs >= idleDeadline + ) { + return ttlDeadline <= idleDeadline + ? "sweep:ttl-expired" + : "sweep:idle-expired"; + } + if (ttlDeadline !== null && nowMs >= ttlDeadline) { return "sweep:ttl-expired"; } - if (idleMs !== null && nowMs >= base + idleMs) { + if (idleDeadline !== null && nowMs >= idleDeadline) { return "sweep:idle-expired"; } return null; @@ -332,19 +322,16 @@ function logOverCap( const gov = ports.governance; if (!gov) return; const live = previews.filter((p) => p.status !== "removed"); - const maxTotal = gov.maxPreviews ?? null; - if (maxTotal !== null && live.length > maxTotal) { + const status = governanceStatus(live, gov); + const maxTotal = gov.maxPreviews; + if (maxTotal !== null && status.total > maxTotal) { ports.log?.( - `sweep over cap SPROUT_MAX_PREVIEWS (${live.length} > ${maxTotal})`, + `sweep over cap SPROUT_MAX_PREVIEWS (${status.total} > ${maxTotal})`, ); } - const perRepo = gov.maxPreviewsPerRepo ?? null; + const perRepo = gov.maxPreviewsPerRepo; if (perRepo !== null) { - const byRepo = new Map(); - for (const p of live) { - byRepo.set(p.canonicalRepoId, (byRepo.get(p.canonicalRepoId) ?? 0) + 1); - } - for (const [repo, count] of byRepo) { + for (const [repo, count] of status.byRepo) { if (count > perRepo) { ports.log?.( `sweep over cap SPROUT_MAX_PREVIEWS_PER_REPO ${repo} (${count} > ${perRepo})`, @@ -352,15 +339,18 @@ function logOverCap( } } } - const perPreview = gov.previewMaxDbConnections ?? null; - const ceiling = gov.postgresMaxConnections ?? null; - if (perPreview !== null && ceiling !== null) { - const projected = live.length * perPreview; - if (projected > ceiling) { - ports.log?.( - `sweep over connection budget (projected ${projected} > ceiling ${ceiling}; ` + - `${live.length} previews x ${perPreview} per preview)`, - ); - } + const perPreview = gov.previewMaxDbConnections; + const ceiling = gov.postgresMaxConnections; + const { projected, over } = status.connections; + if ( + over && + projected !== null && + perPreview !== null && + ceiling !== null + ) { + ports.log?.( + `sweep over connection budget (projected ${projected} > ceiling ${ceiling}; ` + + `${status.total} previews x ${perPreview} per preview)`, + ); } } diff --git a/apps/server/src/sweep/start.ts b/apps/server/src/sweep/start.ts index f436e037..9faa4401 100644 --- a/apps/server/src/sweep/start.ts +++ b/apps/server/src/sweep/start.ts @@ -27,12 +27,7 @@ export function startGatewaySweep(deps: { dataVolumes: deps.dataVolumes, forge, ttlHours: deps.config.ttlHours, - governance: { - maxPreviewsPerRepo: deps.config.maxPreviewsPerRepo, - maxPreviews: deps.config.maxPreviews, - previewMaxDbConnections: deps.config.previewMaxDbConnections, - postgresMaxConnections: deps.config.postgresMaxConnections, - }, + governance: deps.config, log: (message, deletion) => { if (deletion) console.log(message, deletion); else console.log(message); diff --git a/packages/preview-env/src/governance.test.ts b/packages/preview-env/src/governance.test.ts index 2d9ee793..1796859c 100644 --- a/packages/preview-env/src/governance.test.ts +++ b/packages/preview-env/src/governance.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { computeExpiresAtMs, connectionProjection, + governanceStatus, parseGatewayCap, parseGatewayDurationMs, parsePreviewGovernanceField, @@ -88,4 +89,25 @@ describe("preview governance durations", () => { connectionProjection({ activePreviews: 8, perPreview: null, ceiling: 100 }), ).toEqual({ projected: null, over: false }); }); + + test("governance status counts once for caps and current usage", () => { + const previews = [ + { canonicalRepoId: "a" }, + { canonicalRepoId: "a" }, + { canonicalRepoId: "b" }, + ]; + const status = governanceStatus(previews, { + maxPreviews: 10, + maxPreviewsPerRepo: 10, + previewMaxDbConnections: 12, + postgresMaxConnections: 100, + }); + expect(status.total).toBe(3); + expect(status.byRepo.get("a")).toBe(2); + expect(status.connections).toEqual({ projected: 36, over: false }); + expect(governanceStatus(previews, undefined).connections).toEqual({ + projected: null, + over: false, + }); + }); }); diff --git a/packages/preview-env/src/governance.ts b/packages/preview-env/src/governance.ts index 082981d0..aa994d0d 100644 --- a/packages/preview-env/src/governance.ts +++ b/packages/preview-env/src/governance.ts @@ -20,6 +20,22 @@ function parseDurationMs(raw: string): number | null { return ms; } +/** Gateway-level governance: every field resolved, null means off. */ +export type GovernanceConfig = { + previewTtlMs: number | null; + previewIdleMs: number | null; + maxPreviewsPerRepo: number | null; + maxPreviews: number | null; + previewMaxDbConnections: number | null; + postgresMaxConnections: number | null; +}; + +/** Manifest-level governance overrides; undefined means inherit the gateway. */ +export type GovernanceManifest = { + ttl?: string; + idle_teardown?: string; +}; + type GovernanceFieldIssue = { code: "invalid_ttl" | "invalid_idle_teardown"; raw: string; @@ -119,6 +135,22 @@ export function resolveGovernanceMs( return gatewayMs; } +/** Effective per-preview deadlines from manifest overrides over the gateway. */ +export function resolveEffectiveGovernanceMs( + manifest: GovernanceManifest | undefined, + gateway: + | Pick + | undefined, +): { ttlMs: number | null; idleMs: number | null } { + return { + ttlMs: resolveGovernanceMs(manifest?.ttl, gateway?.previewTtlMs ?? null), + idleMs: resolveGovernanceMs( + manifest?.idle_teardown, + gateway?.previewIdleMs ?? null, + ), + }; +} + export function computeExpiresAtMs( lastActivityMs: number, ttlMs: number | null, @@ -143,6 +175,44 @@ export function connectionProjection(input: { return { projected, over: projected > input.ceiling }; } +export type GovernanceStatus = { + total: number; + byRepo: Map; + connections: { projected: number | null; over: boolean }; +}; + +/** One pass over live previews for cap and budget checks; deploy and sweep share it. */ +export function governanceStatus( + previews: readonly { canonicalRepoId: string }[], + gov: + | Pick< + GovernanceConfig, + | "maxPreviews" + | "maxPreviewsPerRepo" + | "previewMaxDbConnections" + | "postgresMaxConnections" + > + | undefined, +): GovernanceStatus { + const byRepo = new Map(); + for (const preview of previews) { + byRepo.set( + preview.canonicalRepoId, + (byRepo.get(preview.canonicalRepoId) ?? 0) + 1, + ); + } + const total = previews.length; + const perPreview = gov?.previewMaxDbConnections ?? null; + const ceiling = gov?.postgresMaxConnections ?? null; + let projected: number | null = null; + let over = false; + if (perPreview !== null && ceiling !== null) { + projected = total * perPreview; + over = projected > ceiling; + } + return { total, byRepo, connections: { projected, over } }; +} + export function previewLimitDetail(input: { cap: string; value: number; diff --git a/packages/preview-env/src/index.ts b/packages/preview-env/src/index.ts index fdccd637..da110d59 100644 --- a/packages/preview-env/src/index.ts +++ b/packages/preview-env/src/index.ts @@ -114,12 +114,17 @@ export { export { computeExpiresAtMs, connectionBudgetDetail, + connectionProjection, governanceIssueMessage, + governanceStatus, parseGatewayCap, parseGatewayDurationMs, parsePreviewGovernanceField, previewLimitDetail, - resolveGovernanceMs, + resolveEffectiveGovernanceMs, + type GovernanceConfig, + type GovernanceManifest, + type GovernanceStatus, } from "./governance.ts"; export { From 9e5138ddd6a405846b82d32662863b6fdc3fed94 Mon Sep 17 00:00:00 2001 From: simpros <38320875+simpros@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:19:07 +0200 Subject: [PATCH 03/10] address review feedback on #265 Round-3 findings plus the remaining round-2 cleanups: - Single connection-budget owner: drop connections from GovernanceStatus; admission and sweep project through connectionProjection. Refresh deploys are exempt from caps and budget alike, fixing the off-by-one 429 detail. - Delete the stored expires_at column: last_activity_at plus ttl_ms/idle_ms is the single source, derived at the read surface (snapshot expiresAtForRow) and in planGovernanceExpiry via computeExpiresAtMs. Squash the two same-feature migrations. - Tombstone policy is explicit: PreviewExpiryReason types the removal cause; destroyPreviewRow always writes the same tombstone shape and keeps the seed watermark for writeProvisioningIntent to reset. - One EffectiveGovernanceMs in preview-env replaces DeployAdmission and the redeclared shapes; ProvisionInput.governanceMs is required. - GovernanceConfig is required end to end; Config composes it instead of re-declaring the fields; single canonical import path. --- .../migration.sql | 5 +- .../snapshot.json | 16 +- .../20261002113921_lush_solo/migration.sql | 2 - .../20261002113921_lush_solo/snapshot.json | 371 ------------------ apps/server/src/http/auth.test.ts | 8 + apps/server/src/http/deploy.ts | 4 +- .../server/src/http/preview-lifecycle.test.ts | 33 ++ apps/server/src/http/routes.ts | 10 +- apps/server/src/http/test-helpers.ts | 10 + apps/server/src/infrastructure/db/schema.ts | 1 - apps/server/src/preview/admission.test.ts | 70 ++++ apps/server/src/preview/admission.ts | 62 +-- apps/server/src/preview/bring-up.ts | 21 +- apps/server/src/preview/lifecycle.ts | 25 +- apps/server/src/preview/locks.ts | 2 +- apps/server/src/preview/snapshot.ts | 26 +- apps/server/src/preview/types.ts | 21 +- apps/server/src/sweep/governance.test.ts | 17 +- apps/server/src/sweep/live-ports.test.ts | 23 ++ apps/server/src/sweep/live-ports.ts | 11 +- apps/server/src/sweep/reconcile.test.ts | 8 + apps/server/src/sweep/reconcile.ts | 68 ++-- packages/preview-env/src/governance.test.ts | 14 +- packages/preview-env/src/governance.ts | 50 ++- packages/preview-env/src/index.ts | 1 + 25 files changed, 329 insertions(+), 550 deletions(-) delete mode 100644 apps/server/drizzle/20261002113921_lush_solo/migration.sql delete mode 100644 apps/server/drizzle/20261002113921_lush_solo/snapshot.json create mode 100644 apps/server/src/preview/admission.test.ts diff --git a/apps/server/drizzle/20261002113607_acoustic_deathstrike/migration.sql b/apps/server/drizzle/20261002113607_acoustic_deathstrike/migration.sql index c051c53a..acd9b048 100644 --- a/apps/server/drizzle/20261002113607_acoustic_deathstrike/migration.sql +++ b/apps/server/drizzle/20261002113607_acoustic_deathstrike/migration.sql @@ -1,3 +1,4 @@ ALTER TABLE `previews` ADD `last_activity_at` text;--> statement-breakpoint -ALTER TABLE `previews` ADD `expires_at` text;--> statement-breakpoint -ALTER TABLE `previews` ADD `expiry_reason` text; \ No newline at end of file +ALTER TABLE `previews` ADD `expiry_reason` text;--> statement-breakpoint +ALTER TABLE `previews` ADD `ttl_ms` integer;--> statement-breakpoint +ALTER TABLE `previews` ADD `idle_ms` integer; diff --git a/apps/server/drizzle/20261002113607_acoustic_deathstrike/snapshot.json b/apps/server/drizzle/20261002113607_acoustic_deathstrike/snapshot.json index 43d49d53..da33b0f2 100644 --- a/apps/server/drizzle/20261002113607_acoustic_deathstrike/snapshot.json +++ b/apps/server/drizzle/20261002113607_acoustic_deathstrike/snapshot.json @@ -274,17 +274,27 @@ "autoincrement": false, "default": null, "generated": null, - "name": "expires_at", + "name": "expiry_reason", "entityType": "columns", "table": "previews" }, { - "type": "text", + "type": "integer", "notNull": false, "autoincrement": false, "default": null, "generated": null, - "name": "expiry_reason", + "name": "ttl_ms", + "entityType": "columns", + "table": "previews" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "idle_ms", "entityType": "columns", "table": "previews" }, diff --git a/apps/server/drizzle/20261002113921_lush_solo/migration.sql b/apps/server/drizzle/20261002113921_lush_solo/migration.sql deleted file mode 100644 index ec88de82..00000000 --- a/apps/server/drizzle/20261002113921_lush_solo/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE `previews` ADD `ttl_ms` integer;--> statement-breakpoint -ALTER TABLE `previews` ADD `idle_ms` integer; \ No newline at end of file diff --git a/apps/server/drizzle/20261002113921_lush_solo/snapshot.json b/apps/server/drizzle/20261002113921_lush_solo/snapshot.json deleted file mode 100644 index 1a07ffe4..00000000 --- a/apps/server/drizzle/20261002113921_lush_solo/snapshot.json +++ /dev/null @@ -1,371 +0,0 @@ -{ - "version": "7", - "dialect": "sqlite", - "id": "bd46096d-d926-4068-bc49-27a1d0232fb7", - "prevIds": [ - "e0a78feb-0129-4236-bc9c-cd12a06e9748" - ], - "ddl": [ - { - "name": "api_tokens", - "entityType": "tables" - }, - { - "name": "previews", - "entityType": "tables" - }, - { - "name": "repos", - "entityType": "tables" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "token_hash", - "entityType": "columns", - "table": "api_tokens" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "scope", - "entityType": "columns", - "table": "api_tokens" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "canonical_repo_id", - "entityType": "columns", - "table": "api_tokens" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", - "generated": null, - "name": "created_at", - "entityType": "columns", - "table": "api_tokens" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "revoked_at", - "entityType": "columns", - "table": "api_tokens" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "canonical_repo_id", - "entityType": "columns", - "table": "previews" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "pr_id", - "entityType": "columns", - "table": "previews" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "slug", - "entityType": "columns", - "table": "previews" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "db_name", - "entityType": "columns", - "table": "previews" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": "'postgres'", - "generated": null, - "name": "db_provider", - "entityType": "columns", - "table": "previews" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "hostname", - "entityType": "columns", - "table": "previews" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "app_image", - "entityType": "columns", - "table": "previews" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "container_id", - "entityType": "columns", - "table": "previews" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "status", - "entityType": "columns", - "table": "previews" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", - "generated": null, - "name": "created_at", - "entityType": "columns", - "table": "previews" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", - "generated": null, - "name": "updated_at", - "entityType": "columns", - "table": "previews" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "seeded_at", - "entityType": "columns", - "table": "previews" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "seeded_seed_image", - "entityType": "columns", - "table": "previews" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "last_error", - "entityType": "columns", - "table": "previews" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "last_error_detail", - "entityType": "columns", - "table": "previews" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "failure_family", - "entityType": "columns", - "table": "previews" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "bring_up_plan", - "entityType": "columns", - "table": "previews" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "seed_log", - "entityType": "columns", - "table": "previews" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "mail_from", - "entityType": "columns", - "table": "previews" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "last_activity_at", - "entityType": "columns", - "table": "previews" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "expires_at", - "entityType": "columns", - "table": "previews" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "expiry_reason", - "entityType": "columns", - "table": "previews" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "ttl_ms", - "entityType": "columns", - "table": "previews" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "idle_ms", - "entityType": "columns", - "table": "previews" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "canonical_id", - "entityType": "columns", - "table": "repos" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "slug", - "entityType": "columns", - "table": "repos" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", - "generated": null, - "name": "created_at", - "entityType": "columns", - "table": "repos" - }, - { - "columns": [ - "canonical_repo_id", - "pr_id" - ], - "nameExplicit": false, - "name": "previews_pk", - "entityType": "pks", - "table": "previews" - }, - { - "columns": [ - "token_hash" - ], - "nameExplicit": false, - "name": "api_tokens_pk", - "table": "api_tokens", - "entityType": "pks" - }, - { - "columns": [ - "canonical_id" - ], - "nameExplicit": false, - "name": "repos_pk", - "table": "repos", - "entityType": "pks" - } - ], - "renames": [] -} \ No newline at end of file diff --git a/apps/server/src/http/auth.test.ts b/apps/server/src/http/auth.test.ts index 7aa361d9..4731a4eb 100644 --- a/apps/server/src/http/auth.test.ts +++ b/apps/server/src/http/auth.test.ts @@ -349,6 +349,14 @@ describe("ensureAdminToken", () => { dataVolumes: bindPreviewDataVolumes(docker), materialization: { traefikNetwork: "sprout-traefik" }, telemetry: NO_TELEMETRY, + governance: { + previewTtlMs: null, + previewIdleMs: null, + maxPreviews: null, + maxPreviewsPerRepo: null, + previewMaxDbConnections: null, + postgresMaxConnections: null, + }, }); const res = await app.handle( new Request("http://localhost/v1/admin/tokens", { diff --git a/apps/server/src/http/deploy.ts b/apps/server/src/http/deploy.ts index c5f3aa7b..bdfbf87b 100644 --- a/apps/server/src/http/deploy.ts +++ b/apps/server/src/http/deploy.ts @@ -47,10 +47,10 @@ import { import { checkDeployAdmission } from "../preview/admission.ts"; import { teardownPreview, - type GovernanceConfig, type LifecycleDeps, type PreviewSnapshot, } from "../preview/lifecycle.ts"; +import type { GovernanceConfig } from "@sprout/preview-env"; import { presentPreviewSnapshot } from "../preview/snapshot.ts"; import { resolvePreviewPlan, @@ -541,7 +541,7 @@ export function deploy( deps: LifecycleDeps & { materialization: PreviewMaterializationCtx; telemetry: TelemetryDeployHook; - governance?: GovernanceConfig; + governance: GovernanceConfig; }, ) { return async ({ diff --git a/apps/server/src/http/preview-lifecycle.test.ts b/apps/server/src/http/preview-lifecycle.test.ts index c6cb7e13..d02475c3 100644 --- a/apps/server/src/http/preview-lifecycle.test.ts +++ b/apps/server/src/http/preview-lifecycle.test.ts @@ -204,6 +204,39 @@ describe("POST /v1/deploy", () => { }); }); + test("derives expires_at from the gateway ttl on the read surface", async () => { + fakePreviewDb = createFakePreviewDb(); + fakeDocker = createFakeDockerClient({ + exposedPorts: { [APP_IMAGE]: 3000 }, + }); + testApp = await createTestApp({ + previewDb: fakePreviewDb, + docker: fakeDocker, + governance: { + previewTtlMs: 7 * 86400_000, + previewIdleMs: null, + maxPreviews: null, + maxPreviewsPerRepo: null, + previewMaxDbConnections: null, + postgresMaxConnections: null, + }, + }); + const { body } = await postDeployToken(testApp, { + canonical_repo_id: REPO, + slug: "myapp", + }); + const res = await postDeploy(body.token as string, deployBody()); + expect(res.settleStatus).toBe(200); + const snap = res.body as { + last_activity_at: string; + expires_at: string | null; + }; + expect(typeof snap.last_activity_at).toBe("string"); + expect(snap.expires_at).toBe( + new Date(Date.parse(snap.last_activity_at) + 7 * 86400_000).toISOString(), + ); + }); + test("applies preview labels alongside the gateway Traefik labels", async () => { const { deployToken } = await setup(); const res = await postDeploy( diff --git a/apps/server/src/http/routes.ts b/apps/server/src/http/routes.ts index 5dd8ea52..75f35287 100644 --- a/apps/server/src/http/routes.ts +++ b/apps/server/src/http/routes.ts @@ -4,10 +4,8 @@ import type { PreviewAppOps } from "../app-deployment/ops.ts"; import type { StateDb } from "../infrastructure/db/client.ts"; import type { DashboardConfig } from "../config.ts"; import type { PreviewDbRouter } from "../preview-db/routing.ts"; -import type { - GovernanceConfig, - LifecycleDeps, -} from "../preview/lifecycle.ts"; +import type { GovernanceConfig } from "@sprout/preview-env"; +import type { LifecycleDeps } from "../preview/lifecycle.ts"; import type { PreviewDataVolumes } from "../preview/data-volumes.ts"; import type { PreviewMaterializationCtx } from "../preview/runtime.ts"; import type { TelemetryDeployHook } from "../telemetry/contract.ts"; @@ -44,7 +42,7 @@ export type RouteDeps = { extraGitlabHosts?: ReadonlySet; telemetry: TelemetryDeployHook; tracesPlugin?: TracesHandle["plugin"]; - governance?: GovernanceConfig; + governance: GovernanceConfig; }; function stubNotImplemented({ @@ -67,7 +65,7 @@ export function createRoutes(deps: RouteDeps) { ...lifecycle, materialization: deps.materialization, telemetry: deps.telemetry, - ...(deps.governance !== undefined ? { governance: deps.governance } : {}), + governance: deps.governance, }; const mailboxUrl = deps.materialization.mail?.uiUrl; const accessDeps = { diff --git a/apps/server/src/http/test-helpers.ts b/apps/server/src/http/test-helpers.ts index 97b48cd5..5df9ca78 100644 --- a/apps/server/src/http/test-helpers.ts +++ b/apps/server/src/http/test-helpers.ts @@ -30,6 +30,7 @@ import type { PostgresConfig, PreviewAuthConfig, } from "../config.ts"; +import type { GovernanceConfig } from "@sprout/preview-env"; import { NO_TELEMETRY, type TelemetryDeployHook } from "../telemetry/contract.ts"; import type { TracesHandle } from "../telemetry/traces.ts"; @@ -109,6 +110,7 @@ export async function createTestApp( extraGitlabHosts?: ReadonlySet; telemetry?: TelemetryDeployHook; tracesPlugin?: TracesHandle["plugin"]; + governance?: GovernanceConfig; } | string = {}, ): Promise { @@ -165,6 +167,14 @@ export async function createTestApp( extraGitlabHosts: opts.extraGitlabHosts, telemetry: opts.telemetry ?? NO_TELEMETRY, ...(opts.tracesPlugin ? { tracesPlugin: opts.tracesPlugin } : {}), + governance: opts.governance ?? { + previewTtlMs: null, + previewIdleMs: null, + maxPreviews: null, + maxPreviewsPerRepo: null, + previewMaxDbConnections: null, + postgresMaxConnections: null, + }, }), db, adminToken, diff --git a/apps/server/src/infrastructure/db/schema.ts b/apps/server/src/infrastructure/db/schema.ts index 46cfe740..c25609bc 100644 --- a/apps/server/src/infrastructure/db/schema.ts +++ b/apps/server/src/infrastructure/db/schema.ts @@ -34,7 +34,6 @@ export const previews = sqliteTable( authBasicUser: text("auth_basic_user"), authBasicPassword: text("auth_basic_password"), lastActivityAt: text("last_activity_at"), - expiresAt: text("expires_at"), expiryReason: text("expiry_reason"), ttlMs: integer("ttl_ms"), idleMs: integer("idle_ms"), diff --git a/apps/server/src/preview/admission.test.ts b/apps/server/src/preview/admission.test.ts new file mode 100644 index 00000000..2bfff6b2 --- /dev/null +++ b/apps/server/src/preview/admission.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createTestDb, type TestDb } from "../http/test-helpers.ts"; +import { previews } from "../infrastructure/db/schema.ts"; +import { checkDeployAdmission } from "./admission.ts"; +import type { GovernanceConfig } from "@sprout/preview-env"; + +const REPO = "https://github.com/acme/widgets"; + +const overBudget: GovernanceConfig = { + previewTtlMs: null, + previewIdleMs: null, + maxPreviews: null, + maxPreviewsPerRepo: null, + previewMaxDbConnections: 12, + postgresMaxConnections: 20, +}; + +describe("checkDeployAdmission connection budget", () => { + let testDb: TestDb | undefined; + + afterEach(async () => { + await testDb?.cleanup(); + testDb = undefined; + }); + + async function livePreviews(...prIds: number[]): Promise { + testDb = await createTestDb(); + for (const prId of prIds) { + await testDb.db.insert(previews).values({ + canonicalRepoId: REPO, + prId, + slug: "widgets", + hostname: `pr-${prId}.example.com`, + status: "running", + }); + } + } + + test("rejects a new preview over the ceiling with a consistent detail", async () => { + await livePreviews(1); + const result = await checkDeployAdmission( + testDb!.db, + { repo: REPO, prId: 2 }, + overBudget, + undefined, + ); + expect(result).toEqual({ + ok: false, + status: 429, + error: "preview_connection_budget_exceeded", + detail: + "preview connection budget exceeded (projected 24 > ceiling 20; " + + "1 active previews + 1 x 12 per preview)", + }); + }); + + test("a refresh is exempt even when the instance is over budget", async () => { + await livePreviews(1, 2); + const result = await checkDeployAdmission( + testDb!.db, + { repo: REPO, prId: 1 }, + overBudget, + undefined, + ); + expect(result).toEqual({ + ok: true, + value: { ttlMs: null, idleMs: null }, + }); + }); +}); diff --git a/apps/server/src/preview/admission.ts b/apps/server/src/preview/admission.ts index ea066d32..dde1a525 100644 --- a/apps/server/src/preview/admission.ts +++ b/apps/server/src/preview/admission.ts @@ -5,6 +5,7 @@ import { governanceStatus, previewLimitDetail, resolveEffectiveGovernanceMs, + type EffectiveGovernanceMs, type GovernanceConfig, type GovernanceManifest, type GovernanceStatus, @@ -13,19 +14,19 @@ import type { StateDb } from "../infrastructure/db/client.ts"; import { previews } from "../infrastructure/db/schema.ts"; import type { Result } from "./result.ts"; -export type DeployAdmission = { ttlMs: number | null; idleMs: number | null }; - /** * Single admission gate for deploy: per-repo/total caps plus the connection - * budget, over one load of the live rows. Refreshing an existing preview - * never counts against the caps. Returns the effective governance. + * budget, over one load of the live rows. Refreshing an existing preview is + * exempt from caps and budget alike: it holds no new slot, and rejecting it + * could block the redeploy that would reduce connections. Returns the + * effective governance. */ export async function checkDeployAdmission( db: StateDb, input: { repo: string; prId: number }, - gov: GovernanceConfig | undefined, + gov: GovernanceConfig, manifest: GovernanceManifest | undefined, -): Promise> { +): Promise> { const rows = await db .select({ canonicalRepoId: previews.canonicalRepoId, @@ -36,10 +37,10 @@ export async function checkDeployAdmission( const isNew = !rows.some( (row) => row.canonicalRepoId === input.repo && row.prId === input.prId, ); - const status: GovernanceStatus = governanceStatus(rows, gov); + const status: GovernanceStatus = governanceStatus(rows); if (isNew) { - const perRepo = gov?.maxPreviewsPerRepo ?? null; + const perRepo = gov.maxPreviewsPerRepo; if (perRepo !== null) { const count = status.byRepo.get(input.repo) ?? 0; if (count >= perRepo) { @@ -55,7 +56,7 @@ export async function checkDeployAdmission( }; } } - const total = gov?.maxPreviews ?? null; + const total = gov.maxPreviews; if (total !== null && status.total >= total) { return { ok: false, @@ -68,28 +69,27 @@ export async function checkDeployAdmission( }), }; } - } - - const perPreview = gov?.previewMaxDbConnections ?? null; - const ceiling = gov?.postgresMaxConnections ?? null; - if (perPreview !== null && ceiling !== null) { - const budget = connectionProjection({ - activePreviews: isNew ? status.total : status.total - 1, - perPreview, - ceiling, - }); - if (budget.over && budget.projected !== null) { - return { - ok: false, - status: 429, - error: "preview_connection_budget_exceeded", - detail: connectionBudgetDetail({ - projected: budget.projected, - ceiling, - perPreview, - active: status.total, - }), - }; + const perPreview = gov.previewMaxDbConnections; + const ceiling = gov.postgresMaxConnections; + if (perPreview !== null && ceiling !== null) { + const budget = connectionProjection({ + activePreviews: status.total, + perPreview, + ceiling, + }); + if (budget.over && budget.projected !== null) { + return { + ok: false, + status: 429, + error: "preview_connection_budget_exceeded", + detail: connectionBudgetDetail({ + projected: budget.projected, + ceiling, + perPreview, + active: status.total, + }), + }; + } } } diff --git a/apps/server/src/preview/bring-up.ts b/apps/server/src/preview/bring-up.ts index 42764b29..e2f68864 100644 --- a/apps/server/src/preview/bring-up.ts +++ b/apps/server/src/preview/bring-up.ts @@ -1,5 +1,4 @@ import type { DbProvider } from "@sprout/preview-env"; -import { computeExpiresAtMs } from "@sprout/preview-env"; import type { PreviewAppOps } from "../app-deployment/ops.ts"; import { resolvePreviewAccessLabels } from "../app-deployment/preview-auth.ts"; import { extractPullDetail } from "../docker/pull-failure.ts"; @@ -175,21 +174,6 @@ async function syncPreviewServices( } } -function expiryForActivity( - activityIso: string, - governanceMs?: { ttlMs: number | null; idleMs: number | null }, -): string | null { - if (!governanceMs) return null; - const baseMs = Date.parse(activityIso); - if (!Number.isFinite(baseMs)) return null; - const expiresMs = computeExpiresAtMs( - baseMs, - governanceMs.ttlMs, - governanceMs.idleMs, - ); - return expiresMs === null ? null : new Date(expiresMs).toISOString(); -} - async function closeRunning( deps: LifecycleDeps, row: PreviewRow, @@ -204,9 +188,8 @@ async function closeRunning( bringUpPlan: null, ...clearLastError, lastActivityAt: now, - expiresAt: expiryForActivity(now, input.governanceMs), - ttlMs: input.governanceMs?.ttlMs ?? null, - idleMs: input.governanceMs?.idleMs ?? null, + ttlMs: input.governanceMs.ttlMs, + idleMs: input.governanceMs.idleMs, expiryReason: null, updatedAt: now, }, diff --git a/apps/server/src/preview/lifecycle.ts b/apps/server/src/preview/lifecycle.ts index 19c98bae..85f5cc91 100644 --- a/apps/server/src/preview/lifecycle.ts +++ b/apps/server/src/preview/lifecycle.ts @@ -4,7 +4,6 @@ import type { StateDb } from "../infrastructure/db/client.ts"; import { previews } from "../infrastructure/db/schema.ts"; import { completeBringUp, pullImagesOutsideLock } from "./bring-up.ts"; import { - isPreviewLocked, tryWithPreviewLock, withDbNameLock, withPreviewLock, @@ -27,6 +26,7 @@ import type { BringUpPlan, DisplayPreviewStatus, LifecycleDeps, + PreviewExpiryReason, PreviewSnapshot, PreviewStatus, ProvisionInput, @@ -37,11 +37,11 @@ import type { } from "./types.ts"; export type { PreviewRow }; -export type { GovernanceConfig } from "@sprout/preview-env"; export type { BringUpPlan, DisplayPreviewStatus, LifecycleDeps, + PreviewExpiryReason, PreviewSnapshot, PreviewStatus, ProvisionInput, @@ -51,7 +51,6 @@ export type { TeardownSnapshot, } from "./types.ts"; export { - isPreviewLocked, tryWithPreviewLock, withDbNameLock, withPreviewLock, @@ -438,7 +437,7 @@ async function destroyPreviewRow( deps: TeardownDeps, existing: PreviewRow, disposition: DestroyDisposition, - expiryReason?: string, + expiryReason?: PreviewExpiryReason, ): Promise> { const repo = existing.canonicalRepoId; const prId = existing.prId; @@ -481,17 +480,13 @@ async function destroyPreviewRow( .set({ status: "removed", containerId: null, - // Expiry clears the seed watermark so the next deploy provisions - // fresh and the seed re-runs; PR-close teardown keeps the same - // tombstone shape with the reason recorded for the read surface. - ...(expiryReason !== undefined - ? { - seededAt: null, - seededSeedImage: null, - expiryReason, - expiresAt: null, - } - : {}), + // The tombstone records why the preview went away; manual + // teardown leaves the reason null while sweep passes its + // deletion reason. The seed watermark is deliberately kept: + // every redeploy of a removed row goes through + // writeProvisioningIntent, which already resets it, so clearing + // here would only make the two removal paths diverge. + expiryReason: expiryReason ?? null, updatedAt: utcIsoNow(), }) .where( diff --git a/apps/server/src/preview/locks.ts b/apps/server/src/preview/locks.ts index a25dc1c1..d21ee88e 100644 --- a/apps/server/src/preview/locks.ts +++ b/apps/server/src/preview/locks.ts @@ -57,7 +57,7 @@ export function tryWithPreviewLock( })); } -export function isPreviewLocked(repo: string, prId: number): boolean { +function isPreviewLocked(repo: string, prId: number): boolean { return (previewLockCounts.get(previewKey(repo, prId)) ?? 0) > 0; } diff --git a/apps/server/src/preview/snapshot.ts b/apps/server/src/preview/snapshot.ts index d15f502d..c00c1a7b 100644 --- a/apps/server/src/preview/snapshot.ts +++ b/apps/server/src/preview/snapshot.ts @@ -1,4 +1,4 @@ -import { deriveMailFromName } from "@sprout/preview-env"; +import { computeExpiresAtMs, deriveMailFromName } from "@sprout/preview-env"; import type { Result } from "./result.ts"; import type { PreviewRow } from "./row.ts"; import type { @@ -27,6 +27,26 @@ export function parsePreviewStatus(status: string): Result { } } +/** + * Read-surface expiry: derived from the last activity plus the stored + * per-preview durations, the same source the sweep plans from. Tombstones + * report null, matching the pre-derivation display. + */ +export function expiresAtForRow( + row: Pick, +): string | null { + if (row.status === "removed") return null; + const baseMs = + row.lastActivityAt == null ? NaN : Date.parse(row.lastActivityAt); + if (!Number.isFinite(baseMs)) return null; + const expiresMs = computeExpiresAtMs( + baseMs, + row.ttlMs ?? null, + row.idleMs ?? null, + ); + return expiresMs === null ? null : new Date(expiresMs).toISOString(); +} + export function previewSnapshotFromRow(row: PreviewRow): PreviewSnapshot { const status = parsePreviewStatus(row.status); const parsed = status.ok ? status.value : "failed"; @@ -53,7 +73,7 @@ export function previewSnapshotFromRow(row: PreviewRow): PreviewSnapshot { ? { last_error_detail: row.lastErrorDetail } : {}), last_activity_at: row.lastActivityAt ?? null, - expires_at: row.expiresAt ?? null, + expires_at: expiresAtForRow(row), expiry_reason: row.expiryReason ?? null, }; } @@ -110,7 +130,7 @@ export function presentListedPreview( status, created_at: row.createdAt, last_activity_at: row.lastActivityAt ?? null, - expires_at: row.expiresAt ?? null, + expires_at: expiresAtForRow(row), expiry_reason: row.expiryReason ?? null, ...(snap.mail_from !== undefined ? { mail_from: snap.mail_from } : {}), ...(snap.mail_from_name !== undefined diff --git a/apps/server/src/preview/types.ts b/apps/server/src/preview/types.ts index 3bc337b3..2e454633 100644 --- a/apps/server/src/preview/types.ts +++ b/apps/server/src/preview/types.ts @@ -1,4 +1,9 @@ -import type { HealthSpec, PreviewAuthSpec, PreviewLabels } from "@sprout/preview-env"; +import type { + EffectiveGovernanceMs, + HealthSpec, + PreviewAuthSpec, + PreviewLabels, +} from "@sprout/preview-env"; import type { PreviewAppOps, PreviewServiceSpec, @@ -97,8 +102,6 @@ export type PhaseTimer = { record(phase: PreviewPhase, ms: number): void; }; -export type { GovernanceConfig } from "@sprout/preview-env"; - export type ProvisionInput = { repo: string; prId: number; @@ -116,7 +119,7 @@ export type ProvisionInput = { traefikForwardAuth?: TraefikForwardAuth; auth?: PreviewAuthSpec; previewAuth?: PreviewAuthConfig; - governanceMs?: { ttlMs: number | null; idleMs: number | null }; + governanceMs: EffectiveGovernanceMs; }; export type TeardownInput = { @@ -124,12 +127,20 @@ export type TeardownInput = { prId: number; }; +/** Removal cause recorded on the tombstone. Eviction ("sweep:pr-not-open") + * and expiry share the tombstone shape with manual teardown; only the + * recorded reason differs. See destroyPreviewRow. */ +export type PreviewExpiryReason = + | "sweep:ttl-expired" + | "sweep:idle-expired" + | "sweep:pr-not-open"; + export type RemovePreviewInput = { repo: string; prId: number; expectedDbName: string | null; expectedCreatedAt: string; - expiryReason?: string; + expiryReason?: PreviewExpiryReason; }; export type PreviewSnapshot = { diff --git a/apps/server/src/sweep/governance.test.ts b/apps/server/src/sweep/governance.test.ts index 130b96f6..cab65d30 100644 --- a/apps/server/src/sweep/governance.test.ts +++ b/apps/server/src/sweep/governance.test.ts @@ -21,14 +21,13 @@ function preview(over: Partial = {}): SweepPreview { describe("governance expiry", () => { afterEach(() => setSystemTime()); - test("expires by stored expires_at with ttl reason", () => { + test("expires by ttl with ttl reason", () => { setSystemTime(new Date("2026-09-10T12:00:00.000Z")); const base = Date.parse("2026-09-02T12:00:00.000Z"); const p = preview({ lastActivityMs: base, ttlMs: 7 * 86400_000, idleMs: null, - expiresAtMs: base + 7 * 86400_000, }); // now is past 7d setSystemTime(new Date(base + 8 * 86400_000)); @@ -41,7 +40,6 @@ describe("governance expiry", () => { lastActivityMs: base, ttlMs: null, idleMs: 2 * 3600_000, - expiresAtMs: base + 2 * 3600_000, }); expect( planGovernanceExpiry(p, base + 3 * 3600_000), @@ -55,19 +53,17 @@ describe("governance expiry", () => { lastActivityMs: base, ttlMs: null, idleMs: null, - expiresAtMs: null, }); expect(planGovernanceExpiry(p, base + 30 * 86400_000)).toBeNull(); }); - test("stale stored deadline does not outlive a refresh", () => { + test("refreshed activity postpones expiry", () => { const base = Date.parse("2026-09-02T12:00:00.000Z"); const refreshed = base + 8 * 86400_000; const stale = preview({ lastActivityMs: refreshed, ttlMs: 7 * 86400_000, idleMs: null, - expiresAtMs: base + 7 * 86400_000, }); expect(planGovernanceExpiry(stale, base + 8 * 86400_000)).toBeNull(); expect( @@ -86,7 +82,6 @@ describe("governance expiry", () => { lastActivityMs: base, ttlMs: 7 * 86400_000, idleMs: null, - expiresAtMs: base + 7 * 86400_000, }), ], listCatalogDatabases: async () => [], @@ -101,6 +96,14 @@ describe("governance expiry", () => { return true; }, ttlHours: 72 * 365, + governance: { + previewTtlMs: null, + previewIdleMs: null, + maxPreviews: null, + maxPreviewsPerRepo: null, + previewMaxDbConnections: null, + postgresMaxConnections: null, + }, }); expect(deletions).toEqual([ expect.objectContaining({ reason: "sweep:ttl-expired", prId: 5 }), diff --git a/apps/server/src/sweep/live-ports.test.ts b/apps/server/src/sweep/live-ports.test.ts index 2ae117c3..4c4345aa 100644 --- a/apps/server/src/sweep/live-ports.test.ts +++ b/apps/server/src/sweep/live-ports.test.ts @@ -8,8 +8,18 @@ import { previews, repos } from "../infrastructure/db/schema.ts"; import type { PreviewDbRouter } from "../preview-db/routing.ts"; import { bindPreviewDataVolumes } from "../preview/data-volumes.ts"; import { createLiveSweepPorts } from "./live-ports.ts"; +import type { GovernanceConfig } from "@sprout/preview-env"; import { runSweepPass } from "./reconcile.ts"; +const offGovernance: GovernanceConfig = { + previewTtlMs: null, + previewIdleMs: null, + maxPreviews: null, + maxPreviewsPerRepo: null, + previewMaxDbConnections: null, + postgresMaxConnections: null, +}; + function stubPreviewDb( partial: Partial & Pick, @@ -80,6 +90,7 @@ describe("createLiveSweepPorts", () => { forge: { listOpenPrIds: async () => [], }, + governance: offGovernance, ttlHours: 72, log: () => {}, }); @@ -124,6 +135,7 @@ describe("createLiveSweepPorts", () => { listPreviewDatabases: async () => [], }), forge: { listOpenPrIds: async () => [1] }, + governance: offGovernance, ttlHours: 72, }); @@ -169,6 +181,7 @@ describe("createLiveSweepPorts", () => { forDrop: dropRecorder(droppedDbs), }), forge: { listOpenPrIds: async () => [1] }, + governance: offGovernance, ttlHours: 72, log: (message) => { logs.push(message); @@ -223,6 +236,7 @@ describe("createLiveSweepPorts", () => { forDrop: dropRecorder(droppedDbs), }), forge: { listOpenPrIds: async () => [1] }, + governance: offGovernance, ttlHours: 1, log: (message) => { logs.push(message); @@ -278,6 +292,7 @@ describe("createLiveSweepPorts", () => { }), }), forge: { listOpenPrIds: async () => [] }, + governance: offGovernance, ttlHours: 72, log: (message) => { logs.push(message); @@ -332,6 +347,7 @@ describe("createLiveSweepPorts", () => { forDrop: dropRecorder(droppedDbs), }), forge: { listOpenPrIds: async () => [] }, + governance: offGovernance, ttlHours: 72, log: () => {}, }); @@ -387,6 +403,7 @@ describe("createLiveSweepPorts", () => { forDrop: dropRecorder(droppedDbs), }), forge: { listOpenPrIds: async () => [42] }, + governance: offGovernance, ttlHours: 72, }); @@ -437,6 +454,7 @@ describe("createLiveSweepPorts", () => { forDrop: dropRecorder(droppedDbs), }), forge: { listOpenPrIds: async () => [42] }, + governance: offGovernance, ttlHours: 72, }); @@ -492,6 +510,7 @@ describe("createLiveSweepPorts", () => { return [42]; }, }, + governance: offGovernance, ttlHours: 72, }); @@ -539,6 +558,7 @@ describe("createLiveSweepPorts", () => { forDrop: dropRecorder(droppedDbs), }), forge: { listOpenPrIds: async () => [42] }, + governance: offGovernance, ttlHours: 72, }); @@ -581,6 +601,7 @@ describe("createLiveSweepPorts", () => { listPreviewDatabases: async () => [], }), forge: { listOpenPrIds: async () => [42] }, + governance: offGovernance, ttlHours: 72, }); @@ -608,6 +629,7 @@ describe("createLiveSweepPorts", () => { listPreviewDatabases: async () => [], }), forge: { listOpenPrIds: async () => [] }, + governance: offGovernance, ttlHours: 72, }); @@ -642,6 +664,7 @@ describe("createLiveSweepPorts", () => { }), }), forge: { listOpenPrIds: async () => [] }, + governance: offGovernance, ttlHours: 72, log: () => {}, }); diff --git a/apps/server/src/sweep/live-ports.ts b/apps/server/src/sweep/live-ports.ts index 7948d719..323b3e16 100644 --- a/apps/server/src/sweep/live-ports.ts +++ b/apps/server/src/sweep/live-ports.ts @@ -12,6 +12,7 @@ import { type TeardownDeps, } from "../preview/lifecycle.ts"; import type { PreviewDbRouter } from "../preview-db/routing.ts"; +import type { PreviewExpiryReason } from "../preview/types.ts"; import type { SweepDeletion, SweepPorts, @@ -25,7 +26,7 @@ export type LiveSweepDeps = { dataVolumes: PreviewDataVolumes; forge: ForgeClient; ttlHours: number; - governance?: SweepPorts["governance"]; + governance: SweepPorts["governance"]; log?: SweepPorts["log"]; }; @@ -40,10 +41,7 @@ function teardownDeps(deps: LiveSweepDeps): TeardownDeps { async function removeControlPlane( deps: LiveSweepDeps, - deletion: Extract< - SweepDeletion, - { reason: "sweep:ttl-expired" | "sweep:idle-expired" | "sweep:pr-not-open" } - >, + deletion: Extract, useTryLock = false, ): Promise { const input = { @@ -63,7 +61,7 @@ async function removeControlPlane( export function createLiveSweepPorts(deps: LiveSweepDeps): SweepPorts { return { ttlHours: deps.ttlHours, - ...(deps.governance !== undefined ? { governance: deps.governance } : {}), + governance: deps.governance, log: deps.log, listPreviews: async () => { const rows = await deps.db.select().from(previews); @@ -84,7 +82,6 @@ export function createLiveSweepPorts(deps: LiveSweepDeps): SweepPorts { createdAtMs, status: row.status, lastActivityMs: parseUnambiguousUtcMs(row.lastActivityAt ?? ""), - expiresAtMs: parseUnambiguousUtcMs(row.expiresAt ?? ""), ttlMs: row.ttlMs ?? null, idleMs: row.idleMs ?? null, }); diff --git a/apps/server/src/sweep/reconcile.test.ts b/apps/server/src/sweep/reconcile.test.ts index e1132d38..8ae6ca57 100644 --- a/apps/server/src/sweep/reconcile.test.ts +++ b/apps/server/src/sweep/reconcile.test.ts @@ -61,6 +61,14 @@ function memoryPorts(seed: { return true; }, ttlHours: 72, + governance: { + previewTtlMs: null, + previewIdleMs: null, + maxPreviews: null, + maxPreviewsPerRepo: null, + previewMaxDbConnections: null, + postgresMaxConnections: null, + }, log: (message, deletion) => { logs.push(message); seed.log?.(message, deletion); diff --git a/apps/server/src/sweep/reconcile.ts b/apps/server/src/sweep/reconcile.ts index cb278fcd..e0405e8a 100644 --- a/apps/server/src/sweep/reconcile.ts +++ b/apps/server/src/sweep/reconcile.ts @@ -1,6 +1,11 @@ import type { DataVolumeRef, GovernanceConfig } from "@sprout/preview-env"; -import { governanceStatus } from "@sprout/preview-env"; +import { + computeExpiresAtMs, + connectionProjection, + governanceStatus, +} from "@sprout/preview-env"; import { isForgeApiError } from "../forge/types.ts"; +import type { PreviewExpiryReason } from "../preview/types.ts"; export type SweepReason = | "sweep:pr-not-open" @@ -23,14 +28,13 @@ export type SweepPreview = { createdAtMs: number | null; status: string; lastActivityMs?: number | null; - expiresAtMs?: number | null; ttlMs?: number | null; idleMs?: number | null; }; export type SweepDeletion = | { - reason: "sweep:pr-not-open" | "sweep:ttl-expired" | "sweep:idle-expired"; + reason: PreviewExpiryReason; canonicalRepoId: string; prId: number; slug: string; @@ -53,7 +57,7 @@ export type SweepPorts = { /** True if resources were removed; false if the plan was stale. */ drop: (deletion: SweepDeletion) => Promise; ttlHours: number; - governance?: GovernanceConfig; + governance: GovernanceConfig; log?: (message: string, deletion?: SweepDeletion) => void; }; @@ -281,8 +285,8 @@ function activityBaseMs(preview: SweepPreview): number | null { /** * Governance expiry derived from the last activity plus the stored - * per-preview durations. The stored expires_at is write-only for the read - * surface: deriving here keeps a stale deadline from outliving a refresh. + * per-preview durations — the same source the read surface derives + * `expires_at` from, so a stale deadline can never outlive a refresh. * The cheap activity signal is the last successful deploy (including * reseed/reset); see docs/previews.md. Returns the reason, or null to keep. */ @@ -292,27 +296,18 @@ export function planGovernanceExpiry( ): "sweep:ttl-expired" | "sweep:idle-expired" | null { const base = activityBaseMs(preview); if (base === null) return null; - const ttlDeadline = - preview.ttlMs != null ? base + preview.ttlMs : null; - const idleDeadline = - preview.idleMs != null ? base + preview.idleMs : null; - if ( - ttlDeadline !== null && - idleDeadline !== null && - nowMs >= ttlDeadline && - nowMs >= idleDeadline - ) { - return ttlDeadline <= idleDeadline - ? "sweep:ttl-expired" - : "sweep:idle-expired"; - } - if (ttlDeadline !== null && nowMs >= ttlDeadline) { - return "sweep:ttl-expired"; - } - if (idleDeadline !== null && nowMs >= idleDeadline) { - return "sweep:idle-expired"; - } - return null; + const expiry = computeExpiresAtMs( + base, + preview.ttlMs ?? null, + preview.idleMs ?? null, + ); + if (expiry === null || nowMs < expiry) return null; + const ttlDeadline = preview.ttlMs != null ? base + preview.ttlMs : null; + const idleDeadline = preview.idleMs != null ? base + preview.idleMs : null; + return ttlDeadline !== null && + (idleDeadline === null || ttlDeadline <= idleDeadline) + ? "sweep:ttl-expired" + : "sweep:idle-expired"; } function logOverCap( @@ -320,9 +315,8 @@ function logOverCap( previews: SweepPreview[], ): void { const gov = ports.governance; - if (!gov) return; const live = previews.filter((p) => p.status !== "removed"); - const status = governanceStatus(live, gov); + const status = governanceStatus(live); const maxTotal = gov.maxPreviews; if (maxTotal !== null && status.total > maxTotal) { ports.log?.( @@ -341,13 +335,15 @@ function logOverCap( } const perPreview = gov.previewMaxDbConnections; const ceiling = gov.postgresMaxConnections; - const { projected, over } = status.connections; - if ( - over && - projected !== null && - perPreview !== null && - ceiling !== null - ) { + if (perPreview === null || ceiling === null) return; + // Current load through the same projection admission uses: active is + // total - 1 so the +1 inside counts the existing previews, not a new one. + const { projected, over } = connectionProjection({ + activePreviews: status.total - 1, + perPreview, + ceiling, + }); + if (over && projected !== null) { ports.log?.( `sweep over connection budget (projected ${projected} > ceiling ${ceiling}; ` + `${status.total} previews x ${perPreview} per preview)`, diff --git a/packages/preview-env/src/governance.test.ts b/packages/preview-env/src/governance.test.ts index 1796859c..d3a52ea4 100644 --- a/packages/preview-env/src/governance.test.ts +++ b/packages/preview-env/src/governance.test.ts @@ -90,24 +90,14 @@ describe("preview governance durations", () => { ).toEqual({ projected: null, over: false }); }); - test("governance status counts once for caps and current usage", () => { + test("governance status counts once for caps", () => { const previews = [ { canonicalRepoId: "a" }, { canonicalRepoId: "a" }, { canonicalRepoId: "b" }, ]; - const status = governanceStatus(previews, { - maxPreviews: 10, - maxPreviewsPerRepo: 10, - previewMaxDbConnections: 12, - postgresMaxConnections: 100, - }); + const status = governanceStatus(previews); expect(status.total).toBe(3); expect(status.byRepo.get("a")).toBe(2); - expect(status.connections).toEqual({ projected: 36, over: false }); - expect(governanceStatus(previews, undefined).connections).toEqual({ - projected: null, - over: false, - }); }); }); diff --git a/packages/preview-env/src/governance.ts b/packages/preview-env/src/governance.ts index aa994d0d..4a08b7be 100644 --- a/packages/preview-env/src/governance.ts +++ b/packages/preview-env/src/governance.ts @@ -22,14 +22,27 @@ function parseDurationMs(raw: string): number | null { /** Gateway-level governance: every field resolved, null means off. */ export type GovernanceConfig = { + /** Null means unbounded; manifest preview.ttl overrides. */ previewTtlMs: number | null; + /** Null means off; measured from the last successful deploy. */ previewIdleMs: number | null; + /** Null means unbounded. */ maxPreviewsPerRepo: number | null; + /** Null means unbounded. */ maxPreviews: number | null; + /** Expected per-preview DB connections; null means no budget. */ previewMaxDbConnections: number | null; + /** Instance ceiling for the budget arithmetic; null means no budget. */ postgresMaxConnections: number | null; }; +/** Per-preview deadlines in ms; null means off. Single home for the shape + * threaded from admission through bring-up. */ +export type EffectiveGovernanceMs = { + ttlMs: number | null; + idleMs: number | null; +}; + /** Manifest-level governance overrides; undefined means inherit the gateway. */ export type GovernanceManifest = { ttl?: string; @@ -138,15 +151,13 @@ export function resolveGovernanceMs( /** Effective per-preview deadlines from manifest overrides over the gateway. */ export function resolveEffectiveGovernanceMs( manifest: GovernanceManifest | undefined, - gateway: - | Pick - | undefined, -): { ttlMs: number | null; idleMs: number | null } { + gateway: Pick, +): EffectiveGovernanceMs { return { - ttlMs: resolveGovernanceMs(manifest?.ttl, gateway?.previewTtlMs ?? null), + ttlMs: resolveGovernanceMs(manifest?.ttl, gateway.previewTtlMs), idleMs: resolveGovernanceMs( manifest?.idle_teardown, - gateway?.previewIdleMs ?? null, + gateway.previewIdleMs, ), }; } @@ -178,21 +189,15 @@ export function connectionProjection(input: { export type GovernanceStatus = { total: number; byRepo: Map; - connections: { projected: number | null; over: boolean }; }; -/** One pass over live previews for cap and budget checks; deploy and sweep share it. */ +/** + * One pass over live previews for the cap checks. Connection budget is not + * part of the status: both callers project it from the same + * `connectionProjection` input so the +1 convention lives in one place. + */ export function governanceStatus( previews: readonly { canonicalRepoId: string }[], - gov: - | Pick< - GovernanceConfig, - | "maxPreviews" - | "maxPreviewsPerRepo" - | "previewMaxDbConnections" - | "postgresMaxConnections" - > - | undefined, ): GovernanceStatus { const byRepo = new Map(); for (const preview of previews) { @@ -201,16 +206,7 @@ export function governanceStatus( (byRepo.get(preview.canonicalRepoId) ?? 0) + 1, ); } - const total = previews.length; - const perPreview = gov?.previewMaxDbConnections ?? null; - const ceiling = gov?.postgresMaxConnections ?? null; - let projected: number | null = null; - let over = false; - if (perPreview !== null && ceiling !== null) { - projected = total * perPreview; - over = projected > ceiling; - } - return { total, byRepo, connections: { projected, over } }; + return { total: previews.length, byRepo }; } export function previewLimitDetail(input: { diff --git a/packages/preview-env/src/index.ts b/packages/preview-env/src/index.ts index da110d59..2fae4d44 100644 --- a/packages/preview-env/src/index.ts +++ b/packages/preview-env/src/index.ts @@ -122,6 +122,7 @@ export { parsePreviewGovernanceField, previewLimitDetail, resolveEffectiveGovernanceMs, + type EffectiveGovernanceMs, type GovernanceConfig, type GovernanceManifest, type GovernanceStatus, From 8ff8b3d41a9aa195f78e5039d381b4797532d338 Mon Sep 17 00:00:00 2001 From: simpros <38320875+simpros@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:38:35 +0200 Subject: [PATCH 04/10] address review feedback on #265 Round-4 findings: thread last-activity watermark into the removal contract so a refreshed preview is a stale plan; fold expiry base, min-deadline and tie-break into preview-env resolvePreviewExpiry used by both the read surface and the sweep planner; make connectionProjection pure previews * perPreview with the +1 at the admission call site; delete dead SweepReason; clear expiry_reason on re-provision intent and accept; move the governed-ttl HTTP test into a focused preview-governance suite and assert full response shapes. --- apps/server/src/http/health-deploy.test.ts | 4 +- .../src/http/preview-governance.test.ts | 172 ++++++++++++++++++ .../server/src/http/preview-lifecycle.test.ts | 41 +---- apps/server/src/preview/admission.ts | 3 +- apps/server/src/preview/lifecycle.ts | 9 + apps/server/src/preview/snapshot.ts | 35 ++-- apps/server/src/preview/types.ts | 4 + apps/server/src/sweep/governance.test.ts | 1 + apps/server/src/sweep/live-ports.test.ts | 15 ++ apps/server/src/sweep/live-ports.ts | 2 + apps/server/src/sweep/reconcile.test.ts | 32 ++++ apps/server/src/sweep/reconcile.ts | 57 ++---- docs/previews.md | 7 +- packages/preview-env/src/governance.test.ts | 53 +++++- packages/preview-env/src/governance.ts | 42 ++++- packages/preview-env/src/index.ts | 1 + 16 files changed, 376 insertions(+), 102 deletions(-) create mode 100644 apps/server/src/http/preview-governance.test.ts diff --git a/apps/server/src/http/health-deploy.test.ts b/apps/server/src/http/health-deploy.test.ts index b5b16a1a..41e5c1a7 100644 --- a/apps/server/src/http/health-deploy.test.ts +++ b/apps/server/src/http/health-deploy.test.ts @@ -139,7 +139,7 @@ describe("POST /v1/deploy health polling", () => { const { deployToken } = await setup(); const res = await postDeploy(deployToken, deployBody()); expect(res.settleStatus).toBe(200); - expect(res.body).toMatchObject({ + expect(res.body).toEqual({ ok: true, canonical_repo_id: REPO, pr_id: 42, @@ -148,6 +148,8 @@ describe("POST /v1/deploy health polling", () => { hostname: "pr-42.myapp.preview.example.com", status: "running", preview_url: "https://pr-42.myapp.preview.example.com", + last_activity_at: expect.any(String), + expires_at: null, expiry_reason: null, }); diff --git a/apps/server/src/http/preview-governance.test.ts b/apps/server/src/http/preview-governance.test.ts new file mode 100644 index 00000000..f81834da --- /dev/null +++ b/apps/server/src/http/preview-governance.test.ts @@ -0,0 +1,172 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { and, eq } from "drizzle-orm"; +import { + createFakeDockerClient, + type FakeDockerClient, +} from "../docker/fake.ts"; +import { previews } from "../infrastructure/db/schema.ts"; +import { bindPreviewDataVolumes } from "../preview/data-volumes.ts"; +import { removePreview } from "../preview/lifecycle.ts"; +import { + createFakePreviewDb, + type FakePreviewDb, +} from "../preview-db/fake.ts"; +import { + bindTestPreviewApp, + createTestApp, + deployBody, + postDeployAndSettle, + postDeployToken, + TEST_APP_IMAGE as APP_IMAGE, + TEST_REPO as REPO, + type TestApp, +} from "./test-helpers.ts"; + +let testApp: TestApp | undefined; +let fakePreviewDb: FakePreviewDb | undefined; +let fakeDocker: FakeDockerClient | undefined; + +afterEach(async () => { + await testApp?.cleanup(); + testApp = undefined; + fakePreviewDb = undefined; + fakeDocker = undefined; +}); + +async function setupGovernance(governance: { + previewTtlMs: number | null; + previewIdleMs: number | null; + maxPreviews: number | null; + maxPreviewsPerRepo: number | null; + previewMaxDbConnections: number | null; + postgresMaxConnections: number | null; +}) { + fakePreviewDb = createFakePreviewDb(); + fakeDocker = createFakeDockerClient({ + exposedPorts: { [APP_IMAGE]: 3000 }, + }); + testApp = await createTestApp({ + previewDb: fakePreviewDb, + docker: fakeDocker, + governance, + }); + const { body } = await postDeployToken(testApp, { + canonical_repo_id: REPO, + slug: "myapp", + }); + return { deployToken: body.token as string }; +} + +function teardownDeps() { + return { + db: testApp!.db, + previewDb: testApp!.previewDb, + app: bindTestPreviewApp(testApp!.docker), + dataVolumes: bindPreviewDataVolumes(testApp!.docker), + }; +} + +async function row() { + const [found] = await testApp!.db + .select() + .from(previews) + .where(and(eq(previews.canonicalRepoId, REPO), eq(previews.prId, 42))) + .limit(1); + return found; +} + +describe("preview governance lifecycle", () => { + test("derives expires_at from the gateway ttl on the read surface", async () => { + const { deployToken } = await setupGovernance({ + previewTtlMs: 7 * 86400_000, + previewIdleMs: null, + maxPreviews: null, + maxPreviewsPerRepo: null, + previewMaxDbConnections: null, + postgresMaxConnections: null, + }); + const res = await postDeployAndSettle(testApp!, deployToken, deployBody()); + expect(res.settleStatus).toBe(200); + const snap = res.body as { + last_activity_at: string; + expires_at: string | null; + }; + expect(typeof snap.last_activity_at).toBe("string"); + expect(snap.expires_at).toBe( + new Date(Date.parse(snap.last_activity_at) + 7 * 86400_000).toISOString(), + ); + }); + + test("a sweep plan built before a refresh is stale", async () => { + const { deployToken } = await setupGovernance({ + previewTtlMs: 7 * 86400_000, + previewIdleMs: null, + maxPreviews: null, + maxPreviewsPerRepo: null, + previewMaxDbConnections: null, + postgresMaxConnections: null, + }); + const first = await postDeployAndSettle(testApp!, deployToken, deployBody()); + expect(first.settleStatus).toBe(200); + const before = await row(); + const stalePlan = { + repo: REPO, + prId: 42, + expectedDbName: before!.dbName, + expectedCreatedAt: before!.createdAt, + expectedLastActivityAt: before!.lastActivityAt ?? null, + }; + + const second = await postDeployAndSettle( + testApp!, + deployToken, + deployBody(), + ); + expect(second.settleStatus).toBe(200); + const after = await row(); + expect(after!.lastActivityAt).not.toBe(before!.lastActivityAt); + + const dropped = await removePreview(teardownDeps(), { + ...stalePlan, + expiryReason: "sweep:ttl-expired", + }); + expect(dropped).toEqual({ ok: true, value: false }); + expect((await row())!.status).toBe("running"); + }); + + test("re-provisioning a removed row clears the tombstone reason", async () => { + const { deployToken } = await setupGovernance({ + previewTtlMs: 7 * 86400_000, + previewIdleMs: null, + maxPreviews: null, + maxPreviewsPerRepo: null, + previewMaxDbConnections: null, + postgresMaxConnections: null, + }); + const first = await postDeployAndSettle(testApp!, deployToken, deployBody()); + expect(first.settleStatus).toBe(200); + const live = await row(); + + const removed = await removePreview(teardownDeps(), { + repo: REPO, + prId: 42, + expectedDbName: live!.dbName, + expectedCreatedAt: live!.createdAt, + expectedLastActivityAt: live!.lastActivityAt ?? null, + expiryReason: "sweep:ttl-expired", + }); + expect(removed).toEqual({ ok: true, value: true }); + expect((await row())!.expiryReason).toBe("sweep:ttl-expired"); + + const redeploy = await postDeployAndSettle( + testApp!, + deployToken, + deployBody(), + ); + expect(redeploy.settleStatus).toBe(200); + expect((await row())!.expiryReason).toBeNull(); + expect( + (redeploy.body as { expiry_reason: string | null }).expiry_reason, + ).toBeNull(); + }); +}); diff --git a/apps/server/src/http/preview-lifecycle.test.ts b/apps/server/src/http/preview-lifecycle.test.ts index d02475c3..012e6e7c 100644 --- a/apps/server/src/http/preview-lifecycle.test.ts +++ b/apps/server/src/http/preview-lifecycle.test.ts @@ -146,7 +146,7 @@ describe("POST /v1/deploy", () => { const { deployToken } = await setup(); const res = await postDeploy(deployToken, deployBody()); expect(res.settleStatus).toBe(200); - expect(res.body).toMatchObject({ + expect(res.body).toEqual({ ok: true, canonical_repo_id: REPO, pr_id: 42, @@ -155,12 +155,10 @@ describe("POST /v1/deploy", () => { hostname: "pr-42.myapp.preview.example.com", status: "running", preview_url: "https://pr-42.myapp.preview.example.com", + last_activity_at: expect.any(String), + expires_at: null, expiry_reason: null, }); - expect((res.body as { expires_at?: unknown }).expires_at ?? null).toBeNull(); - expect( - typeof (res.body as { last_activity_at?: unknown }).last_activity_at, - ).toBe("string"); expect(fakePreviewDb!.created).toEqual(["sprout_myapp_pr42"]); const [row] = await testApp!.db @@ -204,39 +202,6 @@ describe("POST /v1/deploy", () => { }); }); - test("derives expires_at from the gateway ttl on the read surface", async () => { - fakePreviewDb = createFakePreviewDb(); - fakeDocker = createFakeDockerClient({ - exposedPorts: { [APP_IMAGE]: 3000 }, - }); - testApp = await createTestApp({ - previewDb: fakePreviewDb, - docker: fakeDocker, - governance: { - previewTtlMs: 7 * 86400_000, - previewIdleMs: null, - maxPreviews: null, - maxPreviewsPerRepo: null, - previewMaxDbConnections: null, - postgresMaxConnections: null, - }, - }); - const { body } = await postDeployToken(testApp, { - canonical_repo_id: REPO, - slug: "myapp", - }); - const res = await postDeploy(body.token as string, deployBody()); - expect(res.settleStatus).toBe(200); - const snap = res.body as { - last_activity_at: string; - expires_at: string | null; - }; - expect(typeof snap.last_activity_at).toBe("string"); - expect(snap.expires_at).toBe( - new Date(Date.parse(snap.last_activity_at) + 7 * 86400_000).toISOString(), - ); - }); - test("applies preview labels alongside the gateway Traefik labels", async () => { const { deployToken } = await setup(); const res = await postDeploy( diff --git a/apps/server/src/preview/admission.ts b/apps/server/src/preview/admission.ts index dde1a525..1917015b 100644 --- a/apps/server/src/preview/admission.ts +++ b/apps/server/src/preview/admission.ts @@ -72,8 +72,9 @@ export async function checkDeployAdmission( const perPreview = gov.previewMaxDbConnections; const ceiling = gov.postgresMaxConnections; if (perPreview !== null && ceiling !== null) { + // The candidate is the +1: admission projects with it included. const budget = connectionProjection({ - activePreviews: status.total, + previews: status.total + 1, perPreview, ceiling, }); diff --git a/apps/server/src/preview/lifecycle.ts b/apps/server/src/preview/lifecycle.ts index 85f5cc91..04e01227 100644 --- a/apps/server/src/preview/lifecycle.ts +++ b/apps/server/src/preview/lifecycle.ts @@ -195,7 +195,10 @@ async function writeProvisioningIntent( ...authColumnsFor(previewAuthMode(input.auth), stored), ...clearLastError, // New generation: TTL means age of this intent, not birth of the row key. + // A live row never carries a tombstone reason: re-provisioning a + // removed row must not advertise the previous expiry. createdAt: now, + expiryReason: null, updatedAt: now, }, "preview_row_missing_on_intent_write", @@ -226,6 +229,9 @@ async function patchAccept( lastError: null, lastErrorDetail: null, seedLog: null, + // Accepting a deploy clears any tombstone reason from a previous + // removal; closeRunning clears it again at completion. + expiryReason: null, ...(fields.remint ? { createdAt: now } : {}), updatedAt: now, }, @@ -605,6 +611,9 @@ async function removePreviewUnlocked( if (existing.createdAt !== input.expectedCreatedAt) { return { ok: true, value: false }; } + if ((existing.lastActivityAt ?? null) !== input.expectedLastActivityAt) { + return { ok: true, value: false }; + } const status = parsePreviewStatus(existing.status); if (!status.ok) return status; diff --git a/apps/server/src/preview/snapshot.ts b/apps/server/src/preview/snapshot.ts index c00c1a7b..7e4918f6 100644 --- a/apps/server/src/preview/snapshot.ts +++ b/apps/server/src/preview/snapshot.ts @@ -1,4 +1,4 @@ -import { computeExpiresAtMs, deriveMailFromName } from "@sprout/preview-env"; +import { resolvePreviewExpiry, deriveMailFromName } from "@sprout/preview-env"; import type { Result } from "./result.ts"; import type { PreviewRow } from "./row.ts"; import type { @@ -28,23 +28,30 @@ export function parsePreviewStatus(status: string): Result { } /** - * Read-surface expiry: derived from the last activity plus the stored - * per-preview durations, the same source the sweep plans from. Tombstones - * report null, matching the pre-derivation display. + * Read-surface expiry: derived from the same base and bounds the sweep plans + * from, so the displayed deadline and the deletion decision cannot diverge. + * Tombstones report null, matching the pre-derivation display. */ export function expiresAtForRow( - row: Pick, + row: Pick< + PreviewRow, + "status" | "lastActivityAt" | "createdAt" | "ttlMs" | "idleMs" + >, ): string | null { if (row.status === "removed") return null; - const baseMs = - row.lastActivityAt == null ? NaN : Date.parse(row.lastActivityAt); - if (!Number.isFinite(baseMs)) return null; - const expiresMs = computeExpiresAtMs( - baseMs, - row.ttlMs ?? null, - row.idleMs ?? null, - ); - return expiresMs === null ? null : new Date(expiresMs).toISOString(); + const lastActivityMs = + row.lastActivityAt == null ? null : Date.parse(row.lastActivityAt); + const createdAtMs = Date.parse(row.createdAt); + const { expiresAtMs } = resolvePreviewExpiry({ + lastActivityMs: + lastActivityMs !== null && Number.isFinite(lastActivityMs) + ? lastActivityMs + : null, + createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : null, + ttlMs: row.ttlMs ?? null, + idleMs: row.idleMs ?? null, + }); + return expiresAtMs === null ? null : new Date(expiresAtMs).toISOString(); } export function previewSnapshotFromRow(row: PreviewRow): PreviewSnapshot { diff --git a/apps/server/src/preview/types.ts b/apps/server/src/preview/types.ts index 2e454633..f085d551 100644 --- a/apps/server/src/preview/types.ts +++ b/apps/server/src/preview/types.ts @@ -140,6 +140,10 @@ export type RemovePreviewInput = { prId: number; expectedDbName: string | null; expectedCreatedAt: string; + /** Staleness guard on the expiry signal: a refresh advances last activity + * without changing createdAt, so a plan built before the refresh must not + * drop. Compared exactly like expectedCreatedAt. */ + expectedLastActivityAt: string | null; expiryReason?: PreviewExpiryReason; }; diff --git a/apps/server/src/sweep/governance.test.ts b/apps/server/src/sweep/governance.test.ts index cab65d30..e442466c 100644 --- a/apps/server/src/sweep/governance.test.ts +++ b/apps/server/src/sweep/governance.test.ts @@ -13,6 +13,7 @@ function preview(over: Partial = {}): SweepPreview { dbName: "sprout_widgets_pr5", createdAt: "2026-09-02T12:00:00.000Z", createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), + lastActivityAt: null, status: "running", ...over, }; diff --git a/apps/server/src/sweep/live-ports.test.ts b/apps/server/src/sweep/live-ports.test.ts index 4c4345aa..2c9d2074 100644 --- a/apps/server/src/sweep/live-ports.test.ts +++ b/apps/server/src/sweep/live-ports.test.ts @@ -70,6 +70,7 @@ describe("createLiveSweepPorts", () => { containerId: "ctr-10", status: "running", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, updatedAt: "2026-09-02T12:00:00.000Z", }); @@ -123,6 +124,7 @@ describe("createLiveSweepPorts", () => { containerId: null, status: "running", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, updatedAt: "2026-09-02T12:00:00.000Z", }); @@ -164,6 +166,7 @@ describe("createLiveSweepPorts", () => { containerId: null, status: "running", createdAt: "not-a-timestamp", + lastActivityAt: null, updatedAt: "2026-09-02T12:00:00.000Z", }); @@ -219,6 +222,7 @@ describe("createLiveSweepPorts", () => { containerId: null, status: "running", createdAt: "2026-09-02 12:00:00", + lastActivityAt: null, updatedAt: "2026-09-02 12:00:00", }); @@ -274,6 +278,7 @@ describe("createLiveSweepPorts", () => { containerId: "ctr-10", status: "running", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, updatedAt: "2026-09-02T12:00:00.000Z", }); @@ -327,6 +332,7 @@ describe("createLiveSweepPorts", () => { containerId: "ctr-10", status: "running", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, updatedAt: "2026-09-02T12:00:00.000Z", }); @@ -362,6 +368,7 @@ describe("createLiveSweepPorts", () => { slug: "widgets", dbName: "sprout_widgets_pr10", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, }, ]); expect(droppedDbs).toEqual(["sprout_widgets_pr10"]); @@ -389,6 +396,7 @@ describe("createLiveSweepPorts", () => { containerId: null, status: "running", createdAt: "2026-09-03T11:00:00.000Z", + lastActivityAt: null, updatedAt: "2026-09-03T11:00:00.000Z", }); @@ -414,6 +422,7 @@ describe("createLiveSweepPorts", () => { slug: "old", dbName: "sprout_old_pr42", createdAt: "2026-08-01T12:00:00.000Z", + lastActivityAt: null, }); expect(removed).toBe(false); expect(droppedDbs).toEqual([]); @@ -440,6 +449,7 @@ describe("createLiveSweepPorts", () => { containerId: null, status: "running", createdAt: "2026-09-03T11:55:00.000Z", + lastActivityAt: null, updatedAt: "2026-09-03T11:55:00.000Z", }); @@ -465,6 +475,7 @@ describe("createLiveSweepPorts", () => { slug: "widgets", dbName: "sprout_widgets_pr42", createdAt: "2026-08-01T12:00:00.000Z", + lastActivityAt: null, }); expect(removed).toBe(false); expect(droppedDbs).toEqual([]); @@ -490,6 +501,7 @@ describe("createLiveSweepPorts", () => { containerId: null, status: "running", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, updatedAt: "2026-09-02T12:00:00.000Z", }); @@ -521,6 +533,7 @@ describe("createLiveSweepPorts", () => { slug: "widgets", dbName: "sprout_widgets_pr42", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, }); expect(removed).toBe(false); expect(forgeCalls).toBe(1); @@ -544,6 +557,7 @@ describe("createLiveSweepPorts", () => { containerId: null, status: "provisioning", createdAt: "2026-09-03T12:00:00.000Z", + lastActivityAt: null, updatedAt: "2026-09-03T12:00:00.000Z", }); @@ -589,6 +603,7 @@ describe("createLiveSweepPorts", () => { containerId: null, status: "provisioning", createdAt: "2026-09-03T12:00:00.000Z", + lastActivityAt: null, updatedAt: "2026-09-03T12:00:00.000Z", }); diff --git a/apps/server/src/sweep/live-ports.ts b/apps/server/src/sweep/live-ports.ts index 323b3e16..411f5039 100644 --- a/apps/server/src/sweep/live-ports.ts +++ b/apps/server/src/sweep/live-ports.ts @@ -49,6 +49,7 @@ async function removeControlPlane( prId: deletion.prId, expectedDbName: deletion.dbName, expectedCreatedAt: deletion.createdAt, + expectedLastActivityAt: deletion.lastActivityAt, expiryReason: deletion.reason, }; const result = useTryLock @@ -80,6 +81,7 @@ export function createLiveSweepPorts(deps: LiveSweepDeps): SweepPorts { dbName: row.dbName, createdAt: row.createdAt, createdAtMs, + lastActivityAt: row.lastActivityAt ?? null, status: row.status, lastActivityMs: parseUnambiguousUtcMs(row.lastActivityAt ?? ""), ttlMs: row.ttlMs ?? null, diff --git a/apps/server/src/sweep/reconcile.test.ts b/apps/server/src/sweep/reconcile.test.ts index 8ae6ca57..bcd471ce 100644 --- a/apps/server/src/sweep/reconcile.test.ts +++ b/apps/server/src/sweep/reconcile.test.ts @@ -93,6 +93,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr10", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", }, @@ -110,6 +111,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr10", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, }, ]); }); @@ -124,6 +126,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr5", createdAt: "2026-08-01T12:00:00.000Z", + lastActivityAt: null, createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), status: "running", }, @@ -140,6 +143,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr5", createdAt: "2026-08-01T12:00:00.000Z", + lastActivityAt: null, }, ]); expect(forgeCalls).toEqual([]); @@ -222,6 +226,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr3", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", }, @@ -304,6 +309,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr10", createdAt: "2026-08-01T12:00:00.000Z", + lastActivityAt: null, createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), status: "running", }, @@ -313,6 +319,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr11", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", }, @@ -333,6 +340,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr10", createdAt: "2026-08-01T12:00:00.000Z", + lastActivityAt: null, }, { reason: "sweep:orphan-db", @@ -354,6 +362,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr10", createdAt: "2026-08-01T12:00:00.000Z", + lastActivityAt: null, createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), status: "running", }, @@ -363,6 +372,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr11", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", }, @@ -385,6 +395,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr10", createdAt: "2026-08-01T12:00:00.000Z", + lastActivityAt: null, }, { reason: "sweep:orphan-db", @@ -405,6 +416,7 @@ describe("runSweepPass", () => { slug: "repoa", dbName: "sprout_repoa_pr1", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", }, @@ -414,6 +426,7 @@ describe("runSweepPass", () => { slug: "repob", dbName: "sprout_repob_pr2", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", }, @@ -436,6 +449,7 @@ describe("runSweepPass", () => { slug: "repob", dbName: "sprout_repob_pr2", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, }, ]); }); @@ -464,6 +478,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr10", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", }, @@ -473,6 +488,7 @@ describe("runSweepPass", () => { slug: "sprouts", dbName: "sprout_sprouts_pr20", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", }, @@ -482,6 +498,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr11", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", }, @@ -491,6 +508,7 @@ describe("runSweepPass", () => { slug: "sprouts", dbName: "sprout_sprouts_pr21", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", }, @@ -508,6 +526,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr11", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, }, { reason: "sweep:pr-not-open", @@ -516,6 +535,7 @@ describe("runSweepPass", () => { slug: "sprouts", dbName: "sprout_sprouts_pr21", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, }, ]); }); @@ -530,6 +550,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr3", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", }, @@ -559,6 +580,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr3", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", }, @@ -583,6 +605,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr10", createdAt: "2026-08-01T12:00:00.000Z", + lastActivityAt: null, createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), status: "running", }, @@ -592,6 +615,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr11", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", }, @@ -614,6 +638,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr10", createdAt: "2026-08-01T12:00:00.000Z", + lastActivityAt: null, }, { reason: "sweep:orphan-db", @@ -628,6 +653,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr11", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, }, ]); }); @@ -642,6 +668,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr10", createdAt: "2026-08-01T12:00:00.000Z", + lastActivityAt: null, createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), status: "running", }, @@ -662,6 +689,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr10", createdAt: "2026-08-01T12:00:00.000Z", + lastActivityAt: null, }, { reason: "sweep:orphan-container", @@ -691,6 +719,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr3", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", }, @@ -715,6 +744,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr1", createdAt: "2026-08-01T12:00:00.000Z", + lastActivityAt: null, createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), status: "running", }, @@ -724,6 +754,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr2", createdAt: "2026-08-01T12:00:00.000Z", + lastActivityAt: null, createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), status: "running", }, @@ -742,6 +773,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr2", createdAt: "2026-08-01T12:00:00.000Z", + lastActivityAt: null, }, ]); }); diff --git a/apps/server/src/sweep/reconcile.ts b/apps/server/src/sweep/reconcile.ts index e0405e8a..29d7e26b 100644 --- a/apps/server/src/sweep/reconcile.ts +++ b/apps/server/src/sweep/reconcile.ts @@ -1,20 +1,12 @@ import type { DataVolumeRef, GovernanceConfig } from "@sprout/preview-env"; import { - computeExpiresAtMs, connectionProjection, governanceStatus, + resolvePreviewExpiry, } from "@sprout/preview-env"; import { isForgeApiError } from "../forge/types.ts"; import type { PreviewExpiryReason } from "../preview/types.ts"; -export type SweepReason = - | "sweep:pr-not-open" - | "sweep:ttl-expired" - | "sweep:idle-expired" - | "sweep:orphan-db" - | "sweep:orphan-container" - | "sweep:orphan-data-volume"; - export type PreviewRef = { slug: string; prId: number }; export type CatalogDbRef = PreviewRef & { dbName: string }; @@ -26,6 +18,7 @@ export type SweepPreview = { dbName: string | null; createdAt: string; createdAtMs: number | null; + lastActivityAt: string | null; status: string; lastActivityMs?: number | null; ttlMs?: number | null; @@ -40,6 +33,7 @@ export type SweepDeletion = slug: string; dbName: string | null; createdAt: string; + lastActivityAt: string | null; } | { reason: "sweep:orphan-db"; slug: string; prId: number; dbName: string } | { @@ -201,6 +195,7 @@ export async function runSweepPass(ports: SweepPorts): Promise slug: preview.slug, dbName: preview.dbName, createdAt: preview.createdAt, + lastActivityAt: preview.lastActivityAt, }); } else if (preview.createdAtMs !== null && preview.createdAtMs < cutoff) { expiryDeletions.push({ @@ -210,6 +205,7 @@ export async function runSweepPass(ports: SweepPorts): Promise slug: preview.slug, dbName: preview.dbName, createdAt: preview.createdAt, + lastActivityAt: preview.lastActivityAt, }); } else { remainingPreviews.push(preview); @@ -265,6 +261,7 @@ export async function runSweepPass(ports: SweepPorts): Promise slug: preview.slug, dbName: preview.dbName, createdAt: preview.createdAt, + lastActivityAt: preview.lastActivityAt, }); } @@ -277,37 +274,24 @@ export async function runSweepPass(ports: SweepPorts): Promise return { forgeRepoFailures, deletions }; } -function activityBaseMs(preview: SweepPreview): number | null { - if ((preview.lastActivityMs ?? null) !== null) - return preview.lastActivityMs ?? null; - return preview.createdAtMs; -} - /** - * Governance expiry derived from the last activity plus the stored - * per-preview durations — the same source the read surface derives - * `expires_at` from, so a stale deadline can never outlive a refresh. - * The cheap activity signal is the last successful deploy (including - * reseed/reset); see docs/previews.md. Returns the reason, or null to keep. + * Governance expiry from the shared preview-env policy: one place decides + * the base, the earlier bound, and which bound won. Returns the reason, or + * null to keep. The cheap activity signal is the last successful deploy + * (including reseed/reset); see docs/previews.md. */ export function planGovernanceExpiry( preview: SweepPreview, nowMs: number, ): "sweep:ttl-expired" | "sweep:idle-expired" | null { - const base = activityBaseMs(preview); - if (base === null) return null; - const expiry = computeExpiresAtMs( - base, - preview.ttlMs ?? null, - preview.idleMs ?? null, - ); - if (expiry === null || nowMs < expiry) return null; - const ttlDeadline = preview.ttlMs != null ? base + preview.ttlMs : null; - const idleDeadline = preview.idleMs != null ? base + preview.idleMs : null; - return ttlDeadline !== null && - (idleDeadline === null || ttlDeadline <= idleDeadline) - ? "sweep:ttl-expired" - : "sweep:idle-expired"; + const { expiresAtMs, bound } = resolvePreviewExpiry({ + lastActivityMs: preview.lastActivityMs ?? null, + createdAtMs: preview.createdAtMs, + ttlMs: preview.ttlMs ?? null, + idleMs: preview.idleMs ?? null, + }); + if (expiresAtMs === null || nowMs < expiresAtMs) return null; + return bound === "ttl" ? "sweep:ttl-expired" : "sweep:idle-expired"; } function logOverCap( @@ -336,10 +320,9 @@ function logOverCap( const perPreview = gov.previewMaxDbConnections; const ceiling = gov.postgresMaxConnections; if (perPreview === null || ceiling === null) return; - // Current load through the same projection admission uses: active is - // total - 1 so the +1 inside counts the existing previews, not a new one. + // Current load as-is: no candidate, so no +1 — admission adds it. const { projected, over } = connectionProjection({ - activePreviews: status.total - 1, + previews: status.total, perPreview, ceiling, }); diff --git a/docs/previews.md b/docs/previews.md index bdad0a2b..c0d7c1d3 100644 --- a/docs/previews.md +++ b/docs/previews.md @@ -44,9 +44,10 @@ including reseed and reset. There is no per-preview access-log pipeline in this release and no edge traffic accounting — a quiet preview that receives HTTP traffic but no deploy still expires. The gateway's sweep (`SPROUT_SWEEP_CRON`, in-process `Bun.cron`) removes expired previews -through the same path `teardown` uses (stop container, drop database, clear -`seeded_at` so the next deploy re-runs the seed), holding the per-preview -lock so a preview mid-deploy is never expired. The expiry reason +through the same path `teardown` uses (stop container, drop database; the +seed watermark is kept on the tombstone and reset when the next deploy +writes its provisioning intent), holding the per-preview lock so a preview +mid-deploy is never expired. The expiry reason (`sweep:ttl-expired` / `sweep:idle-expired`) is stored on the row and `GET /v1/previews` plus the single-preview read return `expires_at`, `last_activity_at`, and the reason; the CI note carries an `- Expires:` line. diff --git a/packages/preview-env/src/governance.test.ts b/packages/preview-env/src/governance.test.ts index d3a52ea4..bcf8bf54 100644 --- a/packages/preview-env/src/governance.test.ts +++ b/packages/preview-env/src/governance.test.ts @@ -7,6 +7,7 @@ import { parseGatewayDurationMs, parsePreviewGovernanceField, resolveGovernanceMs, + resolvePreviewExpiry, } from "./governance.ts"; describe("preview governance durations", () => { @@ -78,15 +79,15 @@ describe("preview governance durations", () => { expect(computeExpiresAtMs(base, null, null)).toBeNull(); }); - test("connection projection refuses beyond the ceiling", () => { + test("connection projection counts exactly the previews given", () => { expect( - connectionProjection({ activePreviews: 7, perPreview: 12, ceiling: 100 }), + connectionProjection({ previews: 8, perPreview: 12, ceiling: 100 }), ).toEqual({ projected: 96, over: false }); expect( - connectionProjection({ activePreviews: 8, perPreview: 12, ceiling: 100 }), + connectionProjection({ previews: 9, perPreview: 12, ceiling: 100 }), ).toEqual({ projected: 108, over: true }); expect( - connectionProjection({ activePreviews: 8, perPreview: null, ceiling: 100 }), + connectionProjection({ previews: 9, perPreview: null, ceiling: 100 }), ).toEqual({ projected: null, over: false }); }); @@ -100,4 +101,48 @@ describe("preview governance durations", () => { expect(status.total).toBe(3); expect(status.byRepo.get("a")).toBe(2); }); + + test("expiry resolves the earlier bound and names the winner", () => { + const base = 1_000_000; + expect( + resolvePreviewExpiry({ + lastActivityMs: base, + createdAtMs: base, + ttlMs: 7 * 86400_000, + idleMs: null, + }), + ).toEqual({ expiresAtMs: base + 7 * 86400_000, bound: "ttl" }); + expect( + resolvePreviewExpiry({ + lastActivityMs: base, + createdAtMs: base, + ttlMs: 7 * 86400_000, + idleMs: 2 * 3600_000, + }), + ).toEqual({ expiresAtMs: base + 2 * 3600_000, bound: "idle" }); + expect( + resolvePreviewExpiry({ + lastActivityMs: null, + createdAtMs: base, + ttlMs: 7 * 86400_000, + idleMs: null, + }), + ).toEqual({ expiresAtMs: base + 7 * 86400_000, bound: "ttl" }); + expect( + resolvePreviewExpiry({ + lastActivityMs: null, + createdAtMs: null, + ttlMs: 7 * 86400_000, + idleMs: null, + }), + ).toEqual({ expiresAtMs: null, bound: null }); + expect( + resolvePreviewExpiry({ + lastActivityMs: base, + createdAtMs: base, + ttlMs: null, + idleMs: null, + }), + ).toEqual({ expiresAtMs: null, bound: null }); + }); }); diff --git a/packages/preview-env/src/governance.ts b/packages/preview-env/src/governance.ts index 4a08b7be..26f8d169 100644 --- a/packages/preview-env/src/governance.ts +++ b/packages/preview-env/src/governance.ts @@ -174,15 +174,49 @@ export function computeExpiresAtMs( return Math.min(...deadlines); } +/** + * One place decides the expiry base, the earlier bound, and which bound won. + * The base falls back to creation when no successful deploy has refreshed + * activity yet, so the read surface and the sweep plan from the same source. + */ +export function resolvePreviewExpiry(input: { + lastActivityMs: number | null; + createdAtMs: number | null; + ttlMs: number | null; + idleMs: number | null; +}): { expiresAtMs: number | null; bound: "ttl" | "idle" | null } { + const base = input.lastActivityMs ?? input.createdAtMs; + if (base === null) return { expiresAtMs: null, bound: null }; + const ttlDeadline = input.ttlMs !== null ? base + input.ttlMs : null; + const idleDeadline = input.idleMs !== null ? base + input.idleMs : null; + const expiresAtMs = computeExpiresAtMs( + base, + input.ttlMs, + input.idleMs, + ); + if (expiresAtMs === null) return { expiresAtMs: null, bound: null }; + const bound = + ttlDeadline !== null && + (idleDeadline === null || ttlDeadline <= idleDeadline) + ? ("ttl" as const) + : ("idle" as const); + return { expiresAtMs, bound }; +} + +/** + * Pure arithmetic: projected connections for exactly `previews` live + * previews. Candidates add themselves (+1) at the admission call site; the + * sweep log passes the current total as-is. + */ export function connectionProjection(input: { - activePreviews: number; + previews: number; perPreview: number | null; ceiling: number | null; }): { projected: number | null; over: boolean } { if (input.perPreview === null || input.ceiling === null) { return { projected: null, over: false }; } - const projected = (input.activePreviews + 1) * input.perPreview; + const projected = input.previews * input.perPreview; return { projected, over: projected > input.ceiling }; } @@ -193,8 +227,8 @@ export type GovernanceStatus = { /** * One pass over live previews for the cap checks. Connection budget is not - * part of the status: both callers project it from the same - * `connectionProjection` input so the +1 convention lives in one place. + * part of the status: both callers project it from `connectionProjection` + * with an explicit count, so there is no hidden +1 convention. */ export function governanceStatus( previews: readonly { canonicalRepoId: string }[], diff --git a/packages/preview-env/src/index.ts b/packages/preview-env/src/index.ts index 2fae4d44..bd56c208 100644 --- a/packages/preview-env/src/index.ts +++ b/packages/preview-env/src/index.ts @@ -122,6 +122,7 @@ export { parsePreviewGovernanceField, previewLimitDetail, resolveEffectiveGovernanceMs, + resolvePreviewExpiry, type EffectiveGovernanceMs, type GovernanceConfig, type GovernanceManifest, From a5e0a83f16e2e1644a54c1bfe8c07bb59c6ebc67 Mon Sep 17 00:00:00 2001 From: simpros <38320875+simpros@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:15:04 +0200 Subject: [PATCH 05/10] address review feedback on #265 Restore config.ts loader truncated by the rebase; fix app.ts syntax. Move server-only governance resolution out of @sprout/preview-env (parseGateway*, resolveEffectiveGovernanceMs, resolvePreviewExpiry) into apps/server preview/governance; preview-env keeps wire grammar plus the shared duration parser. Single violationMessage presenter for admission 429s and sweep logs; budget counts counted previews. Drop unused parse kind/issue codes. Compute legacyTtlMs once in loadConfig and thread it through route/sweep deps; make sweep governance fields required. --- apps/cli/src/yaml.ts | 9 +- apps/server/src/config.test.ts | 3 + apps/server/src/config.ts | 64 ++--- apps/server/src/http/app.ts | 1 + apps/server/src/http/auth.test.ts | 1 + apps/server/src/http/dashboard.test.ts | 9 + apps/server/src/http/deploy.ts | 16 +- apps/server/src/http/health-deploy.test.ts | 20 +- apps/server/src/http/introspection.test.ts | 10 +- apps/server/src/http/introspection.ts | 13 +- apps/server/src/http/none-deploy.test.ts | 10 +- .../server/src/http/preview-lifecycle.test.ts | 10 +- apps/server/src/http/routes.ts | 11 +- apps/server/src/http/test-helpers.ts | 2 + apps/server/src/preview/admission.test.ts | 89 ++++++- apps/server/src/preview/admission.ts | 66 ++--- apps/server/src/preview/bring-up.ts | 2 +- apps/server/src/preview/governance.test.ts | 252 ++++++++++++++++++ apps/server/src/preview/governance.ts | 233 ++++++++++++++++ apps/server/src/preview/locks.ts | 93 ++++--- apps/server/src/preview/runtime.mail.test.ts | 2 + apps/server/src/preview/snapshot.ts | 39 +-- apps/server/src/preview/types.ts | 9 +- apps/server/src/sweep/governance.test.ts | 31 ++- apps/server/src/sweep/live-ports.test.ts | 26 +- apps/server/src/sweep/live-ports.ts | 4 +- apps/server/src/sweep/reconcile.test.ts | 65 ++++- apps/server/src/sweep/reconcile.ts | 75 ++---- apps/server/src/sweep/start.ts | 2 +- apps/server/src/telemetry/payload.test.ts | 7 + packages/preview-env/src/governance.test.ts | 159 +++-------- packages/preview-env/src/governance.ts | 224 ++-------------- packages/preview-env/src/index.ts | 11 +- 33 files changed, 983 insertions(+), 585 deletions(-) create mode 100644 apps/server/src/preview/governance.test.ts create mode 100644 apps/server/src/preview/governance.ts diff --git a/apps/cli/src/yaml.ts b/apps/cli/src/yaml.ts index d27257df..490fadbb 100644 --- a/apps/cli/src/yaml.ts +++ b/apps/cli/src/yaml.ts @@ -412,12 +412,13 @@ function parseGovernanceField( raw: unknown, path: "preview.ttl" | "preview.idle_teardown", ): Result { - const kind = path === "preview.ttl" ? "ttl" : "idle_teardown"; - const parsed = parsePreviewGovernanceField(raw, kind); + const parsed = parsePreviewGovernanceField(raw); if (!parsed.ok) { - return { ok: false, error: governanceIssueMessage(path, parsed.issue) }; + return { ok: false, error: governanceIssueMessage(path, parsed.raw) }; } - return { ok: true, value: parsed.value }; + // The CLI forwards the normalized string to the gateway; the parsed bound + // is re-derived server-side at the deploy boundary. + return { ok: true, value: parsed.value?.raw }; } function parseServices( diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index c5f98fa3..7d255109 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -391,6 +391,7 @@ describe("loadConfig", () => { gitlabToken: "", extraGitlabHosts: new Set(), ttlHours: 72, + legacyTtlMs: 72 * 3600_000, sweepCron: "*/30 * * * *", previewPortDefault: 8080, seedTimeout: 180, @@ -430,6 +431,7 @@ describe("loadConfig", () => { gitlabToken: "gl", extraGitlabHosts: new Set(["git.example.com"]), ttlHours: 72, + legacyTtlMs: 72 * 3600_000, sweepCron: "*/30 * * * *", previewPortDefault: 8080, seedTimeout: 180, @@ -487,6 +489,7 @@ describe("loadConfig", () => { gitlabToken: "", extraGitlabHosts: new Set(), ttlHours: 72, + legacyTtlMs: 72 * 3600_000, sweepCron: "*/30 * * * *", previewPortDefault: 8080, seedTimeout: 180, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index ec029725..2ea9d7ff 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -5,9 +5,11 @@ import type { import type { TelemetryState } from "./telemetry/destination.ts"; import { formatOtlpDestination } from "./telemetry/destination.ts"; import { + hoursToMs, parseGatewayCap, parseGatewayDurationMs, -} from "@sprout/preview-env"; +} from "./preview/governance.ts"; +import type { GovernanceConfig } from "@sprout/preview-env"; import { DEFAULT_MAIL_FROM_DOMAIN } from "@sprout/preview-env"; import { GITHUB_HOSTS } from "./forge/kind.ts"; import { @@ -155,6 +157,9 @@ export type Config = { extraGitlabHosts: ReadonlySet; adminToken?: string; ttlHours: number; + /** SPROUT_TTL_HOURS as ms, derived once here so read and sweep surfaces + * share one bound without converting units themselves. */ + legacyTtlMs: number; sweepCron: string; previewPortDefault: number; seedTimeout: number; @@ -165,43 +170,7 @@ export type Config = { telemetry: TelemetryState; /** Operator-owned trace export; active exactly when endpoint is set. */ otlp: OtlpConfig; - /** Null means off (unbounded); manifest preview.ttl overrides. */ - previewTtlMs: number | null; - /** Null means off; measured from the last successful deploy. */ - previewIdleMs: number | null; - /** Null means off (unbounded). */ - maxPreviewsPerRepo: number | null; - /** Null means off (unbounded). */ - maxPreviews: number | null; - /** Expected per-preview DB connections; null means no budget. */ - previewMaxDbConnections: number | null; - /** Instance ceiling for the budget arithmetic; null means no budget. */ - postgresMaxConnections: number | null; - /** Null/undefined means off (unbounded); manifest preview.ttl overrides. */ - previewTtlMs?: number | null; - /** Null/undefined means off; measured from the last successful deploy. */ - previewIdleMs?: number | null; - /** Null/undefined means off (unbounded). */ - maxPreviewsPerRepo?: number | null; - /** Null/undefined means off (unbounded). */ - maxPreviews?: number | null; - /** Expected per-preview DB connections; null/undefined means no budget. */ - previewMaxDbConnections?: number | null; - /** Instance ceiling for the budget arithmetic; null/undefined means no budget. */ - postgresMaxConnections?: number | null; - /** Null means off (unbounded); manifest preview.ttl overrides. */ - previewTtlMs: number | null; - /** Null means off; measured from the last successful deploy. */ - previewIdleMs: number | null; - /** Null means off (unbounded). */ - maxPreviewsPerRepo: number | null; - /** Null means off (unbounded). */ - maxPreviews: number | null; - /** Expected per-preview DB connections; null means no budget. */ - previewMaxDbConnections: number | null; - /** Instance ceiling for the budget arithmetic; null means no budget. */ - postgresMaxConnections: number | null; -}; +} & GovernanceConfig; function parsePositiveInt( name: string, @@ -505,8 +474,12 @@ function parseDashboardConfig(): DashboardConfig { } export function parseExtraGitlabHosts(raw: string): ReadonlySet { + const trimmed = raw.trim(); + if (trimmed === "") return new Set(); const out = new Set(); - for (const entry of splitListEntries(raw)) { + for (const part of trimmed.split(",")) { + const entry = part.trim(); + if (entry === "") continue; const sep = entry.includes("=") ? "=" : entry.includes(":") ? ":" : null; if (!sep) { throw new Error( @@ -601,6 +574,12 @@ export function loadConfig(): Config { auth: telemetryDestination.auth, }); + const ttlHours = parsePositiveInt( + "SPROUT_TTL_HOURS", + process.env.SPROUT_TTL_HOURS, + OPTIONAL_ENV_DEFAULTS.SPROUT_TTL_HOURS, + ); + return { postgres: parsePostgresConfig(), mail: parseMailConfig(), @@ -614,11 +593,8 @@ export function loadConfig(): Config { optionalEnv("SPROUT_FORGE_HOSTS"), ), adminToken: adminTokenRaw === "" ? undefined : adminTokenRaw, - ttlHours: parsePositiveInt( - "SPROUT_TTL_HOURS", - process.env.SPROUT_TTL_HOURS, - OPTIONAL_ENV_DEFAULTS.SPROUT_TTL_HOURS, - ), + ttlHours, + legacyTtlMs: hoursToMs(ttlHours), sweepCron: parseSweepCron( process.env.SPROUT_SWEEP_CRON, OPTIONAL_ENV_DEFAULTS.SPROUT_SWEEP_CRON, diff --git a/apps/server/src/http/app.ts b/apps/server/src/http/app.ts index 83e4f832..d5e785f3 100644 --- a/apps/server/src/http/app.ts +++ b/apps/server/src/http/app.ts @@ -31,5 +31,6 @@ export function startServer(deps: ServerDeps) { telemetry: deps.telemetry ?? NO_TELEMETRY, ...(deps.tracesPlugin ? { tracesPlugin: deps.tracesPlugin } : {}), governance: deps.config, + legacyTtlMs: deps.config.legacyTtlMs, }).listen(deps.config.port); } diff --git a/apps/server/src/http/auth.test.ts b/apps/server/src/http/auth.test.ts index 4731a4eb..5cb89fa8 100644 --- a/apps/server/src/http/auth.test.ts +++ b/apps/server/src/http/auth.test.ts @@ -357,6 +357,7 @@ describe("ensureAdminToken", () => { previewMaxDbConnections: null, postgresMaxConnections: null, }, + legacyTtlMs: 72 * 3600_000, }); const res = await app.handle( new Request("http://localhost/v1/admin/tokens", { diff --git a/apps/server/src/http/dashboard.test.ts b/apps/server/src/http/dashboard.test.ts index 4ff5b67f..e5859acd 100644 --- a/apps/server/src/http/dashboard.test.ts +++ b/apps/server/src/http/dashboard.test.ts @@ -391,6 +391,15 @@ describe("GET /dashboard (flag on)", () => { materialization: { traefikNetwork: "sprout-traefik" }, dashboard: { ...ENABLED }, telemetry: NO_TELEMETRY, + governance: { + previewTtlMs: null, + previewIdleMs: null, + maxPreviews: null, + maxPreviewsPerRepo: null, + previewMaxDbConnections: null, + postgresMaxConnections: null, + }, + legacyTtlMs: 72 * 3600_000, }); const res = await probe.handle( new Request("http://localhost/dashboard", { headers: dashboardAuth() }), diff --git a/apps/server/src/http/deploy.ts b/apps/server/src/http/deploy.ts index bdfbf87b..5351fcc7 100644 --- a/apps/server/src/http/deploy.ts +++ b/apps/server/src/http/deploy.ts @@ -491,20 +491,20 @@ export function resolveGovernanceRequest( ): | { ok: true; value: GovernanceManifest } | { ok: false; error: string; detail?: string } { - const ttl = parsePreviewGovernanceField(body.ttl, "ttl"); + const ttl = parsePreviewGovernanceField(body.ttl); if (!ttl.ok) { return { ok: false, error: "invalid_ttl", - detail: governanceIssueMessage("preview.ttl", ttl.issue), + detail: governanceIssueMessage("preview.ttl", ttl.raw), }; } - const idle = parsePreviewGovernanceField(body.idle_teardown, "idle_teardown"); + const idle = parsePreviewGovernanceField(body.idle_teardown); if (!idle.ok) { return { ok: false, error: "invalid_idle_teardown", - detail: governanceIssueMessage("preview.idle_teardown", idle.issue), + detail: governanceIssueMessage("preview.idle_teardown", idle.raw), }; } return { @@ -542,6 +542,7 @@ export function deploy( materialization: PreviewMaterializationCtx; telemetry: TelemetryDeployHook; governance: GovernanceConfig; + legacyTtlMs: number | null; }, ) { return async ({ @@ -677,6 +678,7 @@ export function deploy( plan, reseed: body.reseed === true, governanceMs, + legacyTtlMs: deps.legacyTtlMs, ...(deps.materialization.traefikTls !== undefined ? { traefikTls: deps.materialization.traefikTls } : {}), @@ -701,13 +703,15 @@ export function deploy( return presentPreviewSnapshot( accepted.value.row, deps.materialization.mail?.uiUrl, + deps.legacyTtlMs, ); }; } export function getPreview( deps: LifecycleDeps, - mailboxUrl?: string, + mailboxUrl: string | undefined, + legacyTtlMs: number | null, ) { return async ({ query, @@ -725,7 +729,7 @@ export function getPreview( query.pr_id, ); if (!result.ok) return mapResult(result, set); - return presentPreviewSnapshot(result.value, mailboxUrl); + return presentPreviewSnapshot(result.value, mailboxUrl, legacyTtlMs); }; } diff --git a/apps/server/src/http/health-deploy.test.ts b/apps/server/src/http/health-deploy.test.ts index 41e5c1a7..c14cf0d0 100644 --- a/apps/server/src/http/health-deploy.test.ts +++ b/apps/server/src/http/health-deploy.test.ts @@ -149,9 +149,13 @@ describe("POST /v1/deploy health polling", () => { status: "running", preview_url: "https://pr-42.myapp.preview.example.com", last_activity_at: expect.any(String), - expires_at: null, + expires_at: expect.any(String), expiry_reason: null, }); + // Unified derivation: boundless rows show created_at + legacy ttlHours. + const snap = res.body as { + expires_at: string | null; + }; const [row] = await testApp!.db .select() @@ -160,6 +164,9 @@ describe("POST /v1/deploy health polling", () => { and(eq(previews.canonicalRepoId, REPO), eq(previews.prId, 42)), ) .limit(1); + expect(snap.expires_at).toBe( + new Date(Date.parse(row!.createdAt) + 72 * 3600_000).toISOString(), + ); expect(row?.status).toBe("running"); expect(healthHits[0]).toBe(`http://${PLAN_FIRST_IP}:3000/health`); }); @@ -270,10 +277,19 @@ describe("POST /v1/deploy health polling", () => { status: "provisioning", created_at: expect.any(String), last_activity_at: null, - expires_at: null, + expires_at: expect.any(String), expiry_reason: null, }, ]); + // Never-ran rows carry no governance bounds, so the read surface shows + // the same legacy creation-age deadline the sweep enforces. + const listed = body.previews[0] as { + created_at: string; + expires_at: string | null; + }; + expect(listed.expires_at).toBe( + new Date(Date.parse(listed.created_at) + 72 * 3600_000).toISOString(), + ); }); test("list maps seeding to provisioning", async () => { diff --git a/apps/server/src/http/introspection.test.ts b/apps/server/src/http/introspection.test.ts index 3f83c251..bc81071a 100644 --- a/apps/server/src/http/introspection.test.ts +++ b/apps/server/src/http/introspection.test.ts @@ -104,10 +104,18 @@ describe("GET /v1/previews", () => { status: "running", created_at: expect.any(String), last_activity_at: expect.any(String), - expires_at: null, + expires_at: expect.any(String), expiry_reason: null, }, ]); + // Unified derivation: boundless rows show created_at + legacy ttlHours. + const listed = body.previews[0] as { + created_at: string; + expires_at: string | null; + }; + expect(listed.expires_at).toBe( + new Date(Date.parse(listed.created_at) + 72 * 3600_000).toISOString(), + ); }); }); diff --git a/apps/server/src/http/introspection.ts b/apps/server/src/http/introspection.ts index 1cefacc0..dfbac2c9 100644 --- a/apps/server/src/http/introspection.ts +++ b/apps/server/src/http/introspection.ts @@ -50,7 +50,11 @@ export type DropBody = { yes?: boolean; }; -export function listPreviews(db: StateDb, mailboxUrl?: string) { +export function listPreviews( + db: StateDb, + mailboxUrl: string | undefined, + legacyTtlMs: number | null, +) { return async ({ set }: { set: { status?: number | string } }) => { const rows = await db .select() @@ -65,7 +69,12 @@ export function listPreviews(db: StateDb, mailboxUrl?: string) { return { error: status.error }; } listed.push( - presentListedPreview(row, mailboxUrl, toDisplayStatus(status.value)), + presentListedPreview( + row, + mailboxUrl, + toDisplayStatus(status.value), + legacyTtlMs, + ), ); } diff --git a/apps/server/src/http/none-deploy.test.ts b/apps/server/src/http/none-deploy.test.ts index 3aa1b9e4..97552c4b 100644 --- a/apps/server/src/http/none-deploy.test.ts +++ b/apps/server/src/http/none-deploy.test.ts @@ -152,10 +152,18 @@ describe("none previews", () => { status: "running", created_at: expect.any(String), last_activity_at: expect.any(String), - expires_at: null, + expires_at: expect.any(String), expiry_reason: null, }, ]); + // Unified derivation: boundless rows show created_at + legacy ttlHours. + const listed = body.previews[0] as { + created_at: string; + expires_at: string | null; + }; + expect(listed.expires_at).toBe( + new Date(Date.parse(listed.created_at) + 72 * 3600_000).toISOString(), + ); }); test("companion services start with no database env", async () => { diff --git a/apps/server/src/http/preview-lifecycle.test.ts b/apps/server/src/http/preview-lifecycle.test.ts index 012e6e7c..24d157a1 100644 --- a/apps/server/src/http/preview-lifecycle.test.ts +++ b/apps/server/src/http/preview-lifecycle.test.ts @@ -156,7 +156,7 @@ describe("POST /v1/deploy", () => { status: "running", preview_url: "https://pr-42.myapp.preview.example.com", last_activity_at: expect.any(String), - expires_at: null, + expires_at: expect.any(String), expiry_reason: null, }); expect(fakePreviewDb!.created).toEqual(["sprout_myapp_pr42"]); @@ -168,6 +168,14 @@ describe("POST /v1/deploy", () => { and(eq(previews.canonicalRepoId, REPO), eq(previews.prId, 42)), ) .limit(1); + // Read surface and sweep share one derivation: with no governance bounds + // the legacy creation-age bound (test default ttlHours 72) applies. + const snap = res.body as { + expires_at: string | null; + }; + expect(snap.expires_at).toBe( + new Date(Date.parse(row!.createdAt) + 72 * 3600_000).toISOString(), + ); expect(row).toMatchObject({ canonicalRepoId: REPO, prId: 42, diff --git a/apps/server/src/http/routes.ts b/apps/server/src/http/routes.ts index 75f35287..066df280 100644 --- a/apps/server/src/http/routes.ts +++ b/apps/server/src/http/routes.ts @@ -43,6 +43,9 @@ export type RouteDeps = { telemetry: TelemetryDeployHook; tracesPlugin?: TracesHandle["plugin"]; governance: GovernanceConfig; + /** Legacy creation-age bound (SPROUT_TTL_HOURS as ms), computed once in + * loadConfig; routes never convert units themselves. */ + legacyTtlMs: number | null; }; function stubNotImplemented({ @@ -66,7 +69,9 @@ export function createRoutes(deps: RouteDeps) { materialization: deps.materialization, telemetry: deps.telemetry, governance: deps.governance, + legacyTtlMs: deps.legacyTtlMs, }; + const legacyTtlMs = deployDeps.legacyTtlMs; const mailboxUrl = deps.materialization.mail?.uiUrl; const accessDeps = { db: deps.db, @@ -107,7 +112,7 @@ export function createRoutes(deps: RouteDeps) { }) .delete("/tokens/:id", revokeToken(deps.db)), ) - .get("/previews", listPreviews(deps.db, mailboxUrl), { + .get("/previews", listPreviews(deps.db, mailboxUrl, legacyTtlMs), { beforeHandle: requireAdmin, }) .get("/previews/access", getPreviewAccess(accessDeps), { @@ -128,7 +133,9 @@ export function createRoutes(deps: RouteDeps) { body: dropBody, }) .post("/deploy", deploy(deployDeps), { body: deployBody }) - .get("/preview", getPreview(lifecycle, mailboxUrl), { query: previewQuery }) + .get("/preview", getPreview(lifecycle, mailboxUrl, legacyTtlMs), { + query: previewQuery, + }) .post("/teardown", teardown(lifecycle), { body: teardownBody }) .all("/*", stubNotImplemented), ); diff --git a/apps/server/src/http/test-helpers.ts b/apps/server/src/http/test-helpers.ts index 5df9ca78..b39e170e 100644 --- a/apps/server/src/http/test-helpers.ts +++ b/apps/server/src/http/test-helpers.ts @@ -111,6 +111,7 @@ export async function createTestApp( telemetry?: TelemetryDeployHook; tracesPlugin?: TracesHandle["plugin"]; governance?: GovernanceConfig; + legacyTtlMs?: number | null; } | string = {}, ): Promise { @@ -175,6 +176,7 @@ export async function createTestApp( previewMaxDbConnections: null, postgresMaxConnections: null, }, + legacyTtlMs: opts.legacyTtlMs ?? 72 * 3600_000, }), db, adminToken, diff --git a/apps/server/src/preview/admission.test.ts b/apps/server/src/preview/admission.test.ts index 2bfff6b2..6aea1c58 100644 --- a/apps/server/src/preview/admission.test.ts +++ b/apps/server/src/preview/admission.test.ts @@ -50,7 +50,7 @@ describe("checkDeployAdmission connection budget", () => { error: "preview_connection_budget_exceeded", detail: "preview connection budget exceeded (projected 24 > ceiling 20; " + - "1 active previews + 1 x 12 per preview)", + "2 previews x 12 per preview)", }); }); @@ -68,3 +68,90 @@ describe("checkDeployAdmission connection budget", () => { }); }); }); + +describe("checkDeployAdmission caps", () => { + let testDb: TestDb | undefined; + + afterEach(async () => { + await testDb?.cleanup(); + testDb = undefined; + }); + + async function livePreview(repo: string, prId: number): Promise { + testDb = await createTestDb(); + await testDb.db.insert(previews).values({ + canonicalRepoId: repo, + prId, + slug: "widgets", + hostname: `pr-${prId}.example.com`, + status: "running", + }); + } + + test("rejects a new preview at the per-repo cap", async () => { + await livePreview(REPO, 1); + const result = await checkDeployAdmission( + testDb!.db, + { repo: REPO, prId: 2 }, + { + previewTtlMs: null, + previewIdleMs: null, + maxPreviews: null, + maxPreviewsPerRepo: 1, + previewMaxDbConnections: null, + postgresMaxConnections: null, + }, + undefined, + ); + expect(result).toEqual({ + ok: false, + status: 429, + error: "preview_limit_reached", + detail: "SPROUT_MAX_PREVIEWS_PER_REPO limit reached (limit 1, current 1)", + }); + }); + + test("rejects a new preview at the total cap", async () => { + await livePreview(REPO, 1); + const result = await checkDeployAdmission( + testDb!.db, + { repo: REPO, prId: 2 }, + { + previewTtlMs: null, + previewIdleMs: null, + maxPreviews: 1, + maxPreviewsPerRepo: null, + previewMaxDbConnections: null, + postgresMaxConnections: null, + }, + undefined, + ); + expect(result).toEqual({ + ok: false, + status: 429, + error: "preview_limit_reached", + detail: "SPROUT_MAX_PREVIEWS limit reached (limit 1, current 1)", + }); + }); + + test("a refresh is exempt from caps", async () => { + await livePreview(REPO, 1); + const result = await checkDeployAdmission( + testDb!.db, + { repo: REPO, prId: 1 }, + { + previewTtlMs: null, + previewIdleMs: null, + maxPreviews: 1, + maxPreviewsPerRepo: 1, + previewMaxDbConnections: null, + postgresMaxConnections: null, + }, + undefined, + ); + expect(result).toEqual({ + ok: true, + value: { ttlMs: null, idleMs: null }, + }); + }); +}); diff --git a/apps/server/src/preview/admission.ts b/apps/server/src/preview/admission.ts index 1917015b..24375799 100644 --- a/apps/server/src/preview/admission.ts +++ b/apps/server/src/preview/admission.ts @@ -1,17 +1,18 @@ import { ne } from "drizzle-orm"; import { - connectionBudgetDetail, - connectionProjection, - governanceStatus, - previewLimitDetail, - resolveEffectiveGovernanceMs, type EffectiveGovernanceMs, type GovernanceConfig, type GovernanceManifest, - type GovernanceStatus, } from "@sprout/preview-env"; import type { StateDb } from "../infrastructure/db/client.ts"; import { previews } from "../infrastructure/db/schema.ts"; +import { + evaluateGovernance, + governanceStatus, + resolveEffectiveGovernanceMs, + violationMessage, + type GovernanceStatus, +} from "./governance.ts"; import type { Result } from "./result.ts"; /** @@ -20,6 +21,12 @@ import type { Result } from "./result.ts"; * exempt from caps and budget alike: it holds no new slot, and rejecting it * could block the redeploy that would reduce connections. Returns the * effective governance. + * + * Caps are best-effort under concurrent submits: the check runs before the + * row insert under the per-preview lock, so two simultaneous deploys for new + * targets can both pass when one slot remains. Exact enforcement would need + * admission serialized against the insert; the overshoot is accepted to keep + * deploys concurrent. */ export async function checkDeployAdmission( db: StateDb, @@ -40,58 +47,23 @@ export async function checkDeployAdmission( const status: GovernanceStatus = governanceStatus(rows); if (isNew) { - const perRepo = gov.maxPreviewsPerRepo; - if (perRepo !== null) { - const count = status.byRepo.get(input.repo) ?? 0; - if (count >= perRepo) { + const violation = evaluateGovernance(gov, status, { repo: input.repo })[0]; + if (violation !== undefined) { + if (violation.kind === "connection-budget") { return { ok: false, status: 429, - error: "preview_limit_reached", - detail: previewLimitDetail({ - cap: "SPROUT_MAX_PREVIEWS_PER_REPO", - value: perRepo, - count, - }), + error: "preview_connection_budget_exceeded", + detail: violationMessage(violation), }; } - } - const total = gov.maxPreviews; - if (total !== null && status.total >= total) { return { ok: false, status: 429, error: "preview_limit_reached", - detail: previewLimitDetail({ - cap: "SPROUT_MAX_PREVIEWS", - value: total, - count: status.total, - }), + detail: violationMessage(violation), }; } - const perPreview = gov.previewMaxDbConnections; - const ceiling = gov.postgresMaxConnections; - if (perPreview !== null && ceiling !== null) { - // The candidate is the +1: admission projects with it included. - const budget = connectionProjection({ - previews: status.total + 1, - perPreview, - ceiling, - }); - if (budget.over && budget.projected !== null) { - return { - ok: false, - status: 429, - error: "preview_connection_budget_exceeded", - detail: connectionBudgetDetail({ - projected: budget.projected, - ceiling, - perPreview, - active: status.total, - }), - }; - } - } } return { diff --git a/apps/server/src/preview/bring-up.ts b/apps/server/src/preview/bring-up.ts index e2f68864..53cc580d 100644 --- a/apps/server/src/preview/bring-up.ts +++ b/apps/server/src/preview/bring-up.ts @@ -197,7 +197,7 @@ async function closeRunning( ); return { ok: true, - value: previewSnapshotFromRow(updated), + value: previewSnapshotFromRow(updated, input.legacyTtlMs), }; } diff --git a/apps/server/src/preview/governance.test.ts b/apps/server/src/preview/governance.test.ts new file mode 100644 index 00000000..4360ff49 --- /dev/null +++ b/apps/server/src/preview/governance.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, test } from "bun:test"; +import type { GovernanceConfig } from "@sprout/preview-env"; +import { + connectionProjection, + evaluateGovernance, + governanceStatus, + hoursToMs, + parseGatewayCap, + parseGatewayDurationMs, + resolveEffectiveGovernanceMs, + resolvePreviewExpiry, + violationMessage, +} from "./governance.ts"; + +const REPO_A = "https://github.com/acme/a"; +const REPO_B = "https://github.com/acme/b"; + +const off: GovernanceConfig = { + previewTtlMs: null, + previewIdleMs: null, + maxPreviews: null, + maxPreviewsPerRepo: null, + previewMaxDbConnections: null, + postgresMaxConnections: null, +}; + +describe("server governance policy", () => { + test("status counts once for caps", () => { + const status = governanceStatus([ + { canonicalRepoId: REPO_A }, + { canonicalRepoId: REPO_A }, + { canonicalRepoId: REPO_B }, + ]); + expect(status.total).toBe(3); + expect(status.byRepo.get(REPO_A)).toBe(2); + }); + + test("projection is null without a budget, arithmetic with one", () => { + expect( + connectionProjection({ previews: 9, perPreview: null, ceiling: 100 }), + ).toBeNull(); + expect( + connectionProjection({ previews: 8, perPreview: 12, ceiling: 100 }), + ).toEqual({ projected: 96, over: false }); + expect( + connectionProjection({ previews: 9, perPreview: 12, ceiling: 100 }), + ).toEqual({ projected: 108, over: true }); + }); + + test("pending candidate counts against caps; settled rows only log over", () => { + const gov: GovernanceConfig = { ...off, maxPreviews: 1 }; + const status = governanceStatus([{ canonicalRepoId: REPO_A }]); + expect(evaluateGovernance(gov, status, { repo: REPO_B })).toEqual([ + { kind: "total-cap", cap: 1, count: 1 }, + ]); + expect(evaluateGovernance(gov, status, null)).toEqual([]); + }); + + test("per-repo violation precedes total, total precedes budget", () => { + const gov: GovernanceConfig = { + ...off, + maxPreviewsPerRepo: 1, + maxPreviews: 1, + previewMaxDbConnections: 12, + postgresMaxConnections: 20, + }; + const status = governanceStatus([{ canonicalRepoId: REPO_A }]); + const violations = evaluateGovernance(gov, status, { repo: REPO_A }); + expect(violations.map((v) => v.kind)).toEqual([ + "per-repo-cap", + "total-cap", + "connection-budget", + ]); + }); + + test("budget violation counts the previews the projection counts", () => { + const gov: GovernanceConfig = { + ...off, + previewMaxDbConnections: 12, + postgresMaxConnections: 20, + }; + const status = governanceStatus([{ canonicalRepoId: REPO_A }]); + expect(evaluateGovernance(gov, status, { repo: REPO_B })).toEqual([ + { + kind: "connection-budget", + projected: 24, + ceiling: 20, + perPreview: 12, + previews: 2, + }, + ]); + }); + + test("one message per violation kind", () => { + expect( + violationMessage({ + kind: "per-repo-cap", + repo: REPO_A, + cap: 1, + count: 1, + }), + ).toBe("SPROUT_MAX_PREVIEWS_PER_REPO limit reached (limit 1, current 1)"); + expect(violationMessage({ kind: "total-cap", cap: 1, count: 1 })).toBe( + "SPROUT_MAX_PREVIEWS limit reached (limit 1, current 1)", + ); + expect( + violationMessage({ + kind: "connection-budget", + projected: 24, + ceiling: 20, + perPreview: 12, + previews: 2, + }), + ).toBe( + "preview connection budget exceeded " + + "(projected 24 > ceiling 20; 2 previews x 12 per preview)", + ); + }); + + test("gateway duration accepts off and rejects garbage", () => { + expect(parseGatewayDurationMs("SPROUT_PREVIEW_TTL", undefined)).toBeNull(); + expect(parseGatewayDurationMs("SPROUT_PREVIEW_TTL", "off")).toBeNull(); + expect(parseGatewayDurationMs("SPROUT_PREVIEW_TTL", "7d")).toBe( + 7 * 24 * 60 * 60 * 1000, + ); + expect(parseGatewayDurationMs("SPROUT_PREVIEW_TTL", "2h")).toBe( + 2 * 60 * 60 * 1000, + ); + expect(() => + parseGatewayDurationMs("SPROUT_PREVIEW_TTL", "forever"), + ).toThrow("Invalid SPROUT_PREVIEW_TTL"); + }); + + test("gateway caps accept off and reject garbage", () => { + expect(parseGatewayCap("SPROUT_MAX_PREVIEWS", undefined)).toBeNull(); + expect(parseGatewayCap("SPROUT_MAX_PREVIEWS", "off")).toBeNull(); + expect(parseGatewayCap("SPROUT_MAX_PREVIEWS", "10")).toBe(10); + expect(() => parseGatewayCap("SPROUT_MAX_PREVIEWS", "0")).toThrow( + "Invalid SPROUT_MAX_PREVIEWS", + ); + }); + + test("manifest overrides gateway and off disables", () => { + const gateway = { previewTtlMs: 2 * 3600_000, previewIdleMs: null }; + expect( + resolveEffectiveGovernanceMs( + { ttl: { raw: "7d", ms: 7 * 86400_000 } }, + gateway, + ), + ).toEqual({ ttlMs: 7 * 86400_000, idleMs: null }); + expect( + resolveEffectiveGovernanceMs({ ttl: { raw: "off", ms: null } }, gateway), + ).toEqual({ ttlMs: null, idleMs: null }); + expect(resolveEffectiveGovernanceMs(undefined, gateway)).toEqual({ + ttlMs: 2 * 3600_000, + idleMs: null, + }); + expect( + resolveEffectiveGovernanceMs(undefined, { + previewTtlMs: null, + previewIdleMs: null, + }), + ).toEqual({ ttlMs: null, idleMs: null }); + }); + + test("expiry resolves the earlier bound and names the winner", () => { + const base = 1_000_000; + expect( + resolvePreviewExpiry({ + lastActivityMs: base, + createdAtMs: base, + ttlMs: 7 * 86400_000, + idleMs: null, + legacyTtlMs: null, + }), + ).toEqual({ expiresAtMs: base + 7 * 86400_000, bound: "ttl" }); + expect( + resolvePreviewExpiry({ + lastActivityMs: base, + createdAtMs: base, + ttlMs: 7 * 86400_000, + idleMs: 2 * 3600_000, + legacyTtlMs: null, + }), + ).toEqual({ expiresAtMs: base + 2 * 3600_000, bound: "idle" }); + expect( + resolvePreviewExpiry({ + lastActivityMs: null, + createdAtMs: base, + ttlMs: 7 * 86400_000, + idleMs: null, + legacyTtlMs: null, + }), + ).toEqual({ expiresAtMs: base + 7 * 86400_000, bound: "ttl" }); + expect( + resolvePreviewExpiry({ + lastActivityMs: null, + createdAtMs: null, + ttlMs: 7 * 86400_000, + idleMs: null, + legacyTtlMs: null, + }), + ).toEqual({ expiresAtMs: null, bound: null }); + expect( + resolvePreviewExpiry({ + lastActivityMs: base, + createdAtMs: base, + ttlMs: null, + idleMs: null, + legacyTtlMs: null, + }), + ).toEqual({ expiresAtMs: null, bound: null }); + }); + + test("legacy creation-age bound only collects rows without governance", () => { + const base = 1_000_000; + const legacyTtlMs = 72 * 3600_000; + expect( + resolvePreviewExpiry({ + lastActivityMs: null, + createdAtMs: base, + ttlMs: null, + idleMs: null, + legacyTtlMs, + }), + ).toEqual({ expiresAtMs: base + legacyTtlMs, bound: "ttl" }); + // Explicit governance wins over the legacy default, even when older. + expect( + resolvePreviewExpiry({ + lastActivityMs: null, + createdAtMs: base, + ttlMs: 7 * 86400_000, + idleMs: null, + legacyTtlMs, + }), + ).toEqual({ expiresAtMs: base + 7 * 86400_000, bound: "ttl" }); + // Unparseable creation still yields no deadline. + expect( + resolvePreviewExpiry({ + lastActivityMs: null, + createdAtMs: null, + ttlMs: null, + idleMs: null, + legacyTtlMs, + }), + ).toEqual({ expiresAtMs: null, bound: null }); + }); + + test("hours convert once for the legacy bound", () => { + expect(hoursToMs(72)).toBe(72 * 3600_000); + }); +}); diff --git a/apps/server/src/preview/governance.ts b/apps/server/src/preview/governance.ts new file mode 100644 index 00000000..a526f964 --- /dev/null +++ b/apps/server/src/preview/governance.ts @@ -0,0 +1,233 @@ +import { + parseDurationMs, + type EffectiveGovernanceMs, + type GovernanceConfig, + type GovernanceManifest, +} from "@sprout/preview-env"; + +/** One hours-to-ms conversion for the legacy creation-age bound. */ +export function hoursToMs(hours: number): number { + return hours * 60 * 60 * 1000; +} + +/** Gateway-level parse: empty/off means unbounded, otherwise a duration. */ +export function parseGatewayDurationMs( + envName: string, + raw: string | undefined, +): number | null { + const trimmed = raw?.trim() ?? ""; + if (trimmed === "" || trimmed.toLowerCase() === "off") return null; + const ms = parseDurationMs(trimmed); + if (ms === null) { + throw new Error( + `Invalid ${envName}: expected a duration (e.g. 7d, 2h, 30m) or off, got ${JSON.stringify(raw ?? "")}`, + ); + } + return ms; +} + +export function parseGatewayCap( + envName: string, + raw: string | undefined, +): number | null { + const trimmed = raw?.trim() ?? ""; + if (trimmed === "" || trimmed.toLowerCase() === "off") return null; + const value = Number(trimmed); + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`Invalid ${envName}: must be a positive integer or off`); + } + return value; +} + +/** Manifest override wins; "off" at the effective level disables. Pure + * lookup: the manifest already carries the parsed bound, so there is + * nothing to re-parse and nothing to throw. */ +export function resolveEffectiveGovernanceMs( + manifest: GovernanceManifest | undefined, + gateway: Pick, +): EffectiveGovernanceMs { + return { + ttlMs: manifest?.ttl !== undefined ? manifest.ttl.ms : gateway.previewTtlMs, + idleMs: + manifest?.idle_teardown !== undefined + ? manifest.idle_teardown.ms + : gateway.previewIdleMs, + }; +} + +/** + * One place decides the expiry base, the earlier bound, and which bound won. + * The base falls back to creation when no successful deploy has refreshed + * activity yet, so the read surface and the sweep plan from the same source. + * The legacy creation-age bound applies only when both governance bounds are + * null (rows that never received governance columns): once a preview carries + * an explicit ttl/idle, that policy wins over the legacy default. + */ +export function resolvePreviewExpiry(input: { + lastActivityMs: number | null; + createdAtMs: number | null; + ttlMs: number | null; + idleMs: number | null; + legacyTtlMs: number | null; +}): { expiresAtMs: number | null; bound: "ttl" | "idle" | null } { + const base = input.lastActivityMs ?? input.createdAtMs; + if (base === null) return { expiresAtMs: null, bound: null }; + const ttlDeadline = input.ttlMs !== null ? base + input.ttlMs : null; + const idleDeadline = input.idleMs !== null ? base + input.idleMs : null; + if (ttlDeadline !== null || idleDeadline !== null) { + if ( + ttlDeadline !== null && + (idleDeadline === null || ttlDeadline <= idleDeadline) + ) { + return { expiresAtMs: ttlDeadline, bound: "ttl" }; + } + return { expiresAtMs: idleDeadline, bound: "idle" }; + } + if (input.legacyTtlMs !== null && input.createdAtMs !== null) { + return { + expiresAtMs: input.createdAtMs + input.legacyTtlMs, + bound: "ttl", + }; + } + return { expiresAtMs: null, bound: null }; +} + +export type GovernanceStatus = { + total: number; + byRepo: Map; +}; + +/** + * One pass over live previews for the cap checks. Connection budget is not + * part of the status: both callers project it from `connectionProjection` + * with an explicit count, so there is no hidden +1 convention. + */ +export function governanceStatus( + previews: readonly { canonicalRepoId: string }[], +): GovernanceStatus { + const byRepo = new Map(); + for (const preview of previews) { + byRepo.set( + preview.canonicalRepoId, + (byRepo.get(preview.canonicalRepoId) ?? 0) + 1, + ); + } + return { total: previews.length, byRepo }; +} + +/** + * Projected connections for exactly `previews` live previews, or null when + * no budget is configured. Callers pass the candidate explicitly: admission + * includes the newcomer (+1), the sweep passes the current total as-is. + */ +export function connectionProjection(input: { + previews: number; + perPreview: number | null; + ceiling: number | null; +}): { projected: number; over: boolean } | null { + if (input.perPreview === null || input.ceiling === null) return null; + const projected = input.previews * input.perPreview; + return { projected, over: projected > input.ceiling }; +} + +export type GovernanceViolation = + | { kind: "per-repo-cap"; repo: string; cap: number; count: number } + | { kind: "total-cap"; cap: number; count: number } + | { + kind: "connection-budget"; + projected: number; + ceiling: number; + perPreview: number; + /** Live previews the projection counts, candidate included. */ + previews: number; + }; + +/** + * Single evaluator for the cap/budget policy, shared by admission (which maps + * violations to 429s) and the sweep (which logs them). `pending` is the + * not-yet-inserted candidate: admission passes it so the newcomer counts + * against the caps (`count + 1 > cap`, i.e. reject at the cap), while the + * sweep passes null to report only rows already over (`count > cap`). + */ +export function evaluateGovernance( + gov: GovernanceConfig, + status: GovernanceStatus, + pending: { repo: string } | null, +): GovernanceViolation[] { + const violations: GovernanceViolation[] = []; + const incoming = pending === null ? 0 : 1; + + const perRepo = gov.maxPreviewsPerRepo; + if (perRepo !== null) { + if (pending !== null) { + const count = status.byRepo.get(pending.repo) ?? 0; + if (count + incoming > perRepo) { + violations.push({ + kind: "per-repo-cap", + repo: pending.repo, + cap: perRepo, + count, + }); + } + } else { + for (const [repo, count] of status.byRepo) { + if (count > perRepo) { + violations.push({ kind: "per-repo-cap", repo, cap: perRepo, count }); + } + } + } + } + + const total = gov.maxPreviews; + if (total !== null && status.total + incoming > total) { + violations.push({ kind: "total-cap", cap: total, count: status.total }); + } + + const perPreview = gov.previewMaxDbConnections; + const ceiling = gov.postgresMaxConnections; + if (perPreview !== null && ceiling !== null) { + const budget = connectionProjection({ + previews: status.total + incoming, + perPreview, + ceiling, + }); + if (budget !== null && budget.over) { + violations.push({ + kind: "connection-budget", + projected: budget.projected, + ceiling, + perPreview, + previews: status.total + incoming, + }); + } + } + + return violations; +} + +/** + * Sole violation presenter: admission uses it as the 429 detail, the sweep + * as the log body. The budget counts the previews the projection counts + * (candidate included on admission), so projected always equals + * previews x perPreview with no hidden convention. + */ +export function violationMessage(violation: GovernanceViolation): string { + switch (violation.kind) { + case "per-repo-cap": + return ( + `SPROUT_MAX_PREVIEWS_PER_REPO limit reached ` + + `(limit ${violation.cap}, current ${violation.count})` + ); + case "total-cap": + return ( + `SPROUT_MAX_PREVIEWS limit reached ` + + `(limit ${violation.cap}, current ${violation.count})` + ); + case "connection-budget": + return ( + `preview connection budget exceeded ` + + `(projected ${violation.projected} > ceiling ${violation.ceiling}; ` + + `${violation.previews} previews x ${violation.perPreview} per preview)` + ); + } +} diff --git a/apps/server/src/preview/locks.ts b/apps/server/src/preview/locks.ts index d21ee88e..c19ca46f 100644 --- a/apps/server/src/preview/locks.ts +++ b/apps/server/src/preview/locks.ts @@ -1,46 +1,64 @@ /** Control-plane mutations serialize per (repo, prId) via an in-process queue. */ -const previewLocks = new Map>(); -const previewLockCounts = new Map(); - -/** Catalog DROP/CREATE serialize per dbName so sweep cannot race provision. */ -const dbNameLocks = new Map>(); - function previewKey(repo: string, prId: number): string { return `${repo}\0${prId}`; } -function withKeyedLock( - locks: Map>, - key: string, - fn: () => Promise, -): Promise { - const prev = locks.get(key) ?? Promise.resolve(); - const run = prev.then(fn, fn); - locks.set( - key, - run.then( - () => undefined, - () => undefined, - ), - ); - return run; +/** + * One keyed lock owns both its queue and its occupancy count, so no caller + * can observe them disagreeing. + */ +function createKeyedLock() { + const locks = new Map>(); + const counts = new Map(); + + function withLock(key: string, fn: () => Promise): Promise { + counts.set(key, (counts.get(key) ?? 0) + 1); + const prev = locks.get(key) ?? Promise.resolve(); + const run = prev.then(fn, fn); + locks.set( + key, + run.then( + () => undefined, + () => undefined, + ), + ); + const release = () => { + const next = (counts.get(key) ?? 1) - 1; + if (next <= 0) counts.delete(key); + else counts.set(key, next); + }; + run.then(release, release); + return run; + } + + /** Non-blocking variant: acquired:false when a holder is inside. */ + function tryWithLock( + key: string, + fn: () => Promise, + ): Promise<{ acquired: true; value: T } | { acquired: false }> { + if ((counts.get(key) ?? 0) > 0) { + return Promise.resolve({ acquired: false as const }); + } + return withLock(key, fn).then((value) => ({ + acquired: true as const, + value, + })); + } + + return { withLock, tryWithLock }; } +const previewLock = createKeyedLock(); + +/** Catalog DROP/CREATE serialize per dbName so sweep cannot race provision. */ +const dbNameLock = createKeyedLock(); + export function withPreviewLock( repo: string, prId: number, fn: () => Promise, ): Promise { - const key = previewKey(repo, prId); - previewLockCounts.set(key, (previewLockCounts.get(key) ?? 0) + 1); - const run = withKeyedLock(previewLocks, key, fn); - const release = () => { - const next = (previewLockCounts.get(key) ?? 1) - 1; - if (next <= 0) previewLockCounts.delete(key); - else previewLockCounts.set(key, next); - }; - run.then(release, release); - return run; + return previewLock.withLock(previewKey(repo, prId), fn); } /** Non-blocking variant: returns acquired:false when a deploy holds the lock. */ @@ -49,21 +67,12 @@ export function tryWithPreviewLock( prId: number, fn: () => Promise, ): Promise<{ acquired: true; value: T } | { acquired: false }> { - if (isPreviewLocked(repo, prId)) - return Promise.resolve({ acquired: false as const }); - return withPreviewLock(repo, prId, fn).then((value) => ({ - acquired: true as const, - value, - })); -} - -function isPreviewLocked(repo: string, prId: number): boolean { - return (previewLockCounts.get(previewKey(repo, prId)) ?? 0) > 0; + return previewLock.tryWithLock(previewKey(repo, prId), fn); } export function withDbNameLock( dbName: string, fn: () => Promise, ): Promise { - return withKeyedLock(dbNameLocks, dbName, fn); + return dbNameLock.withLock(dbName, fn); } diff --git a/apps/server/src/preview/runtime.mail.test.ts b/apps/server/src/preview/runtime.mail.test.ts index 45605c80..0b87dc19 100644 --- a/apps/server/src/preview/runtime.mail.test.ts +++ b/apps/server/src/preview/runtime.mail.test.ts @@ -239,6 +239,7 @@ describe("buildMaterializationCtx", () => { gitlabToken: "", extraGitlabHosts: new Set(), ttlHours: 72, + legacyTtlMs: 72 * 3600_000, sweepCron: "*/30 * * * *", previewPortDefault: 8080, seedTimeout: 180, @@ -268,6 +269,7 @@ describe("buildMaterializationCtx", () => { gitlabToken: "", extraGitlabHosts: new Set(), ttlHours: 72, + legacyTtlMs: 72 * 3600_000, sweepCron: "*/30 * * * *", previewPortDefault: 8080, seedTimeout: 180, diff --git a/apps/server/src/preview/snapshot.ts b/apps/server/src/preview/snapshot.ts index 7e4918f6..f5bf6964 100644 --- a/apps/server/src/preview/snapshot.ts +++ b/apps/server/src/preview/snapshot.ts @@ -1,4 +1,6 @@ -import { resolvePreviewExpiry, deriveMailFromName } from "@sprout/preview-env"; +import { deriveMailFromName } from "@sprout/preview-env"; +import { parseUnambiguousUtcMs } from "../infrastructure/db/instant.ts"; +import { resolvePreviewExpiry } from "./governance.ts"; import type { Result } from "./result.ts"; import type { PreviewRow } from "./row.ts"; import type { @@ -28,33 +30,34 @@ export function parsePreviewStatus(status: string): Result { } /** - * Read-surface expiry: derived from the same base and bounds the sweep plans - * from, so the displayed deadline and the deletion decision cannot diverge. - * Tombstones report null, matching the pre-derivation display. + * Read-surface expiry: derived from the same inputs the sweep plans from — + * the canonical UTC-instant parse, the governance bounds, and the legacy + * creation-age bound — so the displayed deadline and the deletion decision + * come from one derivation. Tombstones report null, matching the + * pre-derivation display. */ export function expiresAtForRow( row: Pick< PreviewRow, "status" | "lastActivityAt" | "createdAt" | "ttlMs" | "idleMs" >, + legacyTtlMs: number | null, ): string | null { if (row.status === "removed") return null; - const lastActivityMs = - row.lastActivityAt == null ? null : Date.parse(row.lastActivityAt); - const createdAtMs = Date.parse(row.createdAt); const { expiresAtMs } = resolvePreviewExpiry({ - lastActivityMs: - lastActivityMs !== null && Number.isFinite(lastActivityMs) - ? lastActivityMs - : null, - createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : null, + lastActivityMs: parseUnambiguousUtcMs(row.lastActivityAt ?? ""), + createdAtMs: parseUnambiguousUtcMs(row.createdAt), ttlMs: row.ttlMs ?? null, idleMs: row.idleMs ?? null, + legacyTtlMs, }); return expiresAtMs === null ? null : new Date(expiresAtMs).toISOString(); } -export function previewSnapshotFromRow(row: PreviewRow): PreviewSnapshot { +export function previewSnapshotFromRow( + row: PreviewRow, + legacyTtlMs: number | null, +): PreviewSnapshot { const status = parsePreviewStatus(row.status); const parsed = status.ok ? status.value : "failed"; const effectiveFrom = row.mailFrom ?? undefined; @@ -80,7 +83,7 @@ export function previewSnapshotFromRow(row: PreviewRow): PreviewSnapshot { ? { last_error_detail: row.lastErrorDetail } : {}), last_activity_at: row.lastActivityAt ?? null, - expires_at: expiresAtForRow(row), + expires_at: expiresAtForRow(row, legacyTtlMs), expiry_reason: row.expiryReason ?? null, }; } @@ -93,8 +96,9 @@ export function previewSnapshotFromRow(row: PreviewRow): PreviewSnapshot { export function presentPreviewSnapshot( row: PreviewRow, mailboxUrl: string | undefined, + legacyTtlMs: number | null, ): PreviewSnapshot { - const snapshot = previewSnapshotFromRow(row); + const snapshot = previewSnapshotFromRow(row, legacyTtlMs); if (snapshot.mail_from === undefined || mailboxUrl === undefined) { return snapshot; } @@ -126,8 +130,9 @@ export function presentListedPreview( row: PreviewRow, mailboxUrl: string | undefined, status: DisplayPreviewStatus, + legacyTtlMs: number | null, ): ListedPreview { - const snap = presentPreviewSnapshot(row, mailboxUrl); + const snap = presentPreviewSnapshot(row, mailboxUrl, legacyTtlMs); return { canonical_repo_id: snap.canonical_repo_id, pr_id: snap.pr_id, @@ -137,7 +142,7 @@ export function presentListedPreview( status, created_at: row.createdAt, last_activity_at: row.lastActivityAt ?? null, - expires_at: expiresAtForRow(row), + expires_at: expiresAtForRow(row, legacyTtlMs), expiry_reason: row.expiryReason ?? null, ...(snap.mail_from !== undefined ? { mail_from: snap.mail_from } : {}), ...(snap.mail_from_name !== undefined diff --git a/apps/server/src/preview/types.ts b/apps/server/src/preview/types.ts index f085d551..b51288a3 100644 --- a/apps/server/src/preview/types.ts +++ b/apps/server/src/preview/types.ts @@ -120,6 +120,9 @@ export type ProvisionInput = { auth?: PreviewAuthSpec; previewAuth?: PreviewAuthConfig; governanceMs: EffectiveGovernanceMs; + /** Legacy creation-age bound (SPROUT_TTL_HOURS as ms) for the read surface, + * so the deploy response shows the same deadline the sweep enforces. */ + legacyTtlMs: number | null; }; export type TeardownInput = { @@ -161,9 +164,9 @@ export type PreviewSnapshot = { mail_from_name?: string; last_error?: string; last_error_detail?: string; - last_activity_at?: string | null; - expires_at?: string | null; - expiry_reason?: string | null; + last_activity_at: string | null; + expires_at: string | null; + expiry_reason: string | null; }; export type TeardownSnapshot = { diff --git a/apps/server/src/sweep/governance.test.ts b/apps/server/src/sweep/governance.test.ts index e442466c..acab90d6 100644 --- a/apps/server/src/sweep/governance.test.ts +++ b/apps/server/src/sweep/governance.test.ts @@ -15,6 +15,9 @@ function preview(over: Partial = {}): SweepPreview { createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), lastActivityAt: null, status: "running", + lastActivityMs: null, + ttlMs: null, + idleMs: null, ...over, }; } @@ -32,7 +35,7 @@ describe("governance expiry", () => { }); // now is past 7d setSystemTime(new Date(base + 8 * 86400_000)); - expect(planGovernanceExpiry(p, Date.now())).toBe("sweep:ttl-expired"); + expect(planGovernanceExpiry(p, Date.now(), null)).toBe("sweep:ttl-expired"); }); test("expires by idle with idle reason", () => { @@ -43,9 +46,9 @@ describe("governance expiry", () => { idleMs: 2 * 3600_000, }); expect( - planGovernanceExpiry(p, base + 3 * 3600_000), + planGovernanceExpiry(p, base + 3 * 3600_000, null), ).toBe("sweep:idle-expired"); - expect(planGovernanceExpiry(p, base + 3600_000)).toBeNull(); + expect(planGovernanceExpiry(p, base + 3600_000, null)).toBeNull(); }); test("off at either level disables (null means keep)", () => { @@ -55,7 +58,19 @@ describe("governance expiry", () => { ttlMs: null, idleMs: null, }); - expect(planGovernanceExpiry(p, base + 30 * 86400_000)).toBeNull(); + expect(planGovernanceExpiry(p, base + 30 * 86400_000, null)).toBeNull(); + }); + + test("legacy bound collects boundless rows by creation age", () => { + const base = Date.parse("2026-09-02T12:00:00.000Z"); + const legacyTtlMs = 72 * 3600_000; + const p = preview({ createdAtMs: base }); + expect(planGovernanceExpiry(p, base + legacyTtlMs, legacyTtlMs)).toBe( + "sweep:ttl-expired", + ); + expect( + planGovernanceExpiry(p, base + legacyTtlMs - 1, legacyTtlMs), + ).toBeNull(); }); test("refreshed activity postpones expiry", () => { @@ -66,9 +81,9 @@ describe("governance expiry", () => { ttlMs: 7 * 86400_000, idleMs: null, }); - expect(planGovernanceExpiry(stale, base + 8 * 86400_000)).toBeNull(); + expect(planGovernanceExpiry(stale, base + 8 * 86400_000, null)).toBeNull(); expect( - planGovernanceExpiry(stale, refreshed + 7 * 86400_000), + planGovernanceExpiry(stale, refreshed + 7 * 86400_000, null), ).toBe("sweep:ttl-expired"); }); @@ -96,7 +111,7 @@ describe("governance expiry", () => { deletions.push(d); return true; }, - ttlHours: 72 * 365, + legacyTtlMs: 72 * 365 * 3600_000, governance: { previewTtlMs: null, previewIdleMs: null, @@ -123,7 +138,7 @@ describe("governance expiry", () => { listPreviewContainers: async () => [], listOpenPrIds: async () => [5, 6], drop: async () => true, - ttlHours: 72 * 365, + legacyTtlMs: 72 * 365 * 3600_000, governance: { previewTtlMs: null, previewIdleMs: null, diff --git a/apps/server/src/sweep/live-ports.test.ts b/apps/server/src/sweep/live-ports.test.ts index 2c9d2074..841418ca 100644 --- a/apps/server/src/sweep/live-ports.test.ts +++ b/apps/server/src/sweep/live-ports.test.ts @@ -92,7 +92,7 @@ describe("createLiveSweepPorts", () => { listOpenPrIds: async () => [], }, governance: offGovernance, - ttlHours: 72, + legacyTtlMs: 72 * 3600_000, log: () => {}, }); @@ -138,7 +138,7 @@ describe("createLiveSweepPorts", () => { }), forge: { listOpenPrIds: async () => [1] }, governance: offGovernance, - ttlHours: 72, + legacyTtlMs: 72 * 3600_000, }); const listed = await ports.listPreviews(); @@ -185,7 +185,7 @@ describe("createLiveSweepPorts", () => { }), forge: { listOpenPrIds: async () => [1] }, governance: offGovernance, - ttlHours: 72, + legacyTtlMs: 72 * 3600_000, log: (message) => { logs.push(message); }, @@ -241,7 +241,7 @@ describe("createLiveSweepPorts", () => { }), forge: { listOpenPrIds: async () => [1] }, governance: offGovernance, - ttlHours: 1, + legacyTtlMs: 3600_000, log: (message) => { logs.push(message); }, @@ -298,7 +298,7 @@ describe("createLiveSweepPorts", () => { }), forge: { listOpenPrIds: async () => [] }, governance: offGovernance, - ttlHours: 72, + legacyTtlMs: 72 * 3600_000, log: (message) => { logs.push(message); }, @@ -354,7 +354,7 @@ describe("createLiveSweepPorts", () => { }), forge: { listOpenPrIds: async () => [] }, governance: offGovernance, - ttlHours: 72, + legacyTtlMs: 72 * 3600_000, log: () => {}, }); @@ -412,7 +412,7 @@ describe("createLiveSweepPorts", () => { }), forge: { listOpenPrIds: async () => [42] }, governance: offGovernance, - ttlHours: 72, + legacyTtlMs: 72 * 3600_000, }); const removed = await ports.drop({ @@ -465,7 +465,7 @@ describe("createLiveSweepPorts", () => { }), forge: { listOpenPrIds: async () => [42] }, governance: offGovernance, - ttlHours: 72, + legacyTtlMs: 72 * 3600_000, }); const removed = await ports.drop({ @@ -523,7 +523,7 @@ describe("createLiveSweepPorts", () => { }, }, governance: offGovernance, - ttlHours: 72, + legacyTtlMs: 72 * 3600_000, }); const removed = await ports.drop({ @@ -573,7 +573,7 @@ describe("createLiveSweepPorts", () => { }), forge: { listOpenPrIds: async () => [42] }, governance: offGovernance, - ttlHours: 72, + legacyTtlMs: 72 * 3600_000, }); const removed = await ports.drop({ @@ -617,7 +617,7 @@ describe("createLiveSweepPorts", () => { }), forge: { listOpenPrIds: async () => [42] }, governance: offGovernance, - ttlHours: 72, + legacyTtlMs: 72 * 3600_000, }); const removed = await ports.drop({ @@ -645,7 +645,7 @@ describe("createLiveSweepPorts", () => { }), forge: { listOpenPrIds: async () => [] }, governance: offGovernance, - ttlHours: 72, + legacyTtlMs: 72 * 3600_000, }); const removed = await ports.drop({ @@ -680,7 +680,7 @@ describe("createLiveSweepPorts", () => { }), forge: { listOpenPrIds: async () => [] }, governance: offGovernance, - ttlHours: 72, + legacyTtlMs: 72 * 3600_000, log: () => {}, }); diff --git a/apps/server/src/sweep/live-ports.ts b/apps/server/src/sweep/live-ports.ts index 411f5039..336bf071 100644 --- a/apps/server/src/sweep/live-ports.ts +++ b/apps/server/src/sweep/live-ports.ts @@ -25,7 +25,7 @@ export type LiveSweepDeps = { app: Pick; dataVolumes: PreviewDataVolumes; forge: ForgeClient; - ttlHours: number; + legacyTtlMs: number | null; governance: SweepPorts["governance"]; log?: SweepPorts["log"]; }; @@ -61,7 +61,7 @@ async function removeControlPlane( export function createLiveSweepPorts(deps: LiveSweepDeps): SweepPorts { return { - ttlHours: deps.ttlHours, + legacyTtlMs: deps.legacyTtlMs, governance: deps.governance, log: deps.log, listPreviews: async () => { diff --git a/apps/server/src/sweep/reconcile.test.ts b/apps/server/src/sweep/reconcile.test.ts index bcd471ce..bbff1dca 100644 --- a/apps/server/src/sweep/reconcile.test.ts +++ b/apps/server/src/sweep/reconcile.test.ts @@ -60,7 +60,7 @@ function memoryPorts(seed: { deletions.push(deletion); return true; }, - ttlHours: 72, + legacyTtlMs: 72 * 3600_000, governance: { previewTtlMs: null, previewIdleMs: null, @@ -96,6 +96,9 @@ describe("runSweepPass", () => { lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", + lastActivityMs: null, + ttlMs: null, + idleMs: null, }, ], openPrs: { "https://github.com/acme/widgets": [99] }, @@ -129,6 +132,9 @@ describe("runSweepPass", () => { lastActivityAt: null, createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), status: "running", + lastActivityMs: null, + ttlMs: null, + idleMs: null, }, ], openPrs: { "https://github.com/acme/widgets": [5] }, @@ -229,6 +235,9 @@ describe("runSweepPass", () => { lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", + lastActivityMs: null, + ttlMs: null, + idleMs: null, }, ], catalog: [{ dbName: "sprout_widgets_pr3", slug: "widgets", prId: 3 }], @@ -312,6 +321,9 @@ describe("runSweepPass", () => { lastActivityAt: null, createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), status: "running", + lastActivityMs: null, + ttlMs: null, + idleMs: null, }, { canonicalRepoId: "https://github.com/acme/widgets", @@ -322,6 +334,9 @@ describe("runSweepPass", () => { lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", + lastActivityMs: null, + ttlMs: null, + idleMs: null, }, ], catalog: [{ dbName: "sprout_widgets_pr99", slug: "widgets", prId: 99 }], @@ -365,6 +380,9 @@ describe("runSweepPass", () => { lastActivityAt: null, createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), status: "running", + lastActivityMs: null, + ttlMs: null, + idleMs: null, }, { canonicalRepoId: "https://github.com/acme/widgets", @@ -375,6 +393,9 @@ describe("runSweepPass", () => { lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", + lastActivityMs: null, + ttlMs: null, + idleMs: null, }, ], catalog: [{ dbName: "sprout_widgets_pr99", slug: "widgets", prId: 99 }], @@ -419,6 +440,9 @@ describe("runSweepPass", () => { lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", + lastActivityMs: null, + ttlMs: null, + idleMs: null, }, { canonicalRepoId: "https://github.com/acme/repo-b", @@ -429,6 +453,9 @@ describe("runSweepPass", () => { lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", + lastActivityMs: null, + ttlMs: null, + idleMs: null, }, ], openPrs: { @@ -481,6 +508,9 @@ describe("runSweepPass", () => { lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", + lastActivityMs: null, + ttlMs: null, + idleMs: null, }, { canonicalRepoId: "https://gitlab.com/acme/sprouts", @@ -491,6 +521,9 @@ describe("runSweepPass", () => { lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", + lastActivityMs: null, + ttlMs: null, + idleMs: null, }, { canonicalRepoId: "https://github.com/acme/widgets", @@ -501,6 +534,9 @@ describe("runSweepPass", () => { lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", + lastActivityMs: null, + ttlMs: null, + idleMs: null, }, { canonicalRepoId: "https://gitlab.com/acme/sprouts", @@ -511,6 +547,9 @@ describe("runSweepPass", () => { lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", + lastActivityMs: null, + ttlMs: null, + idleMs: null, }, ], }); @@ -553,6 +592,9 @@ describe("runSweepPass", () => { lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", + lastActivityMs: null, + ttlMs: null, + idleMs: null, }, ], openPrs: { @@ -583,6 +625,9 @@ describe("runSweepPass", () => { lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", + lastActivityMs: null, + ttlMs: null, + idleMs: null, }, ], openPrs: { @@ -608,6 +653,9 @@ describe("runSweepPass", () => { lastActivityAt: null, createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), status: "running", + lastActivityMs: null, + ttlMs: null, + idleMs: null, }, { canonicalRepoId: "https://github.com/acme/widgets", @@ -618,6 +666,9 @@ describe("runSweepPass", () => { lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", + lastActivityMs: null, + ttlMs: null, + idleMs: null, }, ], catalog: [{ dbName: "sprout_widgets_pr99", slug: "widgets", prId: 99 }], @@ -671,6 +722,9 @@ describe("runSweepPass", () => { lastActivityAt: null, createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), status: "running", + lastActivityMs: null, + ttlMs: null, + idleMs: null, }, ], catalog: new Error("postgres catalog boom"), @@ -722,6 +776,9 @@ describe("runSweepPass", () => { lastActivityAt: null, createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", + lastActivityMs: null, + ttlMs: null, + idleMs: null, }, ], catalog: [{ dbName: "sprout_widgets_pr3", slug: "widgets", prId: 3 }], @@ -747,6 +804,9 @@ describe("runSweepPass", () => { lastActivityAt: null, createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), status: "running", + lastActivityMs: null, + ttlMs: null, + idleMs: null, }, { canonicalRepoId: "https://github.com/acme/widgets", @@ -757,6 +817,9 @@ describe("runSweepPass", () => { lastActivityAt: null, createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), status: "running", + lastActivityMs: null, + ttlMs: null, + idleMs: null, }, ], dropErrorFor: (deletion) => diff --git a/apps/server/src/sweep/reconcile.ts b/apps/server/src/sweep/reconcile.ts index 29d7e26b..d315cd88 100644 --- a/apps/server/src/sweep/reconcile.ts +++ b/apps/server/src/sweep/reconcile.ts @@ -1,10 +1,11 @@ import type { DataVolumeRef, GovernanceConfig } from "@sprout/preview-env"; +import { isForgeApiError } from "../forge/types.ts"; import { - connectionProjection, + evaluateGovernance, governanceStatus, resolvePreviewExpiry, -} from "@sprout/preview-env"; -import { isForgeApiError } from "../forge/types.ts"; + violationMessage, +} from "../preview/governance.ts"; import type { PreviewExpiryReason } from "../preview/types.ts"; export type PreviewRef = { slug: string; prId: number }; @@ -20,9 +21,9 @@ export type SweepPreview = { createdAtMs: number | null; lastActivityAt: string | null; status: string; - lastActivityMs?: number | null; - ttlMs?: number | null; - idleMs?: number | null; + lastActivityMs: number | null; + ttlMs: number | null; + idleMs: number | null; }; export type SweepDeletion = @@ -50,7 +51,9 @@ export type SweepPorts = { listOpenPrIds: (canonicalRepoId: string) => Promise; /** True if resources were removed; false if the plan was stale. */ drop: (deletion: SweepDeletion) => Promise; - ttlHours: number; + /** Legacy creation-age bound (SPROUT_TTL_HOURS as ms), computed once in + * loadConfig; the sweep never converts units itself. */ + legacyTtlMs: number | null; governance: GovernanceConfig; log?: (message: string, deletion?: SweepDeletion) => void; }; @@ -177,7 +180,7 @@ export async function runSweepPass(ports: SweepPorts): Promise } const nowMs = Date.now(); - const cutoff = nowMs - ports.ttlHours * 60 * 60 * 1000; + const legacyTtlMs = ports.legacyTtlMs; const previewKeys = new Set(); const remainingPreviews: SweepPreview[] = []; const expiryDeletions: SweepDeletion[] = []; @@ -186,7 +189,7 @@ export async function runSweepPass(ports: SweepPorts): Promise if (preview.status === "removed") continue; previewKeys.add(`${preview.slug}:${preview.prId}`); - const governanceExpiry = planGovernanceExpiry(preview, nowMs); + const governanceExpiry = planGovernanceExpiry(preview, nowMs, legacyTtlMs); if (governanceExpiry) { expiryDeletions.push({ reason: governanceExpiry, @@ -197,16 +200,6 @@ export async function runSweepPass(ports: SweepPorts): Promise createdAt: preview.createdAt, lastActivityAt: preview.lastActivityAt, }); - } else if (preview.createdAtMs !== null && preview.createdAtMs < cutoff) { - expiryDeletions.push({ - reason: "sweep:ttl-expired", - canonicalRepoId: preview.canonicalRepoId, - prId: preview.prId, - slug: preview.slug, - dbName: preview.dbName, - createdAt: preview.createdAt, - lastActivityAt: preview.lastActivityAt, - }); } else { remainingPreviews.push(preview); } @@ -278,17 +271,20 @@ export async function runSweepPass(ports: SweepPorts): Promise * Governance expiry from the shared preview-env policy: one place decides * the base, the earlier bound, and which bound won. Returns the reason, or * null to keep. The cheap activity signal is the last successful deploy - * (including reseed/reset); see docs/previews.md. + * (including reseed/reset); see docs/previews.md. The legacy creation-age + * bound only collects rows that never received governance columns. */ export function planGovernanceExpiry( preview: SweepPreview, nowMs: number, + legacyTtlMs: number | null, ): "sweep:ttl-expired" | "sweep:idle-expired" | null { const { expiresAtMs, bound } = resolvePreviewExpiry({ - lastActivityMs: preview.lastActivityMs ?? null, + lastActivityMs: preview.lastActivityMs, createdAtMs: preview.createdAtMs, - ttlMs: preview.ttlMs ?? null, - idleMs: preview.idleMs ?? null, + ttlMs: preview.ttlMs, + idleMs: preview.idleMs, + legacyTtlMs, }); if (expiresAtMs === null || nowMs < expiresAtMs) return null; return bound === "ttl" ? "sweep:ttl-expired" : "sweep:idle-expired"; @@ -298,38 +294,9 @@ function logOverCap( ports: SweepPorts, previews: SweepPreview[], ): void { - const gov = ports.governance; const live = previews.filter((p) => p.status !== "removed"); const status = governanceStatus(live); - const maxTotal = gov.maxPreviews; - if (maxTotal !== null && status.total > maxTotal) { - ports.log?.( - `sweep over cap SPROUT_MAX_PREVIEWS (${status.total} > ${maxTotal})`, - ); - } - const perRepo = gov.maxPreviewsPerRepo; - if (perRepo !== null) { - for (const [repo, count] of status.byRepo) { - if (count > perRepo) { - ports.log?.( - `sweep over cap SPROUT_MAX_PREVIEWS_PER_REPO ${repo} (${count} > ${perRepo})`, - ); - } - } - } - const perPreview = gov.previewMaxDbConnections; - const ceiling = gov.postgresMaxConnections; - if (perPreview === null || ceiling === null) return; - // Current load as-is: no candidate, so no +1 — admission adds it. - const { projected, over } = connectionProjection({ - previews: status.total, - perPreview, - ceiling, - }); - if (over && projected !== null) { - ports.log?.( - `sweep over connection budget (projected ${projected} > ceiling ${ceiling}; ` + - `${status.total} previews x ${perPreview} per preview)`, - ); + for (const violation of evaluateGovernance(ports.governance, status, null)) { + ports.log?.(`sweep over governance limit: ${violationMessage(violation)}`); } } diff --git a/apps/server/src/sweep/start.ts b/apps/server/src/sweep/start.ts index 9faa4401..e55aecc4 100644 --- a/apps/server/src/sweep/start.ts +++ b/apps/server/src/sweep/start.ts @@ -26,7 +26,7 @@ export function startGatewaySweep(deps: { app: deps.app, dataVolumes: deps.dataVolumes, forge, - ttlHours: deps.config.ttlHours, + legacyTtlMs: deps.config.legacyTtlMs, governance: deps.config, log: (message, deletion) => { if (deletion) console.log(message, deletion); diff --git a/apps/server/src/telemetry/payload.test.ts b/apps/server/src/telemetry/payload.test.ts index dc993554..99805d5e 100644 --- a/apps/server/src/telemetry/payload.test.ts +++ b/apps/server/src/telemetry/payload.test.ts @@ -24,6 +24,7 @@ function testConfig(overrides: Partial = {}): Config { gitlabToken: "", extraGitlabHosts: new Set(), ttlHours: 72, + legacyTtlMs: 72 * 3600_000, sweepCron: "*/30 * * * *", previewPortDefault: 8080, seedTimeout: 180, @@ -31,6 +32,12 @@ function testConfig(overrides: Partial = {}): Config { dashboard: { enabled: false, user: "", password: "" }, telemetry: { enabled: true, endpoint: "", auth: "" }, otlp: { endpoint: "", headers: {} }, + previewTtlMs: null, + previewIdleMs: null, + maxPreviewsPerRepo: null, + maxPreviews: null, + previewMaxDbConnections: null, + postgresMaxConnections: null, ...overrides, }; } diff --git a/packages/preview-env/src/governance.test.ts b/packages/preview-env/src/governance.test.ts index bcf8bf54..be31c48c 100644 --- a/packages/preview-env/src/governance.test.ts +++ b/packages/preview-env/src/governance.test.ts @@ -1,148 +1,61 @@ import { describe, expect, test } from "bun:test"; import { - computeExpiresAtMs, - connectionProjection, - governanceStatus, - parseGatewayCap, - parseGatewayDurationMs, + governanceIssueMessage, + parseDurationMs, parsePreviewGovernanceField, - resolveGovernanceMs, - resolvePreviewExpiry, } from "./governance.ts"; describe("preview governance durations", () => { test("parses manifest ttl/idle with off", () => { - expect(parsePreviewGovernanceField("7d", "ttl")).toEqual({ + expect(parsePreviewGovernanceField("7d")).toEqual({ ok: true, - value: "7d", + value: { raw: "7d", ms: 7 * 86400_000 }, }); - expect(parsePreviewGovernanceField("off", "ttl")).toEqual({ + expect(parsePreviewGovernanceField("off")).toEqual({ ok: true, - value: "off", + value: { raw: "off", ms: null }, }); - expect(parsePreviewGovernanceField("OFF", "idle_teardown")).toEqual({ + expect(parsePreviewGovernanceField("OFF")).toEqual({ ok: true, - value: "off", + value: { raw: "off", ms: null }, }); - expect(parsePreviewGovernanceField(undefined, "ttl")).toEqual({ + expect(parsePreviewGovernanceField(undefined)).toEqual({ ok: true, value: undefined, }); }); - test("rejects malformed durations with named errors", () => { - const bad = parsePreviewGovernanceField("forever", "ttl"); - expect(bad.ok).toBe(false); - if (!bad.ok) expect(bad.issue.code).toBe("invalid_ttl"); - const idleBad = parsePreviewGovernanceField("7x", "idle_teardown"); - expect(idleBad.ok).toBe(false); - }); - - test("gateway duration accepts off and rejects garbage", () => { - expect(parseGatewayDurationMs("SPROUT_PREVIEW_TTL", undefined)).toBeNull(); - expect(parseGatewayDurationMs("SPROUT_PREVIEW_TTL", "off")).toBeNull(); - expect(parseGatewayDurationMs("SPROUT_PREVIEW_TTL", "7d")).toBe( - 7 * 24 * 60 * 60 * 1000, - ); - expect(parseGatewayDurationMs("SPROUT_PREVIEW_TTL", "2h")).toBe( - 2 * 60 * 60 * 1000, - ); - expect(() => - parseGatewayDurationMs("SPROUT_PREVIEW_TTL", "forever"), - ).toThrow("Invalid SPROUT_PREVIEW_TTL"); - }); - - test("gateway caps accept off and reject garbage", () => { - expect(parseGatewayCap("SPROUT_MAX_PREVIEWS", undefined)).toBeNull(); - expect(parseGatewayCap("SPROUT_MAX_PREVIEWS", "off")).toBeNull(); - expect(parseGatewayCap("SPROUT_MAX_PREVIEWS", "10")).toBe(10); - expect(() => parseGatewayCap("SPROUT_MAX_PREVIEWS", "0")).toThrow( - "Invalid SPROUT_MAX_PREVIEWS", - ); - }); - - test("manifest overrides gateway and off disables", () => { - expect(resolveGovernanceMs("7d", 2 * 3600_000)).toBe(7 * 86400_000); - expect(resolveGovernanceMs("off", 2 * 3600_000)).toBeNull(); - expect(resolveGovernanceMs(undefined, 2 * 3600_000)).toBe(2 * 3600_000); - expect(resolveGovernanceMs(undefined, null)).toBeNull(); + test("rejects malformed durations, keeping the raw input", () => { + expect(parsePreviewGovernanceField("forever")).toEqual({ + ok: false, + raw: "forever", + }); + expect(parsePreviewGovernanceField("7x")).toEqual({ + ok: false, + raw: "7x", + }); + expect(parsePreviewGovernanceField("")).toEqual({ + ok: false, + raw: "", + }); + expect(parsePreviewGovernanceField(42)).toEqual({ + ok: false, + raw: "42", + }); }); - test("expiry is the earlier of ttl and idle", () => { - const base = 1_000_000; - expect(computeExpiresAtMs(base, 7 * 86400_000, null)).toBe( - base + 7 * 86400_000, - ); - expect(computeExpiresAtMs(base, 7 * 86400_000, 2 * 3600_000)).toBe( - base + 2 * 3600_000, + test("issue message names the path and the raw input", () => { + expect(governanceIssueMessage("preview.ttl", "forever")).toBe( + 'preview.ttl is invalid (expected e.g. 7d, 2h, 30m or off, got "forever")', ); - expect(computeExpiresAtMs(base, null, null)).toBeNull(); - }); - - test("connection projection counts exactly the previews given", () => { - expect( - connectionProjection({ previews: 8, perPreview: 12, ceiling: 100 }), - ).toEqual({ projected: 96, over: false }); - expect( - connectionProjection({ previews: 9, perPreview: 12, ceiling: 100 }), - ).toEqual({ projected: 108, over: true }); - expect( - connectionProjection({ previews: 9, perPreview: null, ceiling: 100 }), - ).toEqual({ projected: null, over: false }); - }); - - test("governance status counts once for caps", () => { - const previews = [ - { canonicalRepoId: "a" }, - { canonicalRepoId: "a" }, - { canonicalRepoId: "b" }, - ]; - const status = governanceStatus(previews); - expect(status.total).toBe(3); - expect(status.byRepo.get("a")).toBe(2); }); - test("expiry resolves the earlier bound and names the winner", () => { - const base = 1_000_000; - expect( - resolvePreviewExpiry({ - lastActivityMs: base, - createdAtMs: base, - ttlMs: 7 * 86400_000, - idleMs: null, - }), - ).toEqual({ expiresAtMs: base + 7 * 86400_000, bound: "ttl" }); - expect( - resolvePreviewExpiry({ - lastActivityMs: base, - createdAtMs: base, - ttlMs: 7 * 86400_000, - idleMs: 2 * 3600_000, - }), - ).toEqual({ expiresAtMs: base + 2 * 3600_000, bound: "idle" }); - expect( - resolvePreviewExpiry({ - lastActivityMs: null, - createdAtMs: base, - ttlMs: 7 * 86400_000, - idleMs: null, - }), - ).toEqual({ expiresAtMs: base + 7 * 86400_000, bound: "ttl" }); - expect( - resolvePreviewExpiry({ - lastActivityMs: null, - createdAtMs: null, - ttlMs: 7 * 86400_000, - idleMs: null, - }), - ).toEqual({ expiresAtMs: null, bound: null }); - expect( - resolvePreviewExpiry({ - lastActivityMs: base, - createdAtMs: base, - ttlMs: null, - idleMs: null, - }), - ).toEqual({ expiresAtMs: null, bound: null }); + test("duration grammar rejects non-positive and overflowing amounts", () => { + expect(parseDurationMs("7d")).toBe(7 * 86400_000); + expect(parseDurationMs("2h")).toBe(2 * 3600_000); + expect(parseDurationMs("0d")).toBeNull(); + expect(parseDurationMs("-1d")).toBeNull(); + expect(parseDurationMs("7x")).toBeNull(); + expect(parseDurationMs("999999999999d")).toBeNull(); }); }); diff --git a/packages/preview-env/src/governance.ts b/packages/preview-env/src/governance.ts index 26f8d169..020cf751 100644 --- a/packages/preview-env/src/governance.ts +++ b/packages/preview-env/src/governance.ts @@ -1,6 +1,10 @@ const DURATION_RE = /^(\d+)(s|m|h|d)$/; -function parseDurationMs(raw: string): number | null { +/** + * Shared duration grammar for manifest fields and gateway env: "off"/empty + * handling stays with each boundary, this only parses ``. + */ +export function parseDurationMs(raw: string): number | null { const trimmed = raw.trim(); const match = DURATION_RE.exec(trimmed); if (!match) return null; @@ -43,222 +47,44 @@ export type EffectiveGovernanceMs = { idleMs: number | null; }; -/** Manifest-level governance overrides; undefined means inherit the gateway. */ +/** Manifest-level governance overrides; undefined means inherit the gateway. + * Each present field carries its parsed bound (`ms` null means "off"), + * produced once at the boundary so downstream resolution is pure arithmetic + * with no re-parse and no throw. */ export type GovernanceManifest = { - ttl?: string; - idle_teardown?: string; + ttl?: { raw: string; ms: number | null }; + idle_teardown?: { raw: string; ms: number | null }; }; -type GovernanceFieldIssue = { - code: "invalid_ttl" | "invalid_idle_teardown"; - raw: string; -}; - -export function governanceIssueMessage( - path: string, - issue: GovernanceFieldIssue, -): string { - return `${path} is invalid (expected e.g. 7d, 2h, 30m or off, got ${JSON.stringify(issue.raw)})`; +export function governanceIssueMessage(path: string, raw: string): string { + return `${path} is invalid (expected e.g. 7d, 2h, 30m or off, got ${JSON.stringify(raw)})`; } /** * Manifest-level parse: undefined means inherit the gateway default, "off" - * disables, otherwise a duration string. The honest cheap activity signal is - * the last successful deploy (including reseed/reset) — see docs/previews.md. + * disables, otherwise a duration string. Callers already know the field, so + * the failure only carries the offending input for the message. The honest + * cheap activity signal is the last successful deploy (including + * reseed/reset) — see docs/previews.md. */ export function parsePreviewGovernanceField( raw: unknown, - kind: "ttl" | "idle_teardown", ): - | { ok: true; value: string | undefined } - | { ok: false; issue: GovernanceFieldIssue } { + | { ok: true; value: { raw: string; ms: number | null } | undefined } + | { ok: false; raw: string } { if (raw === undefined) return { ok: true, value: undefined }; if (typeof raw !== "string") { - return { - ok: false, - issue: { - code: kind === "ttl" ? "invalid_ttl" : "invalid_idle_teardown", - raw: String(raw), - }, - }; + return { ok: false, raw: String(raw) }; } const trimmed = raw.trim(); if (trimmed === "") { - return { - ok: false, - issue: { - code: kind === "ttl" ? "invalid_ttl" : "invalid_idle_teardown", - raw, - }, - }; - } - if (trimmed.toLowerCase() === "off") return { ok: true, value: "off" }; - if (parseDurationMs(trimmed) === null) { - return { - ok: false, - issue: { - code: kind === "ttl" ? "invalid_ttl" : "invalid_idle_teardown", - raw, - }, - }; + return { ok: false, raw }; } - return { ok: true, value: trimmed }; -} - -/** Gateway-level parse: empty/off means unbounded, otherwise a duration. */ -export function parseGatewayDurationMs( - envName: string, - raw: string | undefined, -): number | null { - const trimmed = raw?.trim() ?? ""; - if (trimmed === "" || trimmed.toLowerCase() === "off") return null; + if (trimmed.toLowerCase() === "off") + return { ok: true, value: { raw: "off", ms: null } }; const ms = parseDurationMs(trimmed); if (ms === null) { - throw new Error( - `Invalid ${envName}: expected a duration (e.g. 7d, 2h, 30m) or off, got ${JSON.stringify(raw ?? "")}`, - ); - } - return ms; -} - -export function parseGatewayCap( - envName: string, - raw: string | undefined, -): number | null { - const trimmed = raw?.trim() ?? ""; - if (trimmed === "" || trimmed.toLowerCase() === "off") return null; - const value = Number(trimmed); - if (!Number.isInteger(value) || value <= 0) { - throw new Error(`Invalid ${envName}: must be a positive integer or off`); - } - return value; -} - -/** Manifest override wins; "off" at the effective level disables. */ -export function resolveGovernanceMs( - manifestRaw: string | undefined, - gatewayMs: number | null, -): number | null { - if (manifestRaw !== undefined) { - if (manifestRaw.trim().toLowerCase() === "off") return null; - const ms = parseDurationMs(manifestRaw); - if (ms === null) throw new Error(`invalid governance duration ${manifestRaw}`); - return ms; - } - return gatewayMs; -} - -/** Effective per-preview deadlines from manifest overrides over the gateway. */ -export function resolveEffectiveGovernanceMs( - manifest: GovernanceManifest | undefined, - gateway: Pick, -): EffectiveGovernanceMs { - return { - ttlMs: resolveGovernanceMs(manifest?.ttl, gateway.previewTtlMs), - idleMs: resolveGovernanceMs( - manifest?.idle_teardown, - gateway.previewIdleMs, - ), - }; -} - -export function computeExpiresAtMs( - lastActivityMs: number, - ttlMs: number | null, - idleMs: number | null, -): number | null { - const deadlines: number[] = []; - if (ttlMs !== null) deadlines.push(lastActivityMs + ttlMs); - if (idleMs !== null) deadlines.push(lastActivityMs + idleMs); - if (deadlines.length === 0) return null; - return Math.min(...deadlines); -} - -/** - * One place decides the expiry base, the earlier bound, and which bound won. - * The base falls back to creation when no successful deploy has refreshed - * activity yet, so the read surface and the sweep plan from the same source. - */ -export function resolvePreviewExpiry(input: { - lastActivityMs: number | null; - createdAtMs: number | null; - ttlMs: number | null; - idleMs: number | null; -}): { expiresAtMs: number | null; bound: "ttl" | "idle" | null } { - const base = input.lastActivityMs ?? input.createdAtMs; - if (base === null) return { expiresAtMs: null, bound: null }; - const ttlDeadline = input.ttlMs !== null ? base + input.ttlMs : null; - const idleDeadline = input.idleMs !== null ? base + input.idleMs : null; - const expiresAtMs = computeExpiresAtMs( - base, - input.ttlMs, - input.idleMs, - ); - if (expiresAtMs === null) return { expiresAtMs: null, bound: null }; - const bound = - ttlDeadline !== null && - (idleDeadline === null || ttlDeadline <= idleDeadline) - ? ("ttl" as const) - : ("idle" as const); - return { expiresAtMs, bound }; -} - -/** - * Pure arithmetic: projected connections for exactly `previews` live - * previews. Candidates add themselves (+1) at the admission call site; the - * sweep log passes the current total as-is. - */ -export function connectionProjection(input: { - previews: number; - perPreview: number | null; - ceiling: number | null; -}): { projected: number | null; over: boolean } { - if (input.perPreview === null || input.ceiling === null) { - return { projected: null, over: false }; - } - const projected = input.previews * input.perPreview; - return { projected, over: projected > input.ceiling }; -} - -export type GovernanceStatus = { - total: number; - byRepo: Map; -}; - -/** - * One pass over live previews for the cap checks. Connection budget is not - * part of the status: both callers project it from `connectionProjection` - * with an explicit count, so there is no hidden +1 convention. - */ -export function governanceStatus( - previews: readonly { canonicalRepoId: string }[], -): GovernanceStatus { - const byRepo = new Map(); - for (const preview of previews) { - byRepo.set( - preview.canonicalRepoId, - (byRepo.get(preview.canonicalRepoId) ?? 0) + 1, - ); + return { ok: false, raw }; } - return { total: previews.length, byRepo }; -} - -export function previewLimitDetail(input: { - cap: string; - value: number; - count: number; -}): string { - return `${input.cap} limit reached (limit ${input.value}, current ${input.count})`; -} - -export function connectionBudgetDetail(input: { - projected: number; - ceiling: number; - perPreview: number; - active: number; -}): string { - return ( - `preview connection budget exceeded (projected ${input.projected} > ceiling ${input.ceiling}; ` + - `${input.active} active previews + 1 x ${input.perPreview} per preview)` - ); + return { ok: true, value: { raw: trimmed, ms } }; } diff --git a/packages/preview-env/src/index.ts b/packages/preview-env/src/index.ts index bd56c208..3c47b9a3 100644 --- a/packages/preview-env/src/index.ts +++ b/packages/preview-env/src/index.ts @@ -112,21 +112,12 @@ export { } from "./volumes.ts"; export { - computeExpiresAtMs, - connectionBudgetDetail, - connectionProjection, governanceIssueMessage, - governanceStatus, - parseGatewayCap, - parseGatewayDurationMs, + parseDurationMs, parsePreviewGovernanceField, - previewLimitDetail, - resolveEffectiveGovernanceMs, - resolvePreviewExpiry, type EffectiveGovernanceMs, type GovernanceConfig, type GovernanceManifest, - type GovernanceStatus, } from "./governance.ts"; export { From 98f472546c8d1ac3bfce453848f2b5ea8529a54e Mon Sep 17 00:00:00 2001 From: simpros <38320875+simpros@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:29:00 +0200 Subject: [PATCH 06/10] address review feedback on #265 Complete the round-9 boundary move: violationMessage as the single violation presenter (pure-data violations, unified budget wording), parseDurationMs shared from preview-env, legacyTtlMs computed once in loadConfig and carried on LifecycleDeps instead of ProvisionInput. --- apps/server/src/http/deploy.ts | 2 - apps/server/src/http/routes.ts | 1 + apps/server/src/preview/bring-up.ts | 2 +- apps/server/src/preview/governance.ts | 74 +++++++++++++------------- apps/server/src/preview/types.ts | 6 +-- packages/preview-env/src/governance.ts | 12 ++--- 6 files changed, 47 insertions(+), 50 deletions(-) diff --git a/apps/server/src/http/deploy.ts b/apps/server/src/http/deploy.ts index 5351fcc7..d28fd4f2 100644 --- a/apps/server/src/http/deploy.ts +++ b/apps/server/src/http/deploy.ts @@ -542,7 +542,6 @@ export function deploy( materialization: PreviewMaterializationCtx; telemetry: TelemetryDeployHook; governance: GovernanceConfig; - legacyTtlMs: number | null; }, ) { return async ({ @@ -678,7 +677,6 @@ export function deploy( plan, reseed: body.reseed === true, governanceMs, - legacyTtlMs: deps.legacyTtlMs, ...(deps.materialization.traefikTls !== undefined ? { traefikTls: deps.materialization.traefikTls } : {}), diff --git a/apps/server/src/http/routes.ts b/apps/server/src/http/routes.ts index 066df280..ca0792df 100644 --- a/apps/server/src/http/routes.ts +++ b/apps/server/src/http/routes.ts @@ -63,6 +63,7 @@ export function createRoutes(deps: RouteDeps) { previewDb: deps.previewDb, app: deps.app, dataVolumes: deps.dataVolumes, + legacyTtlMs: deps.legacyTtlMs, }; const deployDeps = { ...lifecycle, diff --git a/apps/server/src/preview/bring-up.ts b/apps/server/src/preview/bring-up.ts index 53cc580d..e65017e7 100644 --- a/apps/server/src/preview/bring-up.ts +++ b/apps/server/src/preview/bring-up.ts @@ -197,7 +197,7 @@ async function closeRunning( ); return { ok: true, - value: previewSnapshotFromRow(updated, input.legacyTtlMs), + value: previewSnapshotFromRow(updated, deps.legacyTtlMs), }; } diff --git a/apps/server/src/preview/governance.ts b/apps/server/src/preview/governance.ts index a526f964..5fbf08c1 100644 --- a/apps/server/src/preview/governance.ts +++ b/apps/server/src/preview/governance.ts @@ -5,7 +5,6 @@ import { type GovernanceManifest, } from "@sprout/preview-env"; -/** One hours-to-ms conversion for the legacy creation-age bound. */ export function hoursToMs(hours: number): number { return hours * 60 * 60 * 1000; } @@ -39,15 +38,16 @@ export function parseGatewayCap( return value; } -/** Manifest override wins; "off" at the effective level disables. Pure - * lookup: the manifest already carries the parsed bound, so there is - * nothing to re-parse and nothing to throw. */ +/** Manifest override wins; "off" at the effective level disables. Pure lookup: + * the manifest already carries the parsed bound, so there is nothing to + * re-parse and nothing to throw. */ export function resolveEffectiveGovernanceMs( manifest: GovernanceManifest | undefined, gateway: Pick, ): EffectiveGovernanceMs { return { - ttlMs: manifest?.ttl !== undefined ? manifest.ttl.ms : gateway.previewTtlMs, + ttlMs: + manifest?.ttl !== undefined ? manifest.ttl.ms : gateway.previewTtlMs, idleMs: manifest?.idle_teardown !== undefined ? manifest.idle_teardown.ms @@ -99,8 +99,8 @@ export type GovernanceStatus = { /** * One pass over live previews for the cap checks. Connection budget is not - * part of the status: both callers project it from `connectionProjection` - * with an explicit count, so there is no hidden +1 convention. + * part of the status: the evaluator projects it with an explicit count, so + * there is no hidden +1 convention. */ export function governanceStatus( previews: readonly { canonicalRepoId: string }[], @@ -117,8 +117,8 @@ export function governanceStatus( /** * Projected connections for exactly `previews` live previews, or null when - * no budget is configured. Callers pass the candidate explicitly: admission - * includes the newcomer (+1), the sweep passes the current total as-is. + * no budget is configured. The evaluator passes the candidate explicitly: + * admission includes the newcomer, the sweep passes the current total as-is. */ export function connectionProjection(input: { previews: number; @@ -138,16 +138,43 @@ export type GovernanceViolation = projected: number; ceiling: number; perPreview: number; - /** Live previews the projection counts, candidate included. */ previews: number; }; +/** + * Single owner of violation presentation, shared by admission (which maps + * violations to 429s) and the sweep (which prefixes them as over-limit + * logs). One switch, no second copy of the wording in either caller. + */ +export function violationMessage(violation: GovernanceViolation): string { + switch (violation.kind) { + case "per-repo-cap": + return ( + `SPROUT_MAX_PREVIEWS_PER_REPO limit reached (limit ${violation.cap}, ` + + `current ${violation.count})` + ); + case "total-cap": + return ( + `SPROUT_MAX_PREVIEWS limit reached (limit ${violation.cap}, ` + + `current ${violation.count})` + ); + case "connection-budget": + return ( + `preview connection budget exceeded (projected ${violation.projected} > ` + + `ceiling ${violation.ceiling}; ${violation.previews} previews x ` + + `${violation.perPreview} per preview)` + ); + } +} + /** * Single evaluator for the cap/budget policy, shared by admission (which maps * violations to 429s) and the sweep (which logs them). `pending` is the * not-yet-inserted candidate: admission passes it so the newcomer counts * against the caps (`count + 1 > cap`, i.e. reject at the cap), while the * sweep passes null to report only rows already over (`count > cap`). + * Presentation lives in `violationMessage`: one switch, no second copy of + * the wording in either caller. */ export function evaluateGovernance( gov: GovernanceConfig, @@ -204,30 +231,3 @@ export function evaluateGovernance( return violations; } - -/** - * Sole violation presenter: admission uses it as the 429 detail, the sweep - * as the log body. The budget counts the previews the projection counts - * (candidate included on admission), so projected always equals - * previews x perPreview with no hidden convention. - */ -export function violationMessage(violation: GovernanceViolation): string { - switch (violation.kind) { - case "per-repo-cap": - return ( - `SPROUT_MAX_PREVIEWS_PER_REPO limit reached ` + - `(limit ${violation.cap}, current ${violation.count})` - ); - case "total-cap": - return ( - `SPROUT_MAX_PREVIEWS limit reached ` + - `(limit ${violation.cap}, current ${violation.count})` - ); - case "connection-budget": - return ( - `preview connection budget exceeded ` + - `(projected ${violation.projected} > ceiling ${violation.ceiling}; ` + - `${violation.previews} previews x ${violation.perPreview} per preview)` - ); - } -} diff --git a/apps/server/src/preview/types.ts b/apps/server/src/preview/types.ts index b51288a3..e4cf64bb 100644 --- a/apps/server/src/preview/types.ts +++ b/apps/server/src/preview/types.ts @@ -59,6 +59,9 @@ export type LifecycleDeps = { * the preview up. */ phaseTimer?: PhaseTimer; + /** Legacy creation-age bound (SPROUT_TTL_HOURS as ms) for the read surface, + * so responses show the same deadline the sweep enforces. */ + legacyTtlMs: number | null; }; /** Bring-up phases with a telemetry interest, nothing more. */ @@ -120,9 +123,6 @@ export type ProvisionInput = { auth?: PreviewAuthSpec; previewAuth?: PreviewAuthConfig; governanceMs: EffectiveGovernanceMs; - /** Legacy creation-age bound (SPROUT_TTL_HOURS as ms) for the read surface, - * so the deploy response shows the same deadline the sweep enforces. */ - legacyTtlMs: number | null; }; export type TeardownInput = { diff --git a/packages/preview-env/src/governance.ts b/packages/preview-env/src/governance.ts index 020cf751..e617930d 100644 --- a/packages/preview-env/src/governance.ts +++ b/packages/preview-env/src/governance.ts @@ -62,14 +62,12 @@ export function governanceIssueMessage(path: string, raw: string): string { /** * Manifest-level parse: undefined means inherit the gateway default, "off" - * disables, otherwise a duration string. Callers already know the field, so - * the failure only carries the offending input for the message. The honest - * cheap activity signal is the last successful deploy (including - * reseed/reset) — see docs/previews.md. + * disables, otherwise a duration string. The failure carries only the + * offending input; callers already know which field they asked about, so + * they choose the path and error code. The honest cheap activity signal is + * the last successful deploy (including reseed/reset) — see docs/previews.md. */ -export function parsePreviewGovernanceField( - raw: unknown, -): +export function parsePreviewGovernanceField(raw: unknown): | { ok: true; value: { raw: string; ms: number | null } | undefined } | { ok: false; raw: string } { if (raw === undefined) return { ok: true, value: undefined }; From 2bd150e9e873ac2d9236124e3b0aa63a26c95323 Mon Sep 17 00:00:00 2001 From: simpros <38320875+simpros@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:42:32 +0200 Subject: [PATCH 07/10] address review feedback on #265 Round-10 findings: - legacy creation-age bound now applies only to rows that never completed a governed deploy (lastActivityMs null), so explicit off (activity recorded with null bounds) is truly unbounded on both the read surface and the sweep; docs updated to match - getPreview reads legacyTtlMs from LifecycleDeps instead of a second parameter; routes no longer re-spreads/re-aliases it - shared governanceConfig/offGovernanceConfig fixture replaces the copied six-field GovernanceConfig literals in governance tests - 429 wire code carried on GovernanceViolation; admission emits violation.code with no re-switch on kind --- apps/server/src/http/auth.test.ts | 10 +--- apps/server/src/http/dashboard.test.ts | 10 +--- apps/server/src/http/deploy.ts | 7 ++- apps/server/src/http/health-deploy.test.ts | 10 ++-- apps/server/src/http/introspection.test.ts | 10 ++-- apps/server/src/http/none-deploy.test.ts | 10 ++-- .../src/http/preview-governance.test.ts | 40 +++------------ .../server/src/http/preview-lifecycle.test.ts | 12 ++--- apps/server/src/http/routes.ts | 14 +++--- apps/server/src/http/test-helpers.ts | 10 +--- apps/server/src/preview/admission.test.ts | 37 +++----------- apps/server/src/preview/admission.ts | 10 +--- .../server/src/preview/governance-fixtures.ts | 20 ++++++++ apps/server/src/preview/governance.test.ts | 43 +++++++++++----- apps/server/src/preview/governance.ts | 50 +++++++++++++++---- apps/server/src/sweep/governance.test.ts | 21 +++----- apps/server/src/sweep/live-ports.test.ts | 11 +--- apps/server/src/sweep/reconcile.test.ts | 10 +--- apps/server/src/sweep/reconcile.ts | 2 +- docs/operator-deploy.md | 4 +- docs/previews.md | 4 +- 21 files changed, 164 insertions(+), 181 deletions(-) create mode 100644 apps/server/src/preview/governance-fixtures.ts diff --git a/apps/server/src/http/auth.test.ts b/apps/server/src/http/auth.test.ts index 5cb89fa8..3992f4cd 100644 --- a/apps/server/src/http/auth.test.ts +++ b/apps/server/src/http/auth.test.ts @@ -6,6 +6,7 @@ import { createFakeDockerClient } from "../docker/fake.ts"; import { repos } from "../infrastructure/db/schema.ts"; import { createFakePreviewDb } from "../preview-db/fake.ts"; import { bindPreviewDataVolumes } from "../preview/data-volumes.ts"; +import { governanceConfig } from "../preview/governance-fixtures.ts"; import { NO_TELEMETRY } from "../telemetry/contract.ts"; import { createRoutes } from "./routes.ts"; import { @@ -349,14 +350,7 @@ describe("ensureAdminToken", () => { dataVolumes: bindPreviewDataVolumes(docker), materialization: { traefikNetwork: "sprout-traefik" }, telemetry: NO_TELEMETRY, - governance: { - previewTtlMs: null, - previewIdleMs: null, - maxPreviews: null, - maxPreviewsPerRepo: null, - previewMaxDbConnections: null, - postgresMaxConnections: null, - }, + governance: governanceConfig(), legacyTtlMs: 72 * 3600_000, }); const res = await app.handle( diff --git a/apps/server/src/http/dashboard.test.ts b/apps/server/src/http/dashboard.test.ts index e5859acd..9340c9d3 100644 --- a/apps/server/src/http/dashboard.test.ts +++ b/apps/server/src/http/dashboard.test.ts @@ -6,6 +6,7 @@ import { import { previews } from "../infrastructure/db/schema.ts"; import { bindPreviewDataVolumes } from "../preview/data-volumes.ts"; import type { StateDb } from "../infrastructure/db/client.ts"; +import { governanceConfig } from "../preview/governance-fixtures.ts"; import { NO_TELEMETRY } from "../telemetry/contract.ts"; import { createRoutes } from "./routes.ts"; import { @@ -391,14 +392,7 @@ describe("GET /dashboard (flag on)", () => { materialization: { traefikNetwork: "sprout-traefik" }, dashboard: { ...ENABLED }, telemetry: NO_TELEMETRY, - governance: { - previewTtlMs: null, - previewIdleMs: null, - maxPreviews: null, - maxPreviewsPerRepo: null, - previewMaxDbConnections: null, - postgresMaxConnections: null, - }, + governance: governanceConfig(), legacyTtlMs: 72 * 3600_000, }); const res = await probe.handle( diff --git a/apps/server/src/http/deploy.ts b/apps/server/src/http/deploy.ts index d28fd4f2..d599b18b 100644 --- a/apps/server/src/http/deploy.ts +++ b/apps/server/src/http/deploy.ts @@ -709,7 +709,6 @@ export function deploy( export function getPreview( deps: LifecycleDeps, mailboxUrl: string | undefined, - legacyTtlMs: number | null, ) { return async ({ query, @@ -727,7 +726,11 @@ export function getPreview( query.pr_id, ); if (!result.ok) return mapResult(result, set); - return presentPreviewSnapshot(result.value, mailboxUrl, legacyTtlMs); + return presentPreviewSnapshot( + result.value, + mailboxUrl, + deps.legacyTtlMs, + ); }; } diff --git a/apps/server/src/http/health-deploy.test.ts b/apps/server/src/http/health-deploy.test.ts index c14cf0d0..c1916a96 100644 --- a/apps/server/src/http/health-deploy.test.ts +++ b/apps/server/src/http/health-deploy.test.ts @@ -149,10 +149,12 @@ describe("POST /v1/deploy health polling", () => { status: "running", preview_url: "https://pr-42.myapp.preview.example.com", last_activity_at: expect.any(String), - expires_at: expect.any(String), + expires_at: null, expiry_reason: null, }); - // Unified derivation: boundless rows show created_at + legacy ttlHours. + // A governed deploy with both bounds off records activity with null + // bounds, so a running preview is unbounded (the legacy creation-age + // bound only collects rows that never completed a deploy). const snap = res.body as { expires_at: string | null; }; @@ -164,9 +166,7 @@ describe("POST /v1/deploy health polling", () => { and(eq(previews.canonicalRepoId, REPO), eq(previews.prId, 42)), ) .limit(1); - expect(snap.expires_at).toBe( - new Date(Date.parse(row!.createdAt) + 72 * 3600_000).toISOString(), - ); + expect(snap.expires_at).toBeNull(); expect(row?.status).toBe("running"); expect(healthHits[0]).toBe(`http://${PLAN_FIRST_IP}:3000/health`); }); diff --git a/apps/server/src/http/introspection.test.ts b/apps/server/src/http/introspection.test.ts index bc81071a..2967b9d4 100644 --- a/apps/server/src/http/introspection.test.ts +++ b/apps/server/src/http/introspection.test.ts @@ -104,18 +104,18 @@ describe("GET /v1/previews", () => { status: "running", created_at: expect.any(String), last_activity_at: expect.any(String), - expires_at: expect.any(String), + expires_at: null, expiry_reason: null, }, ]); - // Unified derivation: boundless rows show created_at + legacy ttlHours. + // A governed deploy with both bounds off records activity with null + // bounds, so a running preview is unbounded (the legacy creation-age + // bound only collects rows that never completed a deploy). const listed = body.previews[0] as { created_at: string; expires_at: string | null; }; - expect(listed.expires_at).toBe( - new Date(Date.parse(listed.created_at) + 72 * 3600_000).toISOString(), - ); + expect(listed.expires_at).toBeNull(); }); }); diff --git a/apps/server/src/http/none-deploy.test.ts b/apps/server/src/http/none-deploy.test.ts index 97552c4b..0ee788fb 100644 --- a/apps/server/src/http/none-deploy.test.ts +++ b/apps/server/src/http/none-deploy.test.ts @@ -152,18 +152,18 @@ describe("none previews", () => { status: "running", created_at: expect.any(String), last_activity_at: expect.any(String), - expires_at: expect.any(String), + expires_at: null, expiry_reason: null, }, ]); - // Unified derivation: boundless rows show created_at + legacy ttlHours. + // A governed deploy with both bounds off records activity with null + // bounds, so a running preview is unbounded (the legacy creation-age + // bound only collects rows that never completed a deploy). const listed = body.previews[0] as { created_at: string; expires_at: string | null; }; - expect(listed.expires_at).toBe( - new Date(Date.parse(listed.created_at) + 72 * 3600_000).toISOString(), - ); + expect(listed.expires_at).toBeNull(); }); test("companion services start with no database env", async () => { diff --git a/apps/server/src/http/preview-governance.test.ts b/apps/server/src/http/preview-governance.test.ts index f81834da..3151680e 100644 --- a/apps/server/src/http/preview-governance.test.ts +++ b/apps/server/src/http/preview-governance.test.ts @@ -21,6 +21,10 @@ import { TEST_REPO as REPO, type TestApp, } from "./test-helpers.ts"; +import { + governanceConfig, +} from "../preview/governance-fixtures.ts"; +import type { GovernanceConfig } from "@sprout/preview-env"; let testApp: TestApp | undefined; let fakePreviewDb: FakePreviewDb | undefined; @@ -33,14 +37,7 @@ afterEach(async () => { fakeDocker = undefined; }); -async function setupGovernance(governance: { - previewTtlMs: number | null; - previewIdleMs: number | null; - maxPreviews: number | null; - maxPreviewsPerRepo: number | null; - previewMaxDbConnections: number | null; - postgresMaxConnections: number | null; -}) { +async function setupGovernance(governance: GovernanceConfig) { fakePreviewDb = createFakePreviewDb(); fakeDocker = createFakeDockerClient({ exposedPorts: { [APP_IMAGE]: 3000 }, @@ -77,14 +74,7 @@ async function row() { describe("preview governance lifecycle", () => { test("derives expires_at from the gateway ttl on the read surface", async () => { - const { deployToken } = await setupGovernance({ - previewTtlMs: 7 * 86400_000, - previewIdleMs: null, - maxPreviews: null, - maxPreviewsPerRepo: null, - previewMaxDbConnections: null, - postgresMaxConnections: null, - }); + const { deployToken } = await setupGovernance(governanceConfig({ previewTtlMs: 7 * 86400_000 })); const res = await postDeployAndSettle(testApp!, deployToken, deployBody()); expect(res.settleStatus).toBe(200); const snap = res.body as { @@ -98,14 +88,7 @@ describe("preview governance lifecycle", () => { }); test("a sweep plan built before a refresh is stale", async () => { - const { deployToken } = await setupGovernance({ - previewTtlMs: 7 * 86400_000, - previewIdleMs: null, - maxPreviews: null, - maxPreviewsPerRepo: null, - previewMaxDbConnections: null, - postgresMaxConnections: null, - }); + const { deployToken } = await setupGovernance(governanceConfig({ previewTtlMs: 7 * 86400_000 })); const first = await postDeployAndSettle(testApp!, deployToken, deployBody()); expect(first.settleStatus).toBe(200); const before = await row(); @@ -135,14 +118,7 @@ describe("preview governance lifecycle", () => { }); test("re-provisioning a removed row clears the tombstone reason", async () => { - const { deployToken } = await setupGovernance({ - previewTtlMs: 7 * 86400_000, - previewIdleMs: null, - maxPreviews: null, - maxPreviewsPerRepo: null, - previewMaxDbConnections: null, - postgresMaxConnections: null, - }); + const { deployToken } = await setupGovernance(governanceConfig({ previewTtlMs: 7 * 86400_000 })); const first = await postDeployAndSettle(testApp!, deployToken, deployBody()); expect(first.settleStatus).toBe(200); const live = await row(); diff --git a/apps/server/src/http/preview-lifecycle.test.ts b/apps/server/src/http/preview-lifecycle.test.ts index 24d157a1..ea1fc8b2 100644 --- a/apps/server/src/http/preview-lifecycle.test.ts +++ b/apps/server/src/http/preview-lifecycle.test.ts @@ -156,7 +156,7 @@ describe("POST /v1/deploy", () => { status: "running", preview_url: "https://pr-42.myapp.preview.example.com", last_activity_at: expect.any(String), - expires_at: expect.any(String), + expires_at: null, expiry_reason: null, }); expect(fakePreviewDb!.created).toEqual(["sprout_myapp_pr42"]); @@ -168,14 +168,14 @@ describe("POST /v1/deploy", () => { and(eq(previews.canonicalRepoId, REPO), eq(previews.prId, 42)), ) .limit(1); - // Read surface and sweep share one derivation: with no governance bounds - // the legacy creation-age bound (test default ttlHours 72) applies. + // Read surface and sweep share one derivation: a governed deploy with + // both bounds off records activity with null bounds, so the preview is + // unbounded (the legacy creation-age bound only collects rows that + // never completed a governed deploy). const snap = res.body as { expires_at: string | null; }; - expect(snap.expires_at).toBe( - new Date(Date.parse(row!.createdAt) + 72 * 3600_000).toISOString(), - ); + expect(snap.expires_at).toBeNull(); expect(row).toMatchObject({ canonicalRepoId: REPO, prId: 42, diff --git a/apps/server/src/http/routes.ts b/apps/server/src/http/routes.ts index ca0792df..70489be1 100644 --- a/apps/server/src/http/routes.ts +++ b/apps/server/src/http/routes.ts @@ -70,9 +70,7 @@ export function createRoutes(deps: RouteDeps) { materialization: deps.materialization, telemetry: deps.telemetry, governance: deps.governance, - legacyTtlMs: deps.legacyTtlMs, }; - const legacyTtlMs = deployDeps.legacyTtlMs; const mailboxUrl = deps.materialization.mail?.uiUrl; const accessDeps = { db: deps.db, @@ -113,9 +111,13 @@ export function createRoutes(deps: RouteDeps) { }) .delete("/tokens/:id", revokeToken(deps.db)), ) - .get("/previews", listPreviews(deps.db, mailboxUrl, legacyTtlMs), { - beforeHandle: requireAdmin, - }) + .get( + "/previews", + listPreviews(deps.db, mailboxUrl, deps.legacyTtlMs), + { + beforeHandle: requireAdmin, + }, + ) .get("/previews/access", getPreviewAccess(accessDeps), { query: accessQuery, }) @@ -134,7 +136,7 @@ export function createRoutes(deps: RouteDeps) { body: dropBody, }) .post("/deploy", deploy(deployDeps), { body: deployBody }) - .get("/preview", getPreview(lifecycle, mailboxUrl, legacyTtlMs), { + .get("/preview", getPreview(lifecycle, mailboxUrl), { query: previewQuery, }) .post("/teardown", teardown(lifecycle), { body: teardownBody }) diff --git a/apps/server/src/http/test-helpers.ts b/apps/server/src/http/test-helpers.ts index b39e170e..5417caff 100644 --- a/apps/server/src/http/test-helpers.ts +++ b/apps/server/src/http/test-helpers.ts @@ -31,6 +31,7 @@ import type { PreviewAuthConfig, } from "../config.ts"; import type { GovernanceConfig } from "@sprout/preview-env"; +import { governanceConfig } from "../preview/governance-fixtures.ts"; import { NO_TELEMETRY, type TelemetryDeployHook } from "../telemetry/contract.ts"; import type { TracesHandle } from "../telemetry/traces.ts"; @@ -168,14 +169,7 @@ export async function createTestApp( extraGitlabHosts: opts.extraGitlabHosts, telemetry: opts.telemetry ?? NO_TELEMETRY, ...(opts.tracesPlugin ? { tracesPlugin: opts.tracesPlugin } : {}), - governance: opts.governance ?? { - previewTtlMs: null, - previewIdleMs: null, - maxPreviews: null, - maxPreviewsPerRepo: null, - previewMaxDbConnections: null, - postgresMaxConnections: null, - }, + governance: opts.governance ?? governanceConfig(), legacyTtlMs: opts.legacyTtlMs ?? 72 * 3600_000, }), db, diff --git a/apps/server/src/preview/admission.test.ts b/apps/server/src/preview/admission.test.ts index 6aea1c58..8228f729 100644 --- a/apps/server/src/preview/admission.test.ts +++ b/apps/server/src/preview/admission.test.ts @@ -2,18 +2,14 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createTestDb, type TestDb } from "../http/test-helpers.ts"; import { previews } from "../infrastructure/db/schema.ts"; import { checkDeployAdmission } from "./admission.ts"; -import type { GovernanceConfig } from "@sprout/preview-env"; +import { governanceConfig } from "./governance-fixtures.ts"; const REPO = "https://github.com/acme/widgets"; -const overBudget: GovernanceConfig = { - previewTtlMs: null, - previewIdleMs: null, - maxPreviews: null, - maxPreviewsPerRepo: null, +const overBudget = governanceConfig({ previewMaxDbConnections: 12, postgresMaxConnections: 20, -}; +}); describe("checkDeployAdmission connection budget", () => { let testDb: TestDb | undefined; @@ -93,14 +89,7 @@ describe("checkDeployAdmission caps", () => { const result = await checkDeployAdmission( testDb!.db, { repo: REPO, prId: 2 }, - { - previewTtlMs: null, - previewIdleMs: null, - maxPreviews: null, - maxPreviewsPerRepo: 1, - previewMaxDbConnections: null, - postgresMaxConnections: null, - }, + governanceConfig({ maxPreviewsPerRepo: 1 }), undefined, ); expect(result).toEqual({ @@ -116,14 +105,7 @@ describe("checkDeployAdmission caps", () => { const result = await checkDeployAdmission( testDb!.db, { repo: REPO, prId: 2 }, - { - previewTtlMs: null, - previewIdleMs: null, - maxPreviews: 1, - maxPreviewsPerRepo: null, - previewMaxDbConnections: null, - postgresMaxConnections: null, - }, + governanceConfig({ maxPreviews: 1 }), undefined, ); expect(result).toEqual({ @@ -139,14 +121,7 @@ describe("checkDeployAdmission caps", () => { const result = await checkDeployAdmission( testDb!.db, { repo: REPO, prId: 1 }, - { - previewTtlMs: null, - previewIdleMs: null, - maxPreviews: 1, - maxPreviewsPerRepo: 1, - previewMaxDbConnections: null, - postgresMaxConnections: null, - }, + governanceConfig({ maxPreviews: 1, maxPreviewsPerRepo: 1 }), undefined, ); expect(result).toEqual({ diff --git a/apps/server/src/preview/admission.ts b/apps/server/src/preview/admission.ts index 24375799..0d65599f 100644 --- a/apps/server/src/preview/admission.ts +++ b/apps/server/src/preview/admission.ts @@ -49,18 +49,10 @@ export async function checkDeployAdmission( if (isNew) { const violation = evaluateGovernance(gov, status, { repo: input.repo })[0]; if (violation !== undefined) { - if (violation.kind === "connection-budget") { - return { - ok: false, - status: 429, - error: "preview_connection_budget_exceeded", - detail: violationMessage(violation), - }; - } return { ok: false, status: 429, - error: "preview_limit_reached", + error: violation.code, detail: violationMessage(violation), }; } diff --git a/apps/server/src/preview/governance-fixtures.ts b/apps/server/src/preview/governance-fixtures.ts new file mode 100644 index 00000000..38b8666f --- /dev/null +++ b/apps/server/src/preview/governance-fixtures.ts @@ -0,0 +1,20 @@ +import type { GovernanceConfig } from "@sprout/preview-env"; + +/** All-bounds-off governance: previews live until PR close. */ +export function offGovernanceConfig(): GovernanceConfig { + return { + previewTtlMs: null, + previewIdleMs: null, + maxPreviews: null, + maxPreviewsPerRepo: null, + previewMaxDbConnections: null, + postgresMaxConnections: null, + }; +} + +/** Governance with overrides over the all-off default. */ +export function governanceConfig( + overrides: Partial = {}, +): GovernanceConfig { + return { ...offGovernanceConfig(), ...overrides }; +} diff --git a/apps/server/src/preview/governance.test.ts b/apps/server/src/preview/governance.test.ts index 4360ff49..1a1819c6 100644 --- a/apps/server/src/preview/governance.test.ts +++ b/apps/server/src/preview/governance.test.ts @@ -11,18 +11,12 @@ import { resolvePreviewExpiry, violationMessage, } from "./governance.ts"; +import { governanceConfig } from "./governance-fixtures.ts"; const REPO_A = "https://github.com/acme/a"; const REPO_B = "https://github.com/acme/b"; -const off: GovernanceConfig = { - previewTtlMs: null, - previewIdleMs: null, - maxPreviews: null, - maxPreviewsPerRepo: null, - previewMaxDbConnections: null, - postgresMaxConnections: null, -}; +const off: GovernanceConfig = governanceConfig(); describe("server governance policy", () => { test("status counts once for caps", () => { @@ -51,7 +45,12 @@ describe("server governance policy", () => { const gov: GovernanceConfig = { ...off, maxPreviews: 1 }; const status = governanceStatus([{ canonicalRepoId: REPO_A }]); expect(evaluateGovernance(gov, status, { repo: REPO_B })).toEqual([ - { kind: "total-cap", cap: 1, count: 1 }, + { + kind: "total-cap", + code: "preview_limit_reached", + cap: 1, + count: 1, + }, ]); expect(evaluateGovernance(gov, status, null)).toEqual([]); }); @@ -83,6 +82,7 @@ describe("server governance policy", () => { expect(evaluateGovernance(gov, status, { repo: REPO_B })).toEqual([ { kind: "connection-budget", + code: "preview_connection_budget_exceeded", projected: 24, ceiling: 20, perPreview: 12, @@ -95,17 +95,24 @@ describe("server governance policy", () => { expect( violationMessage({ kind: "per-repo-cap", + code: "preview_limit_reached", repo: REPO_A, cap: 1, count: 1, }), ).toBe("SPROUT_MAX_PREVIEWS_PER_REPO limit reached (limit 1, current 1)"); - expect(violationMessage({ kind: "total-cap", cap: 1, count: 1 })).toBe( - "SPROUT_MAX_PREVIEWS limit reached (limit 1, current 1)", - ); + expect( + violationMessage({ + kind: "total-cap", + code: "preview_limit_reached", + cap: 1, + count: 1, + }), + ).toBe("SPROUT_MAX_PREVIEWS limit reached (limit 1, current 1)"); expect( violationMessage({ kind: "connection-budget", + code: "preview_connection_budget_exceeded", projected: 24, ceiling: 20, perPreview: 12, @@ -234,6 +241,18 @@ describe("server governance policy", () => { legacyTtlMs, }), ).toEqual({ expiresAtMs: base + 7 * 86400_000, bound: "ttl" }); + // A governed deploy with both bounds off stays unbounded: activity was + // recorded together with the null bounds, so the legacy default no + // longer applies. + expect( + resolvePreviewExpiry({ + lastActivityMs: base, + createdAtMs: base, + ttlMs: null, + idleMs: null, + legacyTtlMs, + }), + ).toEqual({ expiresAtMs: null, bound: null }); // Unparseable creation still yields no deadline. expect( resolvePreviewExpiry({ diff --git a/apps/server/src/preview/governance.ts b/apps/server/src/preview/governance.ts index 5fbf08c1..278f52c9 100644 --- a/apps/server/src/preview/governance.ts +++ b/apps/server/src/preview/governance.ts @@ -59,9 +59,10 @@ export function resolveEffectiveGovernanceMs( * One place decides the expiry base, the earlier bound, and which bound won. * The base falls back to creation when no successful deploy has refreshed * activity yet, so the read surface and the sweep plan from the same source. - * The legacy creation-age bound applies only when both governance bounds are - * null (rows that never received governance columns): once a preview carries - * an explicit ttl/idle, that policy wins over the legacy default. + * The legacy creation-age bound applies only to rows that never completed + * a governed deploy (lastActivityMs null): closeRunning writes activity + * together with the effective ttl/idle, so null bounds past that point + * mean explicit "off" and yield no deadline. */ export function resolvePreviewExpiry(input: { lastActivityMs: number | null; @@ -83,7 +84,11 @@ export function resolvePreviewExpiry(input: { } return { expiresAtMs: idleDeadline, bound: "idle" }; } - if (input.legacyTtlMs !== null && input.createdAtMs !== null) { + if ( + input.lastActivityMs === null && + input.legacyTtlMs !== null && + input.createdAtMs !== null + ) { return { expiresAtMs: input.createdAtMs + input.legacyTtlMs, bound: "ttl", @@ -131,10 +136,22 @@ export function connectionProjection(input: { } export type GovernanceViolation = - | { kind: "per-repo-cap"; repo: string; cap: number; count: number } - | { kind: "total-cap"; cap: number; count: number } + | { + kind: "per-repo-cap"; + code: "preview_limit_reached"; + repo: string; + cap: number; + count: number; + } + | { + kind: "total-cap"; + code: "preview_limit_reached"; + cap: number; + count: number; + } | { kind: "connection-budget"; + code: "preview_connection_budget_exceeded"; projected: number; ceiling: number; perPreview: number; @@ -173,8 +190,8 @@ export function violationMessage(violation: GovernanceViolation): string { * not-yet-inserted candidate: admission passes it so the newcomer counts * against the caps (`count + 1 > cap`, i.e. reject at the cap), while the * sweep passes null to report only rows already over (`count > cap`). - * Presentation lives in `violationMessage`: one switch, no second copy of - * the wording in either caller. + * Presentation lives in `violationMessage` and the 429 wire code on the + * violation itself, so callers emit without re-switching on `kind`. */ export function evaluateGovernance( gov: GovernanceConfig, @@ -191,6 +208,7 @@ export function evaluateGovernance( if (count + incoming > perRepo) { violations.push({ kind: "per-repo-cap", + code: "preview_limit_reached", repo: pending.repo, cap: perRepo, count, @@ -199,7 +217,13 @@ export function evaluateGovernance( } else { for (const [repo, count] of status.byRepo) { if (count > perRepo) { - violations.push({ kind: "per-repo-cap", repo, cap: perRepo, count }); + violations.push({ + kind: "per-repo-cap", + code: "preview_limit_reached", + repo, + cap: perRepo, + count, + }); } } } @@ -207,7 +231,12 @@ export function evaluateGovernance( const total = gov.maxPreviews; if (total !== null && status.total + incoming > total) { - violations.push({ kind: "total-cap", cap: total, count: status.total }); + violations.push({ + kind: "total-cap", + code: "preview_limit_reached", + cap: total, + count: status.total, + }); } const perPreview = gov.previewMaxDbConnections; @@ -221,6 +250,7 @@ export function evaluateGovernance( if (budget !== null && budget.over) { violations.push({ kind: "connection-budget", + code: "preview_connection_budget_exceeded", projected: budget.projected, ceiling, perPreview, diff --git a/apps/server/src/sweep/governance.test.ts b/apps/server/src/sweep/governance.test.ts index acab90d6..7a0a6beb 100644 --- a/apps/server/src/sweep/governance.test.ts +++ b/apps/server/src/sweep/governance.test.ts @@ -1,4 +1,5 @@ import { describe, expect, setSystemTime, test, afterEach } from "bun:test"; +import { governanceConfig } from "../preview/governance-fixtures.ts"; import { planGovernanceExpiry, runSweepPass, @@ -58,6 +59,9 @@ describe("governance expiry", () => { ttlMs: null, idleMs: null, }); + // Governed-off rows carry activity with null bounds, so even a live + // legacy default must not collect them. + expect(planGovernanceExpiry(p, base + 30 * 86400_000, 72 * 3600_000)).toBeNull(); expect(planGovernanceExpiry(p, base + 30 * 86400_000, null)).toBeNull(); }); @@ -112,14 +116,7 @@ describe("governance expiry", () => { return true; }, legacyTtlMs: 72 * 365 * 3600_000, - governance: { - previewTtlMs: null, - previewIdleMs: null, - maxPreviews: null, - maxPreviewsPerRepo: null, - previewMaxDbConnections: null, - postgresMaxConnections: null, - }, + governance: governanceConfig(), }); expect(deletions).toEqual([ expect.objectContaining({ reason: "sweep:ttl-expired", prId: 5 }), @@ -139,14 +136,10 @@ describe("governance expiry", () => { listOpenPrIds: async () => [5, 6], drop: async () => true, legacyTtlMs: 72 * 365 * 3600_000, - governance: { - previewTtlMs: null, - previewIdleMs: null, + governance: governanceConfig({ maxPreviews: 1, maxPreviewsPerRepo: 1, - previewMaxDbConnections: null, - postgresMaxConnections: null, - }, + }), log: (m) => { logs.push(m); }, diff --git a/apps/server/src/sweep/live-ports.test.ts b/apps/server/src/sweep/live-ports.test.ts index 841418ca..2105896d 100644 --- a/apps/server/src/sweep/live-ports.test.ts +++ b/apps/server/src/sweep/live-ports.test.ts @@ -8,17 +8,10 @@ import { previews, repos } from "../infrastructure/db/schema.ts"; import type { PreviewDbRouter } from "../preview-db/routing.ts"; import { bindPreviewDataVolumes } from "../preview/data-volumes.ts"; import { createLiveSweepPorts } from "./live-ports.ts"; -import type { GovernanceConfig } from "@sprout/preview-env"; +import { offGovernanceConfig } from "../preview/governance-fixtures.ts"; import { runSweepPass } from "./reconcile.ts"; -const offGovernance: GovernanceConfig = { - previewTtlMs: null, - previewIdleMs: null, - maxPreviews: null, - maxPreviewsPerRepo: null, - previewMaxDbConnections: null, - postgresMaxConnections: null, -}; +const offGovernance = offGovernanceConfig(); function stubPreviewDb( partial: Partial & diff --git a/apps/server/src/sweep/reconcile.test.ts b/apps/server/src/sweep/reconcile.test.ts index bbff1dca..585a5554 100644 --- a/apps/server/src/sweep/reconcile.test.ts +++ b/apps/server/src/sweep/reconcile.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, setSystemTime, test } from "bun:test"; import { createForgeClient } from "../forge/client.ts"; import { forgeApiError } from "../forge/types.ts"; +import { governanceConfig } from "../preview/governance-fixtures.ts"; import { runSweepPass, type CatalogDbRef, @@ -61,14 +62,7 @@ function memoryPorts(seed: { return true; }, legacyTtlMs: 72 * 3600_000, - governance: { - previewTtlMs: null, - previewIdleMs: null, - maxPreviews: null, - maxPreviewsPerRepo: null, - previewMaxDbConnections: null, - postgresMaxConnections: null, - }, + governance: governanceConfig(), log: (message, deletion) => { logs.push(message); seed.log?.(message, deletion); diff --git a/apps/server/src/sweep/reconcile.ts b/apps/server/src/sweep/reconcile.ts index d315cd88..ec1d6250 100644 --- a/apps/server/src/sweep/reconcile.ts +++ b/apps/server/src/sweep/reconcile.ts @@ -272,7 +272,7 @@ export async function runSweepPass(ports: SweepPorts): Promise * the base, the earlier bound, and which bound won. Returns the reason, or * null to keep. The cheap activity signal is the last successful deploy * (including reseed/reset); see docs/previews.md. The legacy creation-age - * bound only collects rows that never received governance columns. + * bound only collects rows that never completed a governed deploy. */ export function planGovernanceExpiry( preview: SweepPreview, diff --git a/docs/operator-deploy.md b/docs/operator-deploy.md index 640232d3..31a29b93 100644 --- a/docs/operator-deploy.md +++ b/docs/operator-deploy.md @@ -591,7 +591,9 @@ value fails gateway boot. The first sweep pass lands on the next boundary — never at boot. Unbounded by default: with TTL/idle/caps all `off` the gateway keeps every -preview until PR close and logs a loud boot warning. The self-serve compose +running preview until PR close and logs a loud boot warning. Rows that never +complete a deploy are still collected by the legacy creation-age bound +(`SPROUT_TTL_HOURS`). The self-serve compose stack sets `SPROUT_PREVIEW_TTL=7d` so a new operator is safe without configuring anything; an existing deployment upgrades with no new required env and identical behaviour apart from that warning. diff --git a/docs/previews.md b/docs/previews.md index c0d7c1d3..eaef42c9 100644 --- a/docs/previews.md +++ b/docs/previews.md @@ -55,7 +55,9 @@ mid-deploy is never expired. The expiry reason Manifest keys override the gateway defaults (`SPROUT_PREVIEW_TTL`, `SPROUT_PREVIEW_IDLE_TEARDOWN`); `off` at the effective level disables that bound. With nothing configured the gateway is unbounded apart from PR-close -teardown and logs a loud boot warning. +teardown and logs a loud boot warning. The legacy creation-age bound +(`SPROUT_TTL_HOURS`, default 72h) only collects rows that never completed +a deploy; a running preview with both bounds off has no deadline. ## Preview database roles (`db.roles`) From c2a599e59ccbfe22a6f4bd5bb4b45809c929d14d Mon Sep 17 00:00:00 2001 From: simpros <38320875+simpros@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:13:06 +0200 Subject: [PATCH 08/10] address review feedback on #265 Nest governance policy on Config instead of the intersection, so passing the whole Config never silently satisfies a GovernanceConfig (app and sweep pass config.governance explicitly; LiveSweepDeps names GovernanceConfig instead of indexing SweepPorts). Type legacyTtlMs as number through LifecycleDeps, RouteDeps, SweepPorts and the snapshot/sweep derivations: the only production source is SPROUT_TTL_HOURS, always a number. The null arm leaves resolvePreviewExpiry with the governed/never-governed gate only. presentListedPreview reuses the snapshot it just computed instead of re-deriving expiry fields, keeping the one-derivation invariant. evaluateGovernance runs one candidate loop for pending/settled per-repo checks and drops the redundant budget null check. Share testConfig(overrides) for full Config literals alongside the existing governanceConfig() fixtures. --- apps/server/src/config.fixtures.ts | 24 +++ apps/server/src/config.test.ts | 162 +++++++------------ apps/server/src/config.ts | 75 +++++---- apps/server/src/http/app.ts | 2 +- apps/server/src/http/introspection.ts | 2 +- apps/server/src/http/routes.ts | 2 +- apps/server/src/http/test-helpers.ts | 2 +- apps/server/src/preview/governance.test.ts | 10 +- apps/server/src/preview/governance.ts | 33 ++-- apps/server/src/preview/runtime.mail.test.ts | 61 ++----- apps/server/src/preview/snapshot.ts | 14 +- apps/server/src/preview/types.ts | 2 +- apps/server/src/sweep/governance.test.ts | 16 +- apps/server/src/sweep/live-ports.ts | 5 +- apps/server/src/sweep/reconcile.ts | 4 +- apps/server/src/sweep/start.ts | 2 +- apps/server/src/telemetry/payload.test.ts | 28 +--- 17 files changed, 179 insertions(+), 265 deletions(-) create mode 100644 apps/server/src/config.fixtures.ts diff --git a/apps/server/src/config.fixtures.ts b/apps/server/src/config.fixtures.ts new file mode 100644 index 00000000..8346580c --- /dev/null +++ b/apps/server/src/config.fixtures.ts @@ -0,0 +1,24 @@ +import type { Config } from "./config.ts"; +import { governanceConfig } from "./preview/governance-fixtures.ts"; + +/** Minimal test gateway config; callers override only what they assert on. */ +export function testConfig(overrides: Partial = {}): Config { + return { + traefikNetwork: "traefik", + registryPullAuth: { byHost: new Map() }, + githubToken: "", + gitlabToken: "", + extraGitlabHosts: new Set(), + ttlHours: 72, + legacyTtlMs: 72 * 3600_000, + sweepCron: "*/30 * * * *", + previewPortDefault: 8080, + seedTimeout: 180, + port: 7331, + dashboard: { enabled: false, user: "", password: "" }, + telemetry: { enabled: true, endpoint: "", auth: "" }, + otlp: { endpoint: "", headers: {} }, + governance: governanceConfig(), + ...overrides, + }; +} diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index 7d255109..77b8f27c 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -14,6 +14,7 @@ import { previewAuthNotConfiguredDetail, REQUIRED_ENV, } from "./config.ts"; +import { testConfig } from "./config.fixtures.ts"; import { formatOtlpDestination } from "./telemetry/destination.ts"; const TEST_POSTGRES_VALUES: Record<(typeof POSTGRES_REQUIRED_ENV)[number], string> = { @@ -376,86 +377,55 @@ describe("loadConfig", () => { }); test("configSummary marks unset forge tokens", () => { - const summary = configSummary({ - postgres: { - url: "postgres://admin@localhost:5432/postgres", - host: "postgres", - port: 5432, - user: "sprout_preview", - password: "x", - network: "postgres", - }, - traefikNetwork: "traefik", - registryPullAuth: { byHost: new Map() }, - githubToken: "", - gitlabToken: "", - extraGitlabHosts: new Set(), - ttlHours: 72, - legacyTtlMs: 72 * 3600_000, - sweepCron: "*/30 * * * *", - previewPortDefault: 8080, - seedTimeout: 180, - port: 7331, - dashboard: { enabled: false, user: "", password: "" }, - telemetry: { enabled: true, endpoint: "", auth: "" }, - otlp: { endpoint: "", headers: {} }, - previewTtlMs: null, - previewIdleMs: null, - maxPreviewsPerRepo: null, - maxPreviews: null, - previewMaxDbConnections: null, - postgresMaxConnections: null, - }); + const summary = configSummary( + testConfig({ + postgres: { + url: "postgres://admin@localhost:5432/postgres", + host: "postgres", + port: 5432, + user: "sprout_preview", + password: "x", + network: "postgres", + }, + }), + ); expect(summary.githubToken).toBe("[unset]"); expect(summary.gitlabToken).toBe("[unset]"); }); test("configSummary redacts secrets", () => { - const summary = configSummary({ - postgres: { - url: "postgres://admin:sekrit@localhost:5432/postgres", - host: "postgres", - port: 5432, - user: "sprout_preview", - password: "preview-secret", - network: "postgres", - }, - traefikNetwork: "traefik", - registryPullAuth: { - byHost: new Map([ - ["ghcr.io", { username: "gh", password: "secret" }], - ]), - fallback: { username: "puller", password: "registry-secret" }, - }, - githubToken: "gh", - gitlabToken: "gl", - extraGitlabHosts: new Set(["git.example.com"]), - ttlHours: 72, - legacyTtlMs: 72 * 3600_000, - sweepCron: "*/30 * * * *", - previewPortDefault: 8080, - seedTimeout: 180, - port: 7331, - traefikTls: { entrypoints: "https", certResolver: "letsencrypt" }, - traefikForwardAuth: { - middleware: "voidauth", - address: "https://auth.example.com/forward", - }, - dashboard: { - enabled: true, - host: "dashboard.internal", - user: "op", - password: "pw", - }, - telemetry: { enabled: true, endpoint: "", auth: "" }, - otlp: { endpoint: "", headers: {} }, - previewTtlMs: null, - previewIdleMs: null, - maxPreviewsPerRepo: null, - maxPreviews: null, - previewMaxDbConnections: null, - postgresMaxConnections: null, - }); + const summary = configSummary( + testConfig({ + postgres: { + url: "postgres://admin:sekrit@localhost:5432/postgres", + host: "postgres", + port: 5432, + user: "sprout_preview", + password: "preview-secret", + network: "postgres", + }, + registryPullAuth: { + byHost: new Map([ + ["ghcr.io", { username: "gh", password: "secret" }], + ]), + fallback: { username: "puller", password: "registry-secret" }, + }, + githubToken: "gh", + gitlabToken: "gl", + extraGitlabHosts: new Set(["git.example.com"]), + traefikTls: { entrypoints: "https", certResolver: "letsencrypt" }, + traefikForwardAuth: { + middleware: "voidauth", + address: "https://auth.example.com/forward", + }, + dashboard: { + enabled: true, + host: "dashboard.internal", + user: "op", + password: "pw", + }, + }), + ); expect(String(summary.previewPostgresUrl)).not.toContain("sekrit"); expect(summary.previewPgPassword).toBe("[set]"); @@ -474,36 +444,18 @@ describe("loadConfig", () => { }); test("configSummary marks anonymous registry auth", () => { - const summary = configSummary({ - postgres: { - url: "postgres://admin@localhost:5432/postgres", - host: "postgres", - port: 5432, - user: "sprout_preview", - password: "x", - network: "postgres", - }, - traefikNetwork: "traefik", - registryPullAuth: { byHost: new Map() }, - githubToken: "", - gitlabToken: "", - extraGitlabHosts: new Set(), - ttlHours: 72, - legacyTtlMs: 72 * 3600_000, - sweepCron: "*/30 * * * *", - previewPortDefault: 8080, - seedTimeout: 180, - port: 7331, - dashboard: { enabled: false, user: "", password: "" }, - telemetry: { enabled: true, endpoint: "", auth: "" }, - otlp: { endpoint: "", headers: {} }, - previewTtlMs: null, - previewIdleMs: null, - maxPreviewsPerRepo: null, - maxPreviews: null, - previewMaxDbConnections: null, - postgresMaxConnections: null, - }); + const summary = configSummary( + testConfig({ + postgres: { + url: "postgres://admin@localhost:5432/postgres", + host: "postgres", + port: 5432, + user: "sprout_preview", + password: "x", + network: "postgres", + }, + }), + ); expect(summary.registryPullAuthHosts).toBe(0); expect(summary.registryPullAuthFallback).toBe("[unset]"); expect(summary.traefikTls).toBe("[unset]"); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 2ea9d7ff..62b4f2ef 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -170,7 +170,10 @@ export type Config = { telemetry: TelemetryState; /** Operator-owned trace export; active exactly when endpoint is set. */ otlp: OtlpConfig; -} & GovernanceConfig; + /** Preview TTL/idle/cap/budget policy: null means off. Nested so passing + * the whole Config never silently satisfies a GovernanceConfig. */ + governance: GovernanceConfig; +}; function parsePositiveInt( name: string, @@ -618,40 +621,42 @@ export function loadConfig(): Config { traefikForwardAuth: parseTraefikForwardAuth(), telemetry, otlp: parseOtlpConfig(), - previewTtlMs: parseGatewayDurationMs( - "SPROUT_PREVIEW_TTL", - process.env.SPROUT_PREVIEW_TTL, - ), - previewIdleMs: parseGatewayDurationMs( - "SPROUT_PREVIEW_IDLE_TEARDOWN", - process.env.SPROUT_PREVIEW_IDLE_TEARDOWN, - ), - maxPreviewsPerRepo: parseGatewayCap( - "SPROUT_MAX_PREVIEWS_PER_REPO", - process.env.SPROUT_MAX_PREVIEWS_PER_REPO, - ), - maxPreviews: parseGatewayCap( - "SPROUT_MAX_PREVIEWS", - process.env.SPROUT_MAX_PREVIEWS, - ), - previewMaxDbConnections: parseGatewayCap( - "SPROUT_PREVIEW_MAX_DB_CONNECTIONS", - process.env.SPROUT_PREVIEW_MAX_DB_CONNECTIONS, - ), - postgresMaxConnections: parseGatewayCap( - "SPROUT_POSTGRES_MAX_CONNECTIONS", - process.env.SPROUT_POSTGRES_MAX_CONNECTIONS, - ), + governance: { + previewTtlMs: parseGatewayDurationMs( + "SPROUT_PREVIEW_TTL", + process.env.SPROUT_PREVIEW_TTL, + ), + previewIdleMs: parseGatewayDurationMs( + "SPROUT_PREVIEW_IDLE_TEARDOWN", + process.env.SPROUT_PREVIEW_IDLE_TEARDOWN, + ), + maxPreviewsPerRepo: parseGatewayCap( + "SPROUT_MAX_PREVIEWS_PER_REPO", + process.env.SPROUT_MAX_PREVIEWS_PER_REPO, + ), + maxPreviews: parseGatewayCap( + "SPROUT_MAX_PREVIEWS", + process.env.SPROUT_MAX_PREVIEWS, + ), + previewMaxDbConnections: parseGatewayCap( + "SPROUT_PREVIEW_MAX_DB_CONNECTIONS", + process.env.SPROUT_PREVIEW_MAX_DB_CONNECTIONS, + ), + postgresMaxConnections: parseGatewayCap( + "SPROUT_POSTGRES_MAX_CONNECTIONS", + process.env.SPROUT_POSTGRES_MAX_CONNECTIONS, + ), + }, }; } /** Loud boot warning when the gateway runs unbounded. */ export function governanceUnboundedWarning(config: Config): string | null { if ( - config.previewTtlMs === null && - config.previewIdleMs === null && - config.maxPreviewsPerRepo === null && - config.maxPreviews === null + config.governance.previewTtlMs === null && + config.governance.previewIdleMs === null && + config.governance.maxPreviewsPerRepo === null && + config.governance.maxPreviews === null ) { return ( "preview governance is unbounded (SPROUT_PREVIEW_TTL, " + @@ -731,12 +736,12 @@ export function configSummary(config: Config): Record { gitlabToken: config.gitlabToken === "" ? "[unset]" : "[set]", extraGitlabHosts: config.extraGitlabHosts.size, ttlHours: config.ttlHours, - previewTtlMs: config.previewTtlMs ?? "[off]", - previewIdleMs: config.previewIdleMs ?? "[off]", - maxPreviewsPerRepo: config.maxPreviewsPerRepo ?? "[off]", - maxPreviews: config.maxPreviews ?? "[off]", - previewMaxDbConnections: config.previewMaxDbConnections ?? "[off]", - postgresMaxConnections: config.postgresMaxConnections ?? "[off]", + previewTtlMs: config.governance.previewTtlMs ?? "[off]", + previewIdleMs: config.governance.previewIdleMs ?? "[off]", + maxPreviewsPerRepo: config.governance.maxPreviewsPerRepo ?? "[off]", + maxPreviews: config.governance.maxPreviews ?? "[off]", + previewMaxDbConnections: config.governance.previewMaxDbConnections ?? "[off]", + postgresMaxConnections: config.governance.postgresMaxConnections ?? "[off]", sweepCron: config.sweepCron, previewPortDefault: config.previewPortDefault, seedTimeout: config.seedTimeout, diff --git a/apps/server/src/http/app.ts b/apps/server/src/http/app.ts index d5e785f3..53983a60 100644 --- a/apps/server/src/http/app.ts +++ b/apps/server/src/http/app.ts @@ -30,7 +30,7 @@ export function startServer(deps: ServerDeps) { extraGitlabHosts: deps.config.extraGitlabHosts, telemetry: deps.telemetry ?? NO_TELEMETRY, ...(deps.tracesPlugin ? { tracesPlugin: deps.tracesPlugin } : {}), - governance: deps.config, + governance: deps.config.governance, legacyTtlMs: deps.config.legacyTtlMs, }).listen(deps.config.port); } diff --git a/apps/server/src/http/introspection.ts b/apps/server/src/http/introspection.ts index dfbac2c9..0fcc45b7 100644 --- a/apps/server/src/http/introspection.ts +++ b/apps/server/src/http/introspection.ts @@ -53,7 +53,7 @@ export type DropBody = { export function listPreviews( db: StateDb, mailboxUrl: string | undefined, - legacyTtlMs: number | null, + legacyTtlMs: number, ) { return async ({ set }: { set: { status?: number | string } }) => { const rows = await db diff --git a/apps/server/src/http/routes.ts b/apps/server/src/http/routes.ts index 70489be1..14d672b8 100644 --- a/apps/server/src/http/routes.ts +++ b/apps/server/src/http/routes.ts @@ -45,7 +45,7 @@ export type RouteDeps = { governance: GovernanceConfig; /** Legacy creation-age bound (SPROUT_TTL_HOURS as ms), computed once in * loadConfig; routes never convert units themselves. */ - legacyTtlMs: number | null; + legacyTtlMs: number; }; function stubNotImplemented({ diff --git a/apps/server/src/http/test-helpers.ts b/apps/server/src/http/test-helpers.ts index 5417caff..5f43aa8d 100644 --- a/apps/server/src/http/test-helpers.ts +++ b/apps/server/src/http/test-helpers.ts @@ -112,7 +112,7 @@ export async function createTestApp( telemetry?: TelemetryDeployHook; tracesPlugin?: TracesHandle["plugin"]; governance?: GovernanceConfig; - legacyTtlMs?: number | null; + legacyTtlMs?: number; } | string = {}, ): Promise { diff --git a/apps/server/src/preview/governance.test.ts b/apps/server/src/preview/governance.test.ts index 1a1819c6..13166fc6 100644 --- a/apps/server/src/preview/governance.test.ts +++ b/apps/server/src/preview/governance.test.ts @@ -178,7 +178,7 @@ describe("server governance policy", () => { createdAtMs: base, ttlMs: 7 * 86400_000, idleMs: null, - legacyTtlMs: null, + legacyTtlMs: 72 * 3600_000, }), ).toEqual({ expiresAtMs: base + 7 * 86400_000, bound: "ttl" }); expect( @@ -187,7 +187,7 @@ describe("server governance policy", () => { createdAtMs: base, ttlMs: 7 * 86400_000, idleMs: 2 * 3600_000, - legacyTtlMs: null, + legacyTtlMs: 72 * 3600_000, }), ).toEqual({ expiresAtMs: base + 2 * 3600_000, bound: "idle" }); expect( @@ -196,7 +196,7 @@ describe("server governance policy", () => { createdAtMs: base, ttlMs: 7 * 86400_000, idleMs: null, - legacyTtlMs: null, + legacyTtlMs: 72 * 3600_000, }), ).toEqual({ expiresAtMs: base + 7 * 86400_000, bound: "ttl" }); expect( @@ -205,7 +205,7 @@ describe("server governance policy", () => { createdAtMs: null, ttlMs: 7 * 86400_000, idleMs: null, - legacyTtlMs: null, + legacyTtlMs: 72 * 3600_000, }), ).toEqual({ expiresAtMs: null, bound: null }); expect( @@ -214,7 +214,7 @@ describe("server governance policy", () => { createdAtMs: base, ttlMs: null, idleMs: null, - legacyTtlMs: null, + legacyTtlMs: 72 * 3600_000, }), ).toEqual({ expiresAtMs: null, bound: null }); }); diff --git a/apps/server/src/preview/governance.ts b/apps/server/src/preview/governance.ts index 278f52c9..7999b696 100644 --- a/apps/server/src/preview/governance.ts +++ b/apps/server/src/preview/governance.ts @@ -69,7 +69,7 @@ export function resolvePreviewExpiry(input: { createdAtMs: number | null; ttlMs: number | null; idleMs: number | null; - legacyTtlMs: number | null; + legacyTtlMs: number; }): { expiresAtMs: number | null; bound: "ttl" | "idle" | null } { const base = input.lastActivityMs ?? input.createdAtMs; if (base === null) return { expiresAtMs: null, bound: null }; @@ -84,11 +84,7 @@ export function resolvePreviewExpiry(input: { } return { expiresAtMs: idleDeadline, bound: "idle" }; } - if ( - input.lastActivityMs === null && - input.legacyTtlMs !== null && - input.createdAtMs !== null - ) { + if (input.lastActivityMs === null && input.createdAtMs !== null) { return { expiresAtMs: input.createdAtMs + input.legacyTtlMs, bound: "ttl", @@ -203,29 +199,20 @@ export function evaluateGovernance( const perRepo = gov.maxPreviewsPerRepo; if (perRepo !== null) { - if (pending !== null) { - const count = status.byRepo.get(pending.repo) ?? 0; + const candidates: [string, number][] = + pending !== null + ? [[pending.repo, status.byRepo.get(pending.repo) ?? 0]] + : [...status.byRepo]; + for (const [repo, count] of candidates) { if (count + incoming > perRepo) { violations.push({ kind: "per-repo-cap", code: "preview_limit_reached", - repo: pending.repo, + repo, cap: perRepo, count, }); } - } else { - for (const [repo, count] of status.byRepo) { - if (count > perRepo) { - violations.push({ - kind: "per-repo-cap", - code: "preview_limit_reached", - repo, - cap: perRepo, - count, - }); - } - } } } @@ -242,12 +229,14 @@ export function evaluateGovernance( const perPreview = gov.previewMaxDbConnections; const ceiling = gov.postgresMaxConnections; if (perPreview !== null && ceiling !== null) { + // Narrowed here so the violation carries numbers; connectionProjection + // re-checks the same condition internally and yields null without one. const budget = connectionProjection({ previews: status.total + incoming, perPreview, ceiling, }); - if (budget !== null && budget.over) { + if (budget?.over) { violations.push({ kind: "connection-budget", code: "preview_connection_budget_exceeded", diff --git a/apps/server/src/preview/runtime.mail.test.ts b/apps/server/src/preview/runtime.mail.test.ts index 0b87dc19..e689a786 100644 --- a/apps/server/src/preview/runtime.mail.test.ts +++ b/apps/server/src/preview/runtime.mail.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { defaultDbSpec, type DbSpec } from "@sprout/preview-env"; +import { testConfig } from "../config.fixtures.ts"; import { buildMaterializationCtx, resolvePreviewPlan, @@ -232,58 +233,22 @@ describe("resolvePreviewPlan mail", () => { describe("buildMaterializationCtx", () => { test("carries the preview-auth capability into deploys", () => { - const ctx = buildMaterializationCtx({ - traefikNetwork: "sprout-traefik", - registryPullAuth: { byHost: new Map(), fallback: undefined }, - githubToken: "", - gitlabToken: "", - extraGitlabHosts: new Set(), - ttlHours: 72, - legacyTtlMs: 72 * 3600_000, - sweepCron: "*/30 * * * *", - previewPortDefault: 8080, - seedTimeout: 180, - port: 7331, - previewTtlMs: null, - previewIdleMs: null, - maxPreviewsPerRepo: null, - maxPreviews: null, - previewMaxDbConnections: null, - postgresMaxConnections: null, - previewAuth: { - secret: "sekrit", - address: "http://gateway:7331/v1/internal/preview-auth", - }, - dashboard: { enabled: false, user: "", password: "" }, - telemetry: { enabled: true, endpoint: "", auth: "" }, - otlp: { endpoint: "", headers: {} }, - }); + const ctx = buildMaterializationCtx( + testConfig({ + traefikNetwork: "sprout-traefik", + previewAuth: { + secret: "sekrit", + address: "http://gateway:7331/v1/internal/preview-auth", + }, + }), + ); expect(ctx.previewAuth).toEqual({ secret: "sekrit", address: "http://gateway:7331/v1/internal/preview-auth", }); - const bare = buildMaterializationCtx({ - traefikNetwork: "sprout-traefik", - registryPullAuth: { byHost: new Map(), fallback: undefined }, - githubToken: "", - gitlabToken: "", - extraGitlabHosts: new Set(), - ttlHours: 72, - legacyTtlMs: 72 * 3600_000, - sweepCron: "*/30 * * * *", - previewPortDefault: 8080, - seedTimeout: 180, - port: 7331, - dashboard: { enabled: false, user: "", password: "" }, - telemetry: { enabled: true, endpoint: "", auth: "" }, - otlp: { endpoint: "", headers: {} }, - previewTtlMs: null, - previewIdleMs: null, - maxPreviewsPerRepo: null, - maxPreviews: null, - previewMaxDbConnections: null, - postgresMaxConnections: null, - }); + const bare = buildMaterializationCtx( + testConfig({ traefikNetwork: "sprout-traefik" }), + ); expect(bare.previewAuth).toBeUndefined(); }); }); diff --git a/apps/server/src/preview/snapshot.ts b/apps/server/src/preview/snapshot.ts index f5bf6964..2ba8d976 100644 --- a/apps/server/src/preview/snapshot.ts +++ b/apps/server/src/preview/snapshot.ts @@ -41,7 +41,7 @@ export function expiresAtForRow( PreviewRow, "status" | "lastActivityAt" | "createdAt" | "ttlMs" | "idleMs" >, - legacyTtlMs: number | null, + legacyTtlMs: number, ): string | null { if (row.status === "removed") return null; const { expiresAtMs } = resolvePreviewExpiry({ @@ -56,7 +56,7 @@ export function expiresAtForRow( export function previewSnapshotFromRow( row: PreviewRow, - legacyTtlMs: number | null, + legacyTtlMs: number, ): PreviewSnapshot { const status = parsePreviewStatus(row.status); const parsed = status.ok ? status.value : "failed"; @@ -96,7 +96,7 @@ export function previewSnapshotFromRow( export function presentPreviewSnapshot( row: PreviewRow, mailboxUrl: string | undefined, - legacyTtlMs: number | null, + legacyTtlMs: number, ): PreviewSnapshot { const snapshot = previewSnapshotFromRow(row, legacyTtlMs); if (snapshot.mail_from === undefined || mailboxUrl === undefined) { @@ -130,7 +130,7 @@ export function presentListedPreview( row: PreviewRow, mailboxUrl: string | undefined, status: DisplayPreviewStatus, - legacyTtlMs: number | null, + legacyTtlMs: number, ): ListedPreview { const snap = presentPreviewSnapshot(row, mailboxUrl, legacyTtlMs); return { @@ -141,9 +141,9 @@ export function presentListedPreview( hostname: snap.hostname, status, created_at: row.createdAt, - last_activity_at: row.lastActivityAt ?? null, - expires_at: expiresAtForRow(row, legacyTtlMs), - expiry_reason: row.expiryReason ?? null, + last_activity_at: snap.last_activity_at, + expires_at: snap.expires_at, + expiry_reason: snap.expiry_reason, ...(snap.mail_from !== undefined ? { mail_from: snap.mail_from } : {}), ...(snap.mail_from_name !== undefined ? { mail_from_name: snap.mail_from_name } diff --git a/apps/server/src/preview/types.ts b/apps/server/src/preview/types.ts index e4cf64bb..c843c0e6 100644 --- a/apps/server/src/preview/types.ts +++ b/apps/server/src/preview/types.ts @@ -61,7 +61,7 @@ export type LifecycleDeps = { phaseTimer?: PhaseTimer; /** Legacy creation-age bound (SPROUT_TTL_HOURS as ms) for the read surface, * so responses show the same deadline the sweep enforces. */ - legacyTtlMs: number | null; + legacyTtlMs: number; }; /** Bring-up phases with a telemetry interest, nothing more. */ diff --git a/apps/server/src/sweep/governance.test.ts b/apps/server/src/sweep/governance.test.ts index 7a0a6beb..a121ddfc 100644 --- a/apps/server/src/sweep/governance.test.ts +++ b/apps/server/src/sweep/governance.test.ts @@ -36,7 +36,9 @@ describe("governance expiry", () => { }); // now is past 7d setSystemTime(new Date(base + 8 * 86400_000)); - expect(planGovernanceExpiry(p, Date.now(), null)).toBe("sweep:ttl-expired"); + expect( + planGovernanceExpiry(p, Date.now(), 72 * 3600_000), + ).toBe("sweep:ttl-expired"); }); test("expires by idle with idle reason", () => { @@ -47,9 +49,9 @@ describe("governance expiry", () => { idleMs: 2 * 3600_000, }); expect( - planGovernanceExpiry(p, base + 3 * 3600_000, null), + planGovernanceExpiry(p, base + 3 * 3600_000, 72 * 3600_000), ).toBe("sweep:idle-expired"); - expect(planGovernanceExpiry(p, base + 3600_000, null)).toBeNull(); + expect(planGovernanceExpiry(p, base + 3600_000, 72 * 3600_000)).toBeNull(); }); test("off at either level disables (null means keep)", () => { @@ -62,7 +64,7 @@ describe("governance expiry", () => { // Governed-off rows carry activity with null bounds, so even a live // legacy default must not collect them. expect(planGovernanceExpiry(p, base + 30 * 86400_000, 72 * 3600_000)).toBeNull(); - expect(planGovernanceExpiry(p, base + 30 * 86400_000, null)).toBeNull(); + expect(planGovernanceExpiry(p, base + 30 * 86400_000, 72 * 3600_000)).toBeNull(); }); test("legacy bound collects boundless rows by creation age", () => { @@ -85,9 +87,11 @@ describe("governance expiry", () => { ttlMs: 7 * 86400_000, idleMs: null, }); - expect(planGovernanceExpiry(stale, base + 8 * 86400_000, null)).toBeNull(); expect( - planGovernanceExpiry(stale, refreshed + 7 * 86400_000, null), + planGovernanceExpiry(stale, base + 8 * 86400_000, 72 * 3600_000), + ).toBeNull(); + expect( + planGovernanceExpiry(stale, refreshed + 7 * 86400_000, 72 * 3600_000), ).toBe("sweep:ttl-expired"); }); diff --git a/apps/server/src/sweep/live-ports.ts b/apps/server/src/sweep/live-ports.ts index 336bf071..56d17186 100644 --- a/apps/server/src/sweep/live-ports.ts +++ b/apps/server/src/sweep/live-ports.ts @@ -1,3 +1,4 @@ +import type { GovernanceConfig } from "@sprout/preview-env"; import type { PreviewAppOps } from "../app-deployment/ops.ts"; import type { ForgeClient } from "../forge/client.ts"; import type { StateDb } from "../infrastructure/db/client.ts"; @@ -25,8 +26,8 @@ export type LiveSweepDeps = { app: Pick; dataVolumes: PreviewDataVolumes; forge: ForgeClient; - legacyTtlMs: number | null; - governance: SweepPorts["governance"]; + legacyTtlMs: number; + governance: GovernanceConfig; log?: SweepPorts["log"]; }; diff --git a/apps/server/src/sweep/reconcile.ts b/apps/server/src/sweep/reconcile.ts index ec1d6250..297321ce 100644 --- a/apps/server/src/sweep/reconcile.ts +++ b/apps/server/src/sweep/reconcile.ts @@ -53,7 +53,7 @@ export type SweepPorts = { drop: (deletion: SweepDeletion) => Promise; /** Legacy creation-age bound (SPROUT_TTL_HOURS as ms), computed once in * loadConfig; the sweep never converts units itself. */ - legacyTtlMs: number | null; + legacyTtlMs: number; governance: GovernanceConfig; log?: (message: string, deletion?: SweepDeletion) => void; }; @@ -277,7 +277,7 @@ export async function runSweepPass(ports: SweepPorts): Promise export function planGovernanceExpiry( preview: SweepPreview, nowMs: number, - legacyTtlMs: number | null, + legacyTtlMs: number, ): "sweep:ttl-expired" | "sweep:idle-expired" | null { const { expiresAtMs, bound } = resolvePreviewExpiry({ lastActivityMs: preview.lastActivityMs, diff --git a/apps/server/src/sweep/start.ts b/apps/server/src/sweep/start.ts index e55aecc4..59078a04 100644 --- a/apps/server/src/sweep/start.ts +++ b/apps/server/src/sweep/start.ts @@ -27,7 +27,7 @@ export function startGatewaySweep(deps: { dataVolumes: deps.dataVolumes, forge, legacyTtlMs: deps.config.legacyTtlMs, - governance: deps.config, + governance: deps.config.governance, log: (message, deletion) => { if (deletion) console.log(message, deletion); else console.log(message); diff --git a/apps/server/src/telemetry/payload.test.ts b/apps/server/src/telemetry/payload.test.ts index 99805d5e..75bb43a8 100644 --- a/apps/server/src/telemetry/payload.test.ts +++ b/apps/server/src/telemetry/payload.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import type { Config } from "../config.ts"; +import { testConfig } from "../config.fixtures.ts"; import { buildDeployEvent, buildInstallEvent, @@ -16,32 +16,6 @@ import { telemetryRuntime, } from "./payload.ts"; -function testConfig(overrides: Partial = {}): Config { - return { - traefikNetwork: "traefik", - registryPullAuth: { byHost: new Map() }, - githubToken: "", - gitlabToken: "", - extraGitlabHosts: new Set(), - ttlHours: 72, - legacyTtlMs: 72 * 3600_000, - sweepCron: "*/30 * * * *", - previewPortDefault: 8080, - seedTimeout: 180, - port: 7331, - dashboard: { enabled: false, user: "", password: "" }, - telemetry: { enabled: true, endpoint: "", auth: "" }, - otlp: { endpoint: "", headers: {} }, - previewTtlMs: null, - previewIdleMs: null, - maxPreviewsPerRepo: null, - maxPreviews: null, - previewMaxDbConnections: null, - postgresMaxConnections: null, - ...overrides, - }; -} - const INSTALL_ID = "3f9d7a1e-8b2c-4d5e-9f01-23456789abcd"; const NOW = Date.parse("2026-09-30T12:00:00.000Z"); From 6cb103997cfb7e51a19845223e22421cad61ee73 Mon Sep 17 00:00:00 2001 From: simpros <38320875+simpros@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:33:48 +0200 Subject: [PATCH 09/10] address review feedback on #265 Round-12 leftovers: gateway env parsers move into config.ts (foundation no longer imports the preview domain; caps share parsePositiveInt), parseExtraGitlabHosts reuses splitListEntries, GovernanceManifest drops the server-dead raw arm to parsed ttlMs/idleMs, sweep names its lock choice with two functions and documents snapshot-at-deploy bounds, evaluateGovernance collapses the budget guard. Parser tests move to config.test.ts with loadConfig governance coverage. --- apps/server/src/config.test.ts | 63 +++++++++++++++++++ apps/server/src/config.ts | 47 +++++++++++---- apps/server/src/http/deploy.ts | 4 +- apps/server/src/preview/governance.test.ts | 47 +++------------ apps/server/src/preview/governance.ts | 70 +++++----------------- apps/server/src/sweep/live-ports.ts | 34 ++++++++--- apps/server/src/sweep/reconcile.ts | 14 +++-- packages/preview-env/src/governance.ts | 10 ++-- 8 files changed, 165 insertions(+), 124 deletions(-) diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index 77b8f27c..fbfa3f31 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -10,6 +10,8 @@ import { POSTGRES_REQUIRED_ENV, postgresNotConfiguredDetail, parseExtraGitlabHosts, + parseGatewayCap, + parseGatewayDurationMs, parseOtlpHeaders, previewAuthNotConfiguredDetail, REQUIRED_ENV, @@ -554,6 +556,67 @@ describe("loadConfig", () => { }); }); +describe("preview governance env", () => { + test("gateway durations accept off and reject garbage", () => { + expect(parseGatewayDurationMs("SPROUT_PREVIEW_TTL", undefined)).toBeNull(); + expect(parseGatewayDurationMs("SPROUT_PREVIEW_TTL", "off")).toBeNull(); + expect(parseGatewayDurationMs("SPROUT_PREVIEW_TTL", "7d")).toBe( + 7 * 24 * 60 * 60 * 1000, + ); + expect(parseGatewayDurationMs("SPROUT_PREVIEW_TTL", "2h")).toBe( + 2 * 60 * 60 * 1000, + ); + expect(() => + parseGatewayDurationMs("SPROUT_PREVIEW_TTL", "forever"), + ).toThrow("Invalid SPROUT_PREVIEW_TTL"); + }); + + test("gateway caps accept off and reject garbage", () => { + expect(parseGatewayCap("SPROUT_MAX_PREVIEWS", undefined)).toBeNull(); + expect(parseGatewayCap("SPROUT_MAX_PREVIEWS", "off")).toBeNull(); + expect(parseGatewayCap("SPROUT_MAX_PREVIEWS", "10")).toBe(10); + expect(() => parseGatewayCap("SPROUT_MAX_PREVIEWS", "0")).toThrow( + "Invalid SPROUT_MAX_PREVIEWS", + ); + }); + + test("legacy bound derives once from SPROUT_TTL_HOURS", () => { + setRequiredEnv(); + const config = loadConfig(); + expect(config.ttlHours).toBe(OPTIONAL_ENV_DEFAULTS.SPROUT_TTL_HOURS); + expect(config.legacyTtlMs).toBe( + OPTIONAL_ENV_DEFAULTS.SPROUT_TTL_HOURS * 3600_000, + ); + expect(config.governance).toEqual({ + previewTtlMs: null, + previewIdleMs: null, + maxPreviewsPerRepo: null, + maxPreviews: null, + previewMaxDbConnections: null, + postgresMaxConnections: null, + }); + }); + + test("governance env loads parsed bounds", () => { + setRequiredEnv(); + process.env.SPROUT_PREVIEW_TTL = "7d"; + process.env.SPROUT_PREVIEW_IDLE_TEARDOWN = "2h"; + process.env.SPROUT_MAX_PREVIEWS_PER_REPO = "3"; + process.env.SPROUT_MAX_PREVIEWS = "10"; + process.env.SPROUT_PREVIEW_MAX_DB_CONNECTIONS = "12"; + process.env.SPROUT_POSTGRES_MAX_CONNECTIONS = "100"; + const config = loadConfig(); + expect(config.governance).toEqual({ + previewTtlMs: 7 * 86400_000, + previewIdleMs: 2 * 3600_000, + maxPreviewsPerRepo: 3, + maxPreviews: 10, + previewMaxDbConnections: 12, + postgresMaxConnections: 100, + }); + }); +}); + describe("preview auth capability", () => { test("absent group leaves link unavailable", () => { clearGatewayEnv(); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 62b4f2ef..2a08cb76 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -4,13 +4,8 @@ import type { } from "./app-deployment/labels.ts"; import type { TelemetryState } from "./telemetry/destination.ts"; import { formatOtlpDestination } from "./telemetry/destination.ts"; -import { - hoursToMs, - parseGatewayCap, - parseGatewayDurationMs, -} from "./preview/governance.ts"; import type { GovernanceConfig } from "@sprout/preview-env"; -import { DEFAULT_MAIL_FROM_DOMAIN } from "@sprout/preview-env"; +import { DEFAULT_MAIL_FROM_DOMAIN, parseDurationMs } from "@sprout/preview-env"; import { GITHUB_HOSTS } from "./forge/kind.ts"; import { buildRegistryPullAuth, @@ -188,6 +183,40 @@ function parsePositiveInt( return value; } +/** SPROUT_TTL_HOURS as ms, derived once at load so read and sweep surfaces + * share one bound without converting units themselves. */ +function hoursToMs(hours: number): number { + return hours * 60 * 60 * 1000; +} + +/** Gateway-level duration: empty/off means unbounded, otherwise a duration. */ +export function parseGatewayDurationMs( + envName: string, + raw: string | undefined, +): number | null { + const trimmed = raw?.trim() ?? ""; + if (trimmed === "" || trimmed.toLowerCase() === "off") return null; + const ms = parseDurationMs(trimmed); + if (ms === null) { + throw new Error( + `Invalid ${envName}: expected a duration (e.g. 7d, 2h, 30m) or off, got ${JSON.stringify(raw ?? "")}`, + ); + } + return ms; +} + +/** Gateway-level cap: empty/off means unbounded, otherwise a positive int. */ +export function parseGatewayCap( + envName: string, + raw: string | undefined, +): number | null { + const trimmed = raw?.trim() ?? ""; + if (trimmed === "" || trimmed.toLowerCase() === "off") return null; + // Empty/off returned above, so trimmed is always a value here and the + // default never fires; the positive-int check stays in one place. + return parsePositiveInt(envName, trimmed, 0); +} + function parseSweepCron( raw: string | undefined, defaultValue: string, @@ -477,12 +506,8 @@ function parseDashboardConfig(): DashboardConfig { } export function parseExtraGitlabHosts(raw: string): ReadonlySet { - const trimmed = raw.trim(); - if (trimmed === "") return new Set(); const out = new Set(); - for (const part of trimmed.split(",")) { - const entry = part.trim(); - if (entry === "") continue; + for (const entry of splitListEntries(raw)) { const sep = entry.includes("=") ? "=" : entry.includes(":") ? ":" : null; if (!sep) { throw new Error( diff --git a/apps/server/src/http/deploy.ts b/apps/server/src/http/deploy.ts index d599b18b..4ccf898b 100644 --- a/apps/server/src/http/deploy.ts +++ b/apps/server/src/http/deploy.ts @@ -510,8 +510,8 @@ export function resolveGovernanceRequest( return { ok: true, value: { - ...(ttl.value !== undefined ? { ttl: ttl.value } : {}), - ...(idle.value !== undefined ? { idle_teardown: idle.value } : {}), + ...(ttl.value !== undefined ? { ttlMs: ttl.value.ms } : {}), + ...(idle.value !== undefined ? { idleMs: idle.value.ms } : {}), }, }; } diff --git a/apps/server/src/preview/governance.test.ts b/apps/server/src/preview/governance.test.ts index 13166fc6..25fc59df 100644 --- a/apps/server/src/preview/governance.test.ts +++ b/apps/server/src/preview/governance.test.ts @@ -4,9 +4,6 @@ import { connectionProjection, evaluateGovernance, governanceStatus, - hoursToMs, - parseGatewayCap, - parseGatewayDurationMs, resolveEffectiveGovernanceMs, resolvePreviewExpiry, violationMessage, @@ -124,40 +121,16 @@ describe("server governance policy", () => { ); }); - test("gateway duration accepts off and rejects garbage", () => { - expect(parseGatewayDurationMs("SPROUT_PREVIEW_TTL", undefined)).toBeNull(); - expect(parseGatewayDurationMs("SPROUT_PREVIEW_TTL", "off")).toBeNull(); - expect(parseGatewayDurationMs("SPROUT_PREVIEW_TTL", "7d")).toBe( - 7 * 24 * 60 * 60 * 1000, - ); - expect(parseGatewayDurationMs("SPROUT_PREVIEW_TTL", "2h")).toBe( - 2 * 60 * 60 * 1000, - ); - expect(() => - parseGatewayDurationMs("SPROUT_PREVIEW_TTL", "forever"), - ).toThrow("Invalid SPROUT_PREVIEW_TTL"); - }); - - test("gateway caps accept off and reject garbage", () => { - expect(parseGatewayCap("SPROUT_MAX_PREVIEWS", undefined)).toBeNull(); - expect(parseGatewayCap("SPROUT_MAX_PREVIEWS", "off")).toBeNull(); - expect(parseGatewayCap("SPROUT_MAX_PREVIEWS", "10")).toBe(10); - expect(() => parseGatewayCap("SPROUT_MAX_PREVIEWS", "0")).toThrow( - "Invalid SPROUT_MAX_PREVIEWS", - ); - }); - test("manifest overrides gateway and off disables", () => { const gateway = { previewTtlMs: 2 * 3600_000, previewIdleMs: null }; - expect( - resolveEffectiveGovernanceMs( - { ttl: { raw: "7d", ms: 7 * 86400_000 } }, - gateway, - ), - ).toEqual({ ttlMs: 7 * 86400_000, idleMs: null }); - expect( - resolveEffectiveGovernanceMs({ ttl: { raw: "off", ms: null } }, gateway), - ).toEqual({ ttlMs: null, idleMs: null }); + expect(resolveEffectiveGovernanceMs({ ttlMs: 7 * 86400_000 }, gateway)).toEqual({ + ttlMs: 7 * 86400_000, + idleMs: null, + }); + expect(resolveEffectiveGovernanceMs({ ttlMs: null }, gateway)).toEqual({ + ttlMs: null, + idleMs: null, + }); expect(resolveEffectiveGovernanceMs(undefined, gateway)).toEqual({ ttlMs: 2 * 3600_000, idleMs: null, @@ -264,8 +237,4 @@ describe("server governance policy", () => { }), ).toEqual({ expiresAtMs: null, bound: null }); }); - - test("hours convert once for the legacy bound", () => { - expect(hoursToMs(72)).toBe(72 * 3600_000); - }); }); diff --git a/apps/server/src/preview/governance.ts b/apps/server/src/preview/governance.ts index 7999b696..fcc67e09 100644 --- a/apps/server/src/preview/governance.ts +++ b/apps/server/src/preview/governance.ts @@ -1,43 +1,9 @@ import { - parseDurationMs, type EffectiveGovernanceMs, type GovernanceConfig, type GovernanceManifest, } from "@sprout/preview-env"; -export function hoursToMs(hours: number): number { - return hours * 60 * 60 * 1000; -} - -/** Gateway-level parse: empty/off means unbounded, otherwise a duration. */ -export function parseGatewayDurationMs( - envName: string, - raw: string | undefined, -): number | null { - const trimmed = raw?.trim() ?? ""; - if (trimmed === "" || trimmed.toLowerCase() === "off") return null; - const ms = parseDurationMs(trimmed); - if (ms === null) { - throw new Error( - `Invalid ${envName}: expected a duration (e.g. 7d, 2h, 30m) or off, got ${JSON.stringify(raw ?? "")}`, - ); - } - return ms; -} - -export function parseGatewayCap( - envName: string, - raw: string | undefined, -): number | null { - const trimmed = raw?.trim() ?? ""; - if (trimmed === "" || trimmed.toLowerCase() === "off") return null; - const value = Number(trimmed); - if (!Number.isInteger(value) || value <= 0) { - throw new Error(`Invalid ${envName}: must be a positive integer or off`); - } - return value; -} - /** Manifest override wins; "off" at the effective level disables. Pure lookup: * the manifest already carries the parsed bound, so there is nothing to * re-parse and nothing to throw. */ @@ -47,11 +13,9 @@ export function resolveEffectiveGovernanceMs( ): EffectiveGovernanceMs { return { ttlMs: - manifest?.ttl !== undefined ? manifest.ttl.ms : gateway.previewTtlMs, + manifest?.ttlMs !== undefined ? manifest.ttlMs : gateway.previewTtlMs, idleMs: - manifest?.idle_teardown !== undefined - ? manifest.idle_teardown.ms - : gateway.previewIdleMs, + manifest?.idleMs !== undefined ? manifest.idleMs : gateway.previewIdleMs, }; } @@ -228,24 +192,22 @@ export function evaluateGovernance( const perPreview = gov.previewMaxDbConnections; const ceiling = gov.postgresMaxConnections; - if (perPreview !== null && ceiling !== null) { - // Narrowed here so the violation carries numbers; connectionProjection - // re-checks the same condition internally and yields null without one. - const budget = connectionProjection({ - previews: status.total + incoming, - perPreview, + // connectionProjection returns null without a budget, so one call covers + // both cases; the narrows are only so the violation carries numbers. + const budget = connectionProjection({ + previews: status.total + incoming, + perPreview, + ceiling, + }); + if (budget?.over && perPreview !== null && ceiling !== null) { + violations.push({ + kind: "connection-budget", + code: "preview_connection_budget_exceeded", + projected: budget.projected, ceiling, + perPreview, + previews: status.total + incoming, }); - if (budget?.over) { - violations.push({ - kind: "connection-budget", - code: "preview_connection_budget_exceeded", - projected: budget.projected, - ceiling, - perPreview, - previews: status.total + incoming, - }); - } } return violations; diff --git a/apps/server/src/sweep/live-ports.ts b/apps/server/src/sweep/live-ports.ts index 56d17186..09026143 100644 --- a/apps/server/src/sweep/live-ports.ts +++ b/apps/server/src/sweep/live-ports.ts @@ -43,9 +43,32 @@ function teardownDeps(deps: LiveSweepDeps): TeardownDeps { async function removeControlPlane( deps: LiveSweepDeps, deletion: Extract, - useTryLock = false, ): Promise { - const input = { + const result = await removePreview( + teardownDeps(deps), + controlPlaneInput(deletion), + ); + if (!result.ok) throw new Error(result.error); + return result.value; +} + +/** Non-blocking expiry removal: false when a deploy holds the preview lock. */ +async function tryRemoveControlPlane( + deps: LiveSweepDeps, + deletion: Extract, +): Promise { + const result = await tryRemovePreview( + teardownDeps(deps), + controlPlaneInput(deletion), + ); + if (!result.ok) throw new Error(result.error); + return result.value; +} + +function controlPlaneInput( + deletion: Extract, +) { + return { repo: deletion.canonicalRepoId, prId: deletion.prId, expectedDbName: deletion.dbName, @@ -53,11 +76,6 @@ async function removeControlPlane( expectedLastActivityAt: deletion.lastActivityAt, expiryReason: deletion.reason, }; - const result = useTryLock - ? await tryRemovePreview(teardownDeps(deps), input) - : await removePreview(teardownDeps(deps), input); - if (!result.ok) throw new Error(result.error); - return result.value; } export function createLiveSweepPorts(deps: LiveSweepDeps): SweepPorts { @@ -122,7 +140,7 @@ async function dropDeletion( switch (deletion.reason) { case "sweep:ttl-expired": case "sweep:idle-expired": - return removeControlPlane(deps, deletion, true); + return tryRemoveControlPlane(deps, deletion); case "sweep:pr-not-open": { const open = await deps.forge.listOpenPrIds(deletion.canonicalRepoId); if (open.includes(deletion.prId)) return false; diff --git a/apps/server/src/sweep/reconcile.ts b/apps/server/src/sweep/reconcile.ts index 297321ce..325c28fb 100644 --- a/apps/server/src/sweep/reconcile.ts +++ b/apps/server/src/sweep/reconcile.ts @@ -268,11 +268,15 @@ export async function runSweepPass(ports: SweepPorts): Promise } /** - * Governance expiry from the shared preview-env policy: one place decides - * the base, the earlier bound, and which bound won. Returns the reason, or - * null to keep. The cheap activity signal is the last successful deploy - * (including reseed/reset); see docs/previews.md. The legacy creation-age - * bound only collects rows that never completed a governed deploy. + * Governance expiry from the shared preview governance policy: one place + * decides the base, the earlier bound, and which bound won. Returns the + * reason, or null to keep. The cheap activity signal is the last successful + * deploy (including reseed/reset); see docs/previews.md. The legacy + * creation-age bound only collects rows that never completed a governed + * deploy. Expiry reads only the bounds snapshotted on the row at deploy + * time: live gateway config intentionally does not re-bound running + * previews (a config change takes effect on the next deploy). + * `ports.governance` feeds only the over-cap log below, never expiry. */ export function planGovernanceExpiry( preview: SweepPreview, diff --git a/packages/preview-env/src/governance.ts b/packages/preview-env/src/governance.ts index e617930d..a7d9e507 100644 --- a/packages/preview-env/src/governance.ts +++ b/packages/preview-env/src/governance.ts @@ -48,12 +48,12 @@ export type EffectiveGovernanceMs = { }; /** Manifest-level governance overrides; undefined means inherit the gateway. - * Each present field carries its parsed bound (`ms` null means "off"), - * produced once at the boundary so downstream resolution is pure arithmetic - * with no re-parse and no throw. */ + * Each present field is the parsed bound (null means "off"), produced once + * at the boundary so downstream resolution is pure arithmetic with no + * re-parse and no throw. */ export type GovernanceManifest = { - ttl?: { raw: string; ms: number | null }; - idle_teardown?: { raw: string; ms: number | null }; + ttlMs?: number | null; + idleMs?: number | null; }; export function governanceIssueMessage(path: string, raw: string): string { From eea6d3c78b4b15a43b35828f6e9333df29770013 Mon Sep 17 00:00:00 2001 From: simpros <38320875+simpros@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:54:34 +0200 Subject: [PATCH 10/10] address review feedback on #265 Model the connection budget as one pair object with fail-fast boot on a half-configured pair; share one row-expiry derivation between the read surface and the sweep; add CLI governance tests --- .../src/commands/deploy-governance.test.ts | 54 +++++++++++++++++ apps/cli/src/commands/deploy-outcome.ts | 1 - apps/cli/src/yaml.test.ts | 53 +++++++++++++++++ apps/server/src/config.test.ts | 19 ++++-- apps/server/src/config.ts | 43 ++++++++++---- apps/server/src/preview/admission.test.ts | 3 +- .../server/src/preview/governance-fixtures.ts | 3 +- apps/server/src/preview/governance.test.ts | 20 ++++--- apps/server/src/preview/governance.ts | 59 +++++++++++++------ apps/server/src/preview/snapshot.ts | 22 +++---- apps/server/src/sweep/governance.test.ts | 18 +++--- apps/server/src/sweep/live-ports.test.ts | 13 ++-- apps/server/src/sweep/live-ports.ts | 5 +- apps/server/src/sweep/reconcile.test.ts | 42 ------------- apps/server/src/sweep/reconcile.ts | 38 +++++------- docs/operator-deploy.md | 4 +- packages/preview-env/src/governance.ts | 16 +++-- packages/preview-env/src/index.ts | 1 + 18 files changed, 263 insertions(+), 151 deletions(-) create mode 100644 apps/cli/src/commands/deploy-governance.test.ts diff --git a/apps/cli/src/commands/deploy-governance.test.ts b/apps/cli/src/commands/deploy-governance.test.ts new file mode 100644 index 00000000..d5ddc599 --- /dev/null +++ b/apps/cli/src/commands/deploy-governance.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import type { SproutYaml } from "../yaml.ts"; +import { buildDeployRequest } from "./deploy-core.ts"; + +const IDENTITY = { repo: "https://github.com/acme/widgets", prId: 7 }; + +function yaml(preview: SproutYaml["preview"]): SproutYaml { + return { slug: "myapp", preview }; +} + +const MINIMAL_PREVIEW: SproutYaml["preview"] = { + hostname: "pr-{pr_id}.example.com", +}; + +const INPUTS = { + appImage: "img:latest", + seedArg: [], + service: [], + clearServices: false, +}; + +describe("buildDeployRequest governance forwarding", () => { + test("forwards ttl and idle_teardown when set", () => { + const result = buildDeployRequest( + yaml({ ...MINIMAL_PREVIEW, ttl: "7d", idle_teardown: "2h" }), + IDENTITY, + INPUTS, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.ttl).toBe("7d"); + expect(result.value.idle_teardown).toBe("2h"); + }); + + test("forwards off verbatim", () => { + const result = buildDeployRequest( + yaml({ ...MINIMAL_PREVIEW, ttl: "off" }), + IDENTITY, + INPUTS, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.ttl).toBe("off"); + expect(result.value.idle_teardown).toBeUndefined(); + }); + + test("omits both when absent", () => { + const result = buildDeployRequest(yaml(MINIMAL_PREVIEW), IDENTITY, INPUTS); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.ttl).toBeUndefined(); + expect(result.value.idle_teardown).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/commands/deploy-outcome.ts b/apps/cli/src/commands/deploy-outcome.ts index 03908fd4..f83a1b0c 100644 --- a/apps/cli/src/commands/deploy-outcome.ts +++ b/apps/cli/src/commands/deploy-outcome.ts @@ -8,7 +8,6 @@ export type DeploySnapshotFields = { last_error?: string | null; last_error_detail?: string | null; expires_at?: string | null; - last_activity_at?: string | null; }; /** Settled deploy presentation shared by core, poll, and forge-note. */ diff --git a/apps/cli/src/yaml.test.ts b/apps/cli/src/yaml.test.ts index c656e9c8..238e9581 100644 --- a/apps/cli/src/yaml.test.ts +++ b/apps/cli/src/yaml.test.ts @@ -1233,3 +1233,56 @@ describe("preview.auth", () => { }); }); }); + +describe("preview.ttl / preview.idle_teardown", () => { + const base = (extra: string) => + parseSproutYaml( + `slug: myapp\npreview:\n hostname: "pr-{pr_id}.example.com"\n${extra}`, + ); + + test("absent governance keys stay absent", () => { + expect(base("")).toEqual({ + ok: true, + value: { slug: "myapp", preview: { hostname: "pr-{pr_id}.example.com" } }, + }); + }); + + test("parses durations and forwards the normalized string", () => { + expect(base(" ttl: 7d\n idle_teardown: 2h\n")).toEqual({ + ok: true, + value: { + slug: "myapp", + preview: { + hostname: "pr-{pr_id}.example.com", + ttl: "7d", + idle_teardown: "2h", + }, + }, + }); + }); + + test("off disables and forwards as off", () => { + expect(base(" ttl: off\n idle_teardown: OFF\n")).toEqual({ + ok: true, + value: { + slug: "myapp", + preview: { + hostname: "pr-{pr_id}.example.com", + ttl: "off", + idle_teardown: "off", + }, + }, + }); + }); + + test("rejects malformed governance with the key named", () => { + expect(base(" ttl: forever\n")).toEqual({ + ok: false, + error: 'preview.ttl is invalid (expected e.g. 7d, 2h, 30m or off, got "forever")', + }); + expect(base(" idle_teardown: 0\n")).toEqual({ + ok: false, + error: 'preview.idle_teardown is invalid (expected e.g. 7d, 2h, 30m or off, got "0")', + }); + }); +}); diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index fbfa3f31..6f2740ee 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -592,8 +592,7 @@ describe("preview governance env", () => { previewIdleMs: null, maxPreviewsPerRepo: null, maxPreviews: null, - previewMaxDbConnections: null, - postgresMaxConnections: null, + connectionBudget: null, }); }); @@ -611,10 +610,22 @@ describe("preview governance env", () => { previewIdleMs: 2 * 3600_000, maxPreviewsPerRepo: 3, maxPreviews: 10, - previewMaxDbConnections: 12, - postgresMaxConnections: 100, + connectionBudget: { perPreview: 12, ceiling: 100 }, }); }); + + test("half-configured connection budget fails boot naming the missing key", () => { + setRequiredEnv(); + process.env.SPROUT_PREVIEW_MAX_DB_CONNECTIONS = "12"; + expect(() => loadConfig()).toThrow( + "Incomplete preview connection budget: missing SPROUT_POSTGRES_MAX_CONNECTIONS", + ); + delete process.env.SPROUT_PREVIEW_MAX_DB_CONNECTIONS; + process.env.SPROUT_POSTGRES_MAX_CONNECTIONS = "100"; + expect(() => loadConfig()).toThrow( + "Incomplete preview connection budget: missing SPROUT_PREVIEW_MAX_DB_CONNECTIONS", + ); + }); }); describe("preview auth capability", () => { diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 2a08cb76..5341a9a5 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -4,7 +4,7 @@ import type { } from "./app-deployment/labels.ts"; import type { TelemetryState } from "./telemetry/destination.ts"; import { formatOtlpDestination } from "./telemetry/destination.ts"; -import type { GovernanceConfig } from "@sprout/preview-env"; +import type { ConnectionBudget, GovernanceConfig } from "@sprout/preview-env"; import { DEFAULT_MAIL_FROM_DOMAIN, parseDurationMs } from "@sprout/preview-env"; import { GITHUB_HOSTS } from "./forge/kind.ts"; import { @@ -217,6 +217,30 @@ export function parseGatewayCap( return parsePositiveInt(envName, trimmed, 0); } +/** Connection budget is a pair or absent: one half without the other + * fails boot instead of silently enforcing nothing. */ +function parseConnectionBudget(): ConnectionBudget | null { + const perPreview = parseGatewayCap( + "SPROUT_PREVIEW_MAX_DB_CONNECTIONS", + process.env.SPROUT_PREVIEW_MAX_DB_CONNECTIONS, + ); + const ceiling = parseGatewayCap( + "SPROUT_POSTGRES_MAX_CONNECTIONS", + process.env.SPROUT_POSTGRES_MAX_CONNECTIONS, + ); + if (perPreview === null && ceiling === null) return null; + if (perPreview === null || ceiling === null) { + const missing = + perPreview === null + ? "SPROUT_PREVIEW_MAX_DB_CONNECTIONS" + : "SPROUT_POSTGRES_MAX_CONNECTIONS"; + throw new Error( + `Incomplete preview connection budget: missing ${missing} (set both or neither)`, + ); + } + return { perPreview, ceiling }; +} + function parseSweepCron( raw: string | undefined, defaultValue: string, @@ -663,14 +687,7 @@ export function loadConfig(): Config { "SPROUT_MAX_PREVIEWS", process.env.SPROUT_MAX_PREVIEWS, ), - previewMaxDbConnections: parseGatewayCap( - "SPROUT_PREVIEW_MAX_DB_CONNECTIONS", - process.env.SPROUT_PREVIEW_MAX_DB_CONNECTIONS, - ), - postgresMaxConnections: parseGatewayCap( - "SPROUT_POSTGRES_MAX_CONNECTIONS", - process.env.SPROUT_POSTGRES_MAX_CONNECTIONS, - ), + connectionBudget: parseConnectionBudget(), }, }; } @@ -765,8 +782,7 @@ export function configSummary(config: Config): Record { previewIdleMs: config.governance.previewIdleMs ?? "[off]", maxPreviewsPerRepo: config.governance.maxPreviewsPerRepo ?? "[off]", maxPreviews: config.governance.maxPreviews ?? "[off]", - previewMaxDbConnections: config.governance.previewMaxDbConnections ?? "[off]", - postgresMaxConnections: config.governance.postgresMaxConnections ?? "[off]", + connectionBudget: connectionBudgetSummary(config.governance.connectionBudget), sweepCron: config.sweepCron, previewPortDefault: config.previewPortDefault, seedTimeout: config.seedTimeout, @@ -785,6 +801,11 @@ function otlpHeadersSummary(otlp: OtlpConfig): string { return Object.keys(otlp.headers).length > 0 ? "[set]" : "[empty]"; } +function connectionBudgetSummary(budget: ConnectionBudget | null): string { + if (budget === null) return "[off]"; + return `${budget.perPreview}x/ceiling-${budget.ceiling}`; +} + function telemetryAuthSummary(telemetry: TelemetryState): string { if (!telemetry.enabled || telemetry.auth === "") return "[empty]"; return "[set]"; diff --git a/apps/server/src/preview/admission.test.ts b/apps/server/src/preview/admission.test.ts index 8228f729..028b6776 100644 --- a/apps/server/src/preview/admission.test.ts +++ b/apps/server/src/preview/admission.test.ts @@ -7,8 +7,7 @@ import { governanceConfig } from "./governance-fixtures.ts"; const REPO = "https://github.com/acme/widgets"; const overBudget = governanceConfig({ - previewMaxDbConnections: 12, - postgresMaxConnections: 20, + connectionBudget: { perPreview: 12, ceiling: 20 }, }); describe("checkDeployAdmission connection budget", () => { diff --git a/apps/server/src/preview/governance-fixtures.ts b/apps/server/src/preview/governance-fixtures.ts index 38b8666f..6a714ff8 100644 --- a/apps/server/src/preview/governance-fixtures.ts +++ b/apps/server/src/preview/governance-fixtures.ts @@ -7,8 +7,7 @@ export function offGovernanceConfig(): GovernanceConfig { previewIdleMs: null, maxPreviews: null, maxPreviewsPerRepo: null, - previewMaxDbConnections: null, - postgresMaxConnections: null, + connectionBudget: null, }; } diff --git a/apps/server/src/preview/governance.test.ts b/apps/server/src/preview/governance.test.ts index 25fc59df..cd9f1abf 100644 --- a/apps/server/src/preview/governance.test.ts +++ b/apps/server/src/preview/governance.test.ts @@ -27,14 +27,18 @@ describe("server governance policy", () => { }); test("projection is null without a budget, arithmetic with one", () => { + expect(connectionProjection({ previews: 9, budget: null })).toBeNull(); expect( - connectionProjection({ previews: 9, perPreview: null, ceiling: 100 }), - ).toBeNull(); - expect( - connectionProjection({ previews: 8, perPreview: 12, ceiling: 100 }), + connectionProjection({ + previews: 8, + budget: { perPreview: 12, ceiling: 100 }, + }), ).toEqual({ projected: 96, over: false }); expect( - connectionProjection({ previews: 9, perPreview: 12, ceiling: 100 }), + connectionProjection({ + previews: 9, + budget: { perPreview: 12, ceiling: 100 }, + }), ).toEqual({ projected: 108, over: true }); }); @@ -57,8 +61,7 @@ describe("server governance policy", () => { ...off, maxPreviewsPerRepo: 1, maxPreviews: 1, - previewMaxDbConnections: 12, - postgresMaxConnections: 20, + connectionBudget: { perPreview: 12, ceiling: 20 }, }; const status = governanceStatus([{ canonicalRepoId: REPO_A }]); const violations = evaluateGovernance(gov, status, { repo: REPO_A }); @@ -72,8 +75,7 @@ describe("server governance policy", () => { test("budget violation counts the previews the projection counts", () => { const gov: GovernanceConfig = { ...off, - previewMaxDbConnections: 12, - postgresMaxConnections: 20, + connectionBudget: { perPreview: 12, ceiling: 20 }, }; const status = governanceStatus([{ canonicalRepoId: REPO_A }]); expect(evaluateGovernance(gov, status, { repo: REPO_B })).toEqual([ diff --git a/apps/server/src/preview/governance.ts b/apps/server/src/preview/governance.ts index fcc67e09..325ff287 100644 --- a/apps/server/src/preview/governance.ts +++ b/apps/server/src/preview/governance.ts @@ -1,8 +1,10 @@ import { + type ConnectionBudget, type EffectiveGovernanceMs, type GovernanceConfig, type GovernanceManifest, } from "@sprout/preview-env"; +import { parseUnambiguousUtcMs } from "../infrastructure/db/instant.ts"; /** Manifest override wins; "off" at the effective level disables. Pure lookup: * the manifest already carries the parsed bound, so there is nothing to @@ -57,6 +59,30 @@ export function resolvePreviewExpiry(input: { return { expiresAtMs: null, bound: null }; } +/** + * Row-shaped entry to the shared expiry derivation: parses the stored + * instants once, so the read surface and the sweep plan from one mapping + * instead of each parsing the same columns. Unparseable instants yield no + * deadline rather than a guessed one. + */ +export function resolveRowExpiry( + row: { + lastActivityAt: string | null; + createdAt: string; + ttlMs: number | null; + idleMs: number | null; + }, + legacyTtlMs: number, +): { expiresAtMs: number | null; bound: "ttl" | "idle" | null } { + return resolvePreviewExpiry({ + lastActivityMs: parseUnambiguousUtcMs(row.lastActivityAt ?? ""), + createdAtMs: parseUnambiguousUtcMs(row.createdAt), + ttlMs: row.ttlMs ?? null, + idleMs: row.idleMs ?? null, + legacyTtlMs, + }); +} + export type GovernanceStatus = { total: number; byRepo: Map; @@ -82,17 +108,18 @@ export function governanceStatus( /** * Projected connections for exactly `previews` live previews, or null when - * no budget is configured. The evaluator passes the candidate explicitly: - * admission includes the newcomer, the sweep passes the current total as-is. + * no budget is configured. The budget arrives as one pair object, so a + * half-configured budget is unrepresentable past the config boundary and + * the evaluator passes the candidate count explicitly: admission includes + * the newcomer, the sweep passes the current total as-is. */ export function connectionProjection(input: { previews: number; - perPreview: number | null; - ceiling: number | null; + budget: ConnectionBudget | null; }): { projected: number; over: boolean } | null { - if (input.perPreview === null || input.ceiling === null) return null; - const projected = input.previews * input.perPreview; - return { projected, over: projected > input.ceiling }; + if (input.budget === null) return null; + const projected = input.previews * input.budget.perPreview; + return { projected, over: projected > input.budget.ceiling }; } export type GovernanceViolation = @@ -190,22 +217,18 @@ export function evaluateGovernance( }); } - const perPreview = gov.previewMaxDbConnections; - const ceiling = gov.postgresMaxConnections; - // connectionProjection returns null without a budget, so one call covers - // both cases; the narrows are only so the violation carries numbers. - const budget = connectionProjection({ + const budget = gov.connectionBudget; + const projection = connectionProjection({ previews: status.total + incoming, - perPreview, - ceiling, + budget, }); - if (budget?.over && perPreview !== null && ceiling !== null) { + if (budget !== null && projection?.over) { violations.push({ kind: "connection-budget", code: "preview_connection_budget_exceeded", - projected: budget.projected, - ceiling, - perPreview, + projected: projection.projected, + ceiling: budget.ceiling, + perPreview: budget.perPreview, previews: status.total + incoming, }); } diff --git a/apps/server/src/preview/snapshot.ts b/apps/server/src/preview/snapshot.ts index 2ba8d976..39bc06b0 100644 --- a/apps/server/src/preview/snapshot.ts +++ b/apps/server/src/preview/snapshot.ts @@ -1,6 +1,5 @@ import { deriveMailFromName } from "@sprout/preview-env"; -import { parseUnambiguousUtcMs } from "../infrastructure/db/instant.ts"; -import { resolvePreviewExpiry } from "./governance.ts"; +import { resolveRowExpiry } from "./governance.ts"; import type { Result } from "./result.ts"; import type { PreviewRow } from "./row.ts"; import type { @@ -30,13 +29,12 @@ export function parsePreviewStatus(status: string): Result { } /** - * Read-surface expiry: derived from the same inputs the sweep plans from — - * the canonical UTC-instant parse, the governance bounds, and the legacy - * creation-age bound — so the displayed deadline and the deletion decision - * come from one derivation. Tombstones report null, matching the - * pre-derivation display. + * Read-surface expiry: the displayed deadline and the sweep's deletion + * decision come from one derivation (`resolveRowExpiry`), over the same + * parsed instants, governance bounds, and legacy creation-age bound. + * Tombstones report null, matching the pre-derivation display. */ -export function expiresAtForRow( +function expiresAtForRow( row: Pick< PreviewRow, "status" | "lastActivityAt" | "createdAt" | "ttlMs" | "idleMs" @@ -44,13 +42,7 @@ export function expiresAtForRow( legacyTtlMs: number, ): string | null { if (row.status === "removed") return null; - const { expiresAtMs } = resolvePreviewExpiry({ - lastActivityMs: parseUnambiguousUtcMs(row.lastActivityAt ?? ""), - createdAtMs: parseUnambiguousUtcMs(row.createdAt), - ttlMs: row.ttlMs ?? null, - idleMs: row.idleMs ?? null, - legacyTtlMs, - }); + const { expiresAtMs } = resolveRowExpiry(row, legacyTtlMs); return expiresAtMs === null ? null : new Date(expiresAtMs).toISOString(); } diff --git a/apps/server/src/sweep/governance.test.ts b/apps/server/src/sweep/governance.test.ts index a121ddfc..7a3e9642 100644 --- a/apps/server/src/sweep/governance.test.ts +++ b/apps/server/src/sweep/governance.test.ts @@ -13,16 +13,18 @@ function preview(over: Partial = {}): SweepPreview { slug: "widgets", dbName: "sprout_widgets_pr5", createdAt: "2026-09-02T12:00:00.000Z", - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), lastActivityAt: null, status: "running", - lastActivityMs: null, ttlMs: null, idleMs: null, ...over, }; } +function activityAt(ms: number): string { + return new Date(ms).toISOString(); +} + describe("governance expiry", () => { afterEach(() => setSystemTime()); @@ -30,7 +32,7 @@ describe("governance expiry", () => { setSystemTime(new Date("2026-09-10T12:00:00.000Z")); const base = Date.parse("2026-09-02T12:00:00.000Z"); const p = preview({ - lastActivityMs: base, + lastActivityAt: activityAt(base), ttlMs: 7 * 86400_000, idleMs: null, }); @@ -44,7 +46,7 @@ describe("governance expiry", () => { test("expires by idle with idle reason", () => { const base = Date.parse("2026-09-02T12:00:00.000Z"); const p = preview({ - lastActivityMs: base, + lastActivityAt: activityAt(base), ttlMs: null, idleMs: 2 * 3600_000, }); @@ -57,7 +59,7 @@ describe("governance expiry", () => { test("off at either level disables (null means keep)", () => { const base = Date.parse("2026-09-02T12:00:00.000Z"); const p = preview({ - lastActivityMs: base, + lastActivityAt: activityAt(base), ttlMs: null, idleMs: null, }); @@ -70,7 +72,7 @@ describe("governance expiry", () => { test("legacy bound collects boundless rows by creation age", () => { const base = Date.parse("2026-09-02T12:00:00.000Z"); const legacyTtlMs = 72 * 3600_000; - const p = preview({ createdAtMs: base }); + const p = preview(); expect(planGovernanceExpiry(p, base + legacyTtlMs, legacyTtlMs)).toBe( "sweep:ttl-expired", ); @@ -83,7 +85,7 @@ describe("governance expiry", () => { const base = Date.parse("2026-09-02T12:00:00.000Z"); const refreshed = base + 8 * 86400_000; const stale = preview({ - lastActivityMs: refreshed, + lastActivityAt: activityAt(refreshed), ttlMs: 7 * 86400_000, idleMs: null, }); @@ -103,7 +105,7 @@ describe("governance expiry", () => { const result = await runSweepPass({ listPreviews: async () => [ preview({ - lastActivityMs: base, + lastActivityAt: activityAt(base), ttlMs: 7 * 86400_000, idleMs: null, }), diff --git a/apps/server/src/sweep/live-ports.test.ts b/apps/server/src/sweep/live-ports.test.ts index 2105896d..fe872e88 100644 --- a/apps/server/src/sweep/live-ports.test.ts +++ b/apps/server/src/sweep/live-ports.test.ts @@ -100,7 +100,7 @@ describe("createLiveSweepPorts", () => { expect(rows[0]?.containerId).toBeNull(); }); - test("parses ISO-Z createdAt into createdAtMs", async () => { + test("parses ISO-Z createdAt with no invalid-instant log", async () => { const testDb = await createTestDb(); cleanup = testDb.cleanup; @@ -121,6 +121,8 @@ describe("createLiveSweepPorts", () => { updatedAt: "2026-09-02T12:00:00.000Z", }); + const droppedDbs: string[] = []; + const logs: string[] = []; const docker = createFakeDockerClient(); const ports = createLiveSweepPorts({ db: testDb.db, @@ -132,13 +134,14 @@ describe("createLiveSweepPorts", () => { forge: { listOpenPrIds: async () => [1] }, governance: offGovernance, legacyTtlMs: 72 * 3600_000, + log: (message) => { + logs.push(message); + }, }); const listed = await ports.listPreviews(); expect(listed[0]?.createdAt).toBe("2026-09-02T12:00:00.000Z"); - expect(listed[0]?.createdAtMs).toBe( - Date.parse("2026-09-02T12:00:00.000Z"), - ); + expect(logs.some((m) => m.includes("invalid createdAt"))).toBe(false); }); test("invalid createdAt skips TTL but protects catalog from orphan GC", async () => { @@ -186,7 +189,6 @@ describe("createLiveSweepPorts", () => { const listed = await ports.listPreviews(); expect(listed).toHaveLength(1); - expect(listed[0]?.createdAtMs).toBeNull(); expect(logs.some((m) => m.includes("invalid createdAt"))).toBe(true); const result = await runSweepPass(ports); @@ -242,7 +244,6 @@ describe("createLiveSweepPorts", () => { const listed = await ports.listPreviews(); expect(listed).toHaveLength(1); - expect(listed[0]?.createdAtMs).toBeNull(); expect(logs.some((m) => m.includes("invalid createdAt"))).toBe(true); const result = await runSweepPass(ports); diff --git a/apps/server/src/sweep/live-ports.ts b/apps/server/src/sweep/live-ports.ts index 09026143..1ac27f5e 100644 --- a/apps/server/src/sweep/live-ports.ts +++ b/apps/server/src/sweep/live-ports.ts @@ -87,8 +87,7 @@ export function createLiveSweepPorts(deps: LiveSweepDeps): SweepPorts { const rows = await deps.db.select().from(previews); const out: SweepPreview[] = []; for (const row of rows) { - const createdAtMs = parseUnambiguousUtcMs(row.createdAt); - if (createdAtMs === null) { + if (parseUnambiguousUtcMs(row.createdAt) === null) { deps.log?.( `sweep preview invalid createdAt ${row.createdAt} (${row.slug}:${row.prId})`, ); @@ -99,10 +98,8 @@ export function createLiveSweepPorts(deps: LiveSweepDeps): SweepPorts { slug: row.slug, dbName: row.dbName, createdAt: row.createdAt, - createdAtMs, lastActivityAt: row.lastActivityAt ?? null, status: row.status, - lastActivityMs: parseUnambiguousUtcMs(row.lastActivityAt ?? ""), ttlMs: row.ttlMs ?? null, idleMs: row.idleMs ?? null, }); diff --git a/apps/server/src/sweep/reconcile.test.ts b/apps/server/src/sweep/reconcile.test.ts index 585a5554..94c27dab 100644 --- a/apps/server/src/sweep/reconcile.test.ts +++ b/apps/server/src/sweep/reconcile.test.ts @@ -88,9 +88,7 @@ describe("runSweepPass", () => { dbName: "sprout_widgets_pr10", createdAt: "2026-09-02T12:00:00.000Z", lastActivityAt: null, - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", - lastActivityMs: null, ttlMs: null, idleMs: null, }, @@ -124,9 +122,7 @@ describe("runSweepPass", () => { dbName: "sprout_widgets_pr5", createdAt: "2026-08-01T12:00:00.000Z", lastActivityAt: null, - createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), status: "running", - lastActivityMs: null, ttlMs: null, idleMs: null, }, @@ -227,9 +223,7 @@ describe("runSweepPass", () => { dbName: "sprout_widgets_pr3", createdAt: "2026-09-02T12:00:00.000Z", lastActivityAt: null, - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", - lastActivityMs: null, ttlMs: null, idleMs: null, }, @@ -313,9 +307,7 @@ describe("runSweepPass", () => { dbName: "sprout_widgets_pr10", createdAt: "2026-08-01T12:00:00.000Z", lastActivityAt: null, - createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), status: "running", - lastActivityMs: null, ttlMs: null, idleMs: null, }, @@ -326,9 +318,7 @@ describe("runSweepPass", () => { dbName: "sprout_widgets_pr11", createdAt: "2026-09-02T12:00:00.000Z", lastActivityAt: null, - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", - lastActivityMs: null, ttlMs: null, idleMs: null, }, @@ -372,9 +362,7 @@ describe("runSweepPass", () => { dbName: "sprout_widgets_pr10", createdAt: "2026-08-01T12:00:00.000Z", lastActivityAt: null, - createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), status: "running", - lastActivityMs: null, ttlMs: null, idleMs: null, }, @@ -385,9 +373,7 @@ describe("runSweepPass", () => { dbName: "sprout_widgets_pr11", createdAt: "2026-09-02T12:00:00.000Z", lastActivityAt: null, - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", - lastActivityMs: null, ttlMs: null, idleMs: null, }, @@ -432,9 +418,7 @@ describe("runSweepPass", () => { dbName: "sprout_repoa_pr1", createdAt: "2026-09-02T12:00:00.000Z", lastActivityAt: null, - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", - lastActivityMs: null, ttlMs: null, idleMs: null, }, @@ -445,9 +429,7 @@ describe("runSweepPass", () => { dbName: "sprout_repob_pr2", createdAt: "2026-09-02T12:00:00.000Z", lastActivityAt: null, - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", - lastActivityMs: null, ttlMs: null, idleMs: null, }, @@ -500,9 +482,7 @@ describe("runSweepPass", () => { dbName: "sprout_widgets_pr10", createdAt: "2026-09-02T12:00:00.000Z", lastActivityAt: null, - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", - lastActivityMs: null, ttlMs: null, idleMs: null, }, @@ -513,9 +493,7 @@ describe("runSweepPass", () => { dbName: "sprout_sprouts_pr20", createdAt: "2026-09-02T12:00:00.000Z", lastActivityAt: null, - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", - lastActivityMs: null, ttlMs: null, idleMs: null, }, @@ -526,9 +504,7 @@ describe("runSweepPass", () => { dbName: "sprout_widgets_pr11", createdAt: "2026-09-02T12:00:00.000Z", lastActivityAt: null, - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", - lastActivityMs: null, ttlMs: null, idleMs: null, }, @@ -539,9 +515,7 @@ describe("runSweepPass", () => { dbName: "sprout_sprouts_pr21", createdAt: "2026-09-02T12:00:00.000Z", lastActivityAt: null, - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", - lastActivityMs: null, ttlMs: null, idleMs: null, }, @@ -584,9 +558,7 @@ describe("runSweepPass", () => { dbName: "sprout_widgets_pr3", createdAt: "2026-09-02T12:00:00.000Z", lastActivityAt: null, - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", - lastActivityMs: null, ttlMs: null, idleMs: null, }, @@ -617,9 +589,7 @@ describe("runSweepPass", () => { dbName: "sprout_widgets_pr3", createdAt: "2026-09-02T12:00:00.000Z", lastActivityAt: null, - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", - lastActivityMs: null, ttlMs: null, idleMs: null, }, @@ -645,9 +615,7 @@ describe("runSweepPass", () => { dbName: "sprout_widgets_pr10", createdAt: "2026-08-01T12:00:00.000Z", lastActivityAt: null, - createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), status: "running", - lastActivityMs: null, ttlMs: null, idleMs: null, }, @@ -658,9 +626,7 @@ describe("runSweepPass", () => { dbName: "sprout_widgets_pr11", createdAt: "2026-09-02T12:00:00.000Z", lastActivityAt: null, - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", - lastActivityMs: null, ttlMs: null, idleMs: null, }, @@ -714,9 +680,7 @@ describe("runSweepPass", () => { dbName: "sprout_widgets_pr10", createdAt: "2026-08-01T12:00:00.000Z", lastActivityAt: null, - createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), status: "running", - lastActivityMs: null, ttlMs: null, idleMs: null, }, @@ -768,9 +732,7 @@ describe("runSweepPass", () => { dbName: "sprout_widgets_pr3", createdAt: "2026-09-02T12:00:00.000Z", lastActivityAt: null, - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), status: "running", - lastActivityMs: null, ttlMs: null, idleMs: null, }, @@ -796,9 +758,7 @@ describe("runSweepPass", () => { dbName: "sprout_widgets_pr1", createdAt: "2026-08-01T12:00:00.000Z", lastActivityAt: null, - createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), status: "running", - lastActivityMs: null, ttlMs: null, idleMs: null, }, @@ -809,9 +769,7 @@ describe("runSweepPass", () => { dbName: "sprout_widgets_pr2", createdAt: "2026-08-01T12:00:00.000Z", lastActivityAt: null, - createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), status: "running", - lastActivityMs: null, ttlMs: null, idleMs: null, }, diff --git a/apps/server/src/sweep/reconcile.ts b/apps/server/src/sweep/reconcile.ts index 325c28fb..099dfcda 100644 --- a/apps/server/src/sweep/reconcile.ts +++ b/apps/server/src/sweep/reconcile.ts @@ -3,7 +3,7 @@ import { isForgeApiError } from "../forge/types.ts"; import { evaluateGovernance, governanceStatus, - resolvePreviewExpiry, + resolveRowExpiry, violationMessage, } from "../preview/governance.ts"; import type { PreviewExpiryReason } from "../preview/types.ts"; @@ -18,10 +18,8 @@ export type SweepPreview = { slug: string; dbName: string | null; createdAt: string; - createdAtMs: number | null; lastActivityAt: string | null; status: string; - lastActivityMs: number | null; ttlMs: number | null; idleMs: number | null; }; @@ -182,6 +180,7 @@ export async function runSweepPass(ports: SweepPorts): Promise const nowMs = Date.now(); const legacyTtlMs = ports.legacyTtlMs; const previewKeys = new Set(); + const live: SweepPreview[] = []; const remainingPreviews: SweepPreview[] = []; const expiryDeletions: SweepDeletion[] = []; @@ -189,6 +188,7 @@ export async function runSweepPass(ports: SweepPorts): Promise if (preview.status === "removed") continue; previewKeys.add(`${preview.slug}:${preview.prId}`); + live.push(preview); const governanceExpiry = planGovernanceExpiry(preview, nowMs, legacyTtlMs); if (governanceExpiry) { expiryDeletions.push({ @@ -205,7 +205,7 @@ export async function runSweepPass(ports: SweepPorts): Promise } } - logOverCap(ports, previews); + logOverCap(ports, live); const orphanDeletions = planOrphans({ previewKeys, @@ -268,14 +268,14 @@ export async function runSweepPass(ports: SweepPorts): Promise } /** - * Governance expiry from the shared preview governance policy: one place - * decides the base, the earlier bound, and which bound won. Returns the - * reason, or null to keep. The cheap activity signal is the last successful - * deploy (including reseed/reset); see docs/previews.md. The legacy - * creation-age bound only collects rows that never completed a governed - * deploy. Expiry reads only the bounds snapshotted on the row at deploy - * time: live gateway config intentionally does not re-bound running - * previews (a config change takes effect on the next deploy). + * Governance expiry from the shared row derivation: one place decides the + * base, the earlier bound, and which bound won. Returns the reason, or + * null to keep. The cheap activity signal is the last successful deploy + * (including reseed/reset); see docs/previews.md. The legacy creation-age + * bound only collects rows that never completed a governed deploy. Expiry + * reads only the bounds snapshotted on the row at deploy time: live + * gateway config intentionally does not re-bound running previews (a + * config change takes effect on the next deploy). * `ports.governance` feeds only the over-cap log below, never expiry. */ export function planGovernanceExpiry( @@ -283,22 +283,12 @@ export function planGovernanceExpiry( nowMs: number, legacyTtlMs: number, ): "sweep:ttl-expired" | "sweep:idle-expired" | null { - const { expiresAtMs, bound } = resolvePreviewExpiry({ - lastActivityMs: preview.lastActivityMs, - createdAtMs: preview.createdAtMs, - ttlMs: preview.ttlMs, - idleMs: preview.idleMs, - legacyTtlMs, - }); + const { expiresAtMs, bound } = resolveRowExpiry(preview, legacyTtlMs); if (expiresAtMs === null || nowMs < expiresAtMs) return null; return bound === "ttl" ? "sweep:ttl-expired" : "sweep:idle-expired"; } -function logOverCap( - ports: SweepPorts, - previews: SweepPreview[], -): void { - const live = previews.filter((p) => p.status !== "removed"); +function logOverCap(ports: SweepPorts, live: SweepPreview[]): void { const status = governanceStatus(live); for (const violation of evaluateGovernance(ports.governance, status, null)) { ports.log?.(`sweep over governance limit: ${violationMessage(violation)}`); diff --git a/docs/operator-deploy.md b/docs/operator-deploy.md index 31a29b93..e5b8c8b7 100644 --- a/docs/operator-deploy.md +++ b/docs/operator-deploy.md @@ -610,7 +610,9 @@ Connection arithmetic the gateway enforces instead of discovering afterwards: `projected = (active previews + 1) × SPROUT_PREVIEW_MAX_DB_CONNECTIONS` against `SPROUT_POSTGRES_MAX_CONNECTIONS`. A deploy beyond the projection -fails with `preview_connection_budget_exceeded`. +fails with `preview_connection_budget_exceeded`. The two vars are a pair — +set both or neither; setting exactly one fails gateway boot instead of +silently enforcing nothing. Measured case: each preview app holds two long-lived pools at default size, so ~12 idle connections per preview; ~8 previews fill a 100-slot Postgres diff --git a/packages/preview-env/src/governance.ts b/packages/preview-env/src/governance.ts index a7d9e507..65b5570d 100644 --- a/packages/preview-env/src/governance.ts +++ b/packages/preview-env/src/governance.ts @@ -24,6 +24,13 @@ export function parseDurationMs(raw: string): number | null { return ms; } +/** Connection budget arithmetic: expected per-preview connections against + * the instance ceiling. Null means no budget. */ +export type ConnectionBudget = { + perPreview: number; + ceiling: number; +}; + /** Gateway-level governance: every field resolved, null means off. */ export type GovernanceConfig = { /** Null means unbounded; manifest preview.ttl overrides. */ @@ -34,10 +41,11 @@ export type GovernanceConfig = { maxPreviewsPerRepo: number | null; /** Null means unbounded. */ maxPreviews: number | null; - /** Expected per-preview DB connections; null means no budget. */ - previewMaxDbConnections: number | null; - /** Instance ceiling for the budget arithmetic; null means no budget. */ - postgresMaxConnections: number | null; + /** Null means no budget. Parsed together at the config boundary: a + * half-configured pair fails boot instead of silently enforcing + * nothing, so the evaluator never re-narrows two independent + * nullables. */ + connectionBudget: ConnectionBudget | null; }; /** Per-preview deadlines in ms; null means off. Single home for the shape diff --git a/packages/preview-env/src/index.ts b/packages/preview-env/src/index.ts index 3c47b9a3..3f24227a 100644 --- a/packages/preview-env/src/index.ts +++ b/packages/preview-env/src/index.ts @@ -115,6 +115,7 @@ export { governanceIssueMessage, parseDurationMs, parsePreviewGovernanceField, + type ConnectionBudget, type EffectiveGovernanceMs, type GovernanceConfig, type GovernanceManifest,