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-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 62882e6e..f83a1b0c 100644 --- a/apps/cli/src/commands/deploy-outcome.ts +++ b/apps/cli/src/commands/deploy-outcome.ts @@ -7,6 +7,7 @@ export type DeploySnapshotFields = { mail_from?: string | null; last_error?: string | null; last_error_detail?: string | null; + expires_at?: string | null; }; /** Settled deploy presentation shared by core, poll, and forge-note. */ @@ -14,6 +15,7 @@ export type DeploySettled = { previewUrl: string; mailboxUrl?: string; mailFrom?: string; + expiresAt?: string; }; export type DeployOutcome = @@ -46,12 +48,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 +71,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.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/cli/src/yaml.ts b/apps/cli/src/yaml.ts index f8f5b87a..490fadbb 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,19 @@ function parsePreviewVolumesField( }; } +function parseGovernanceField( + raw: unknown, + path: "preview.ttl" | "preview.idle_teardown", +): Result { + const parsed = parsePreviewGovernanceField(raw); + if (!parsed.ok) { + return { ok: false, error: governanceIssueMessage(path, parsed.raw) }; + } + // 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( raw: unknown, ): Result { @@ -552,6 +569,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 +601,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..acd9b048 --- /dev/null +++ b/apps/server/drizzle/20261002113607_acoustic_deathstrike/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE `previews` ADD `last_activity_at` text;--> statement-breakpoint +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 new file mode 100644 index 00000000..da33b0f2 --- /dev/null +++ b/apps/server/drizzle/20261002113607_acoustic_deathstrike/snapshot.json @@ -0,0 +1,361 @@ +{ + "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": "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.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 2aad89e0..6f2740ee 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -10,10 +10,13 @@ import { POSTGRES_REQUIRED_ENV, postgresNotConfiguredDetail, parseExtraGitlabHosts, + parseGatewayCap, + parseGatewayDurationMs, parseOtlpHeaders, 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> = { @@ -78,6 +81,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(() => { @@ -370,72 +379,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, - sweepCron: "*/30 * * * *", - previewPortDefault: 8080, - seedTimeout: 180, - port: 7331, - dashboard: { enabled: false, user: "", password: "" }, - telemetry: { enabled: true, endpoint: "", auth: "" }, - otlp: { endpoint: "", headers: {} }, - }); + 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, - 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: {} }, - }); + 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]"); @@ -454,29 +446,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, - sweepCron: "*/30 * * * *", - previewPortDefault: 8080, - seedTimeout: 180, - port: 7331, - dashboard: { enabled: false, user: "", password: "" }, - telemetry: { enabled: true, endpoint: "", auth: "" }, - otlp: { endpoint: "", headers: {} }, - }); + 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]"); @@ -575,6 +556,78 @@ 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, + connectionBudget: 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, + 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", () => { test("absent group leaves link unavailable", () => { clearGatewayEnv(); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 4627046f..5341a9a5 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -4,7 +4,8 @@ import type { } from "./app-deployment/labels.ts"; import type { TelemetryState } from "./telemetry/destination.ts"; import { formatOtlpDestination } from "./telemetry/destination.ts"; -import { DEFAULT_MAIL_FROM_DOMAIN } 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 { buildRegistryPullAuth, @@ -73,6 +74,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 +91,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", @@ -141,6 +152,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; @@ -151,6 +165,9 @@ export type Config = { telemetry: TelemetryState; /** Operator-owned trace export; active exactly when endpoint is set. */ otlp: OtlpConfig; + /** Preview TTL/idle/cap/budget policy: null means off. Nested so passing + * the whole Config never silently satisfies a GovernanceConfig. */ + governance: GovernanceConfig; }; function parsePositiveInt( @@ -166,6 +183,64 @@ 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); +} + +/** 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, @@ -551,6 +626,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(), @@ -564,11 +645,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, @@ -592,9 +670,45 @@ export function loadConfig(): Config { traefikForwardAuth: parseTraefikForwardAuth(), telemetry, otlp: parseOtlpConfig(), + 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, + ), + connectionBudget: parseConnectionBudget(), + }, }; } +/** Loud boot warning when the gateway runs unbounded. */ +export function governanceUnboundedWarning(config: Config): string | null { + if ( + config.governance.previewTtlMs === null && + config.governance.previewIdleMs === null && + config.governance.maxPreviewsPerRepo === null && + config.governance.maxPreviews === 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 +778,11 @@ export function configSummary(config: Config): Record { gitlabToken: config.gitlabToken === "" ? "[unset]" : "[set]", extraGitlabHosts: config.extraGitlabHosts.size, ttlHours: config.ttlHours, + previewTtlMs: config.governance.previewTtlMs ?? "[off]", + previewIdleMs: config.governance.previewIdleMs ?? "[off]", + maxPreviewsPerRepo: config.governance.maxPreviewsPerRepo ?? "[off]", + maxPreviews: config.governance.maxPreviews ?? "[off]", + connectionBudget: connectionBudgetSummary(config.governance.connectionBudget), sweepCron: config.sweepCron, previewPortDefault: config.previewPortDefault, seedTimeout: config.seedTimeout, @@ -682,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/http/app.ts b/apps/server/src/http/app.ts index 00033b08..53983a60 100644 --- a/apps/server/src/http/app.ts +++ b/apps/server/src/http/app.ts @@ -30,5 +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, + 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 7aa361d9..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,6 +350,8 @@ describe("ensureAdminToken", () => { dataVolumes: bindPreviewDataVolumes(docker), materialization: { traefikNetwork: "sprout-traefik" }, telemetry: NO_TELEMETRY, + governance: governanceConfig(), + 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..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,6 +392,8 @@ describe("GET /dashboard (flag on)", () => { materialization: { traefikNetwork: "sprout-traefik" }, dashboard: { ...ENABLED }, telemetry: NO_TELEMETRY, + governance: governanceConfig(), + 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 caf61370..4ccf898b 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,6 +14,7 @@ import { parseMailSpec, parsePreviewAuthSpec, parsePreviewEnvForProvider, + parsePreviewGovernanceField, parsePreviewVolumes, parseServiceEnvMap, previewAuthMode, @@ -24,6 +26,7 @@ import { validateHostname, type DbRolesMode, type DbSpec, + type GovernanceManifest, type HealthRequest, type MailSpec, type PreviewAuthSpec, @@ -41,11 +44,13 @@ import { postgresNotConfiguredDetail, previewAuthNotConfiguredDetail, } from "../config.ts"; +import { checkDeployAdmission } from "../preview/admission.ts"; import { teardownPreview, type LifecycleDeps, type PreviewSnapshot, } from "../preview/lifecycle.ts"; +import type { GovernanceConfig } from "@sprout/preview-env"; import { presentPreviewSnapshot } from "../preview/snapshot.ts"; import { resolvePreviewPlan, @@ -125,6 +130,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 +163,8 @@ export type DeployBody = { labels?: PreviewLabels; volumes?: string[]; reseed?: boolean; + ttl?: string; + idle_teardown?: string; }; export type DeploySpecs = { @@ -477,6 +486,36 @@ export function resolvePreviewVolumesRequest( }; } +export function resolveGovernanceRequest( + body: Pick, +): + | { ok: true; value: GovernanceManifest } + | { ok: false; error: string; detail?: string } { + const ttl = parsePreviewGovernanceField(body.ttl); + if (!ttl.ok) { + return { + ok: false, + error: "invalid_ttl", + detail: governanceIssueMessage("preview.ttl", ttl.raw), + }; + } + const idle = parsePreviewGovernanceField(body.idle_teardown); + if (!idle.ok) { + return { + ok: false, + error: "invalid_idle_teardown", + detail: governanceIssueMessage("preview.idle_teardown", idle.raw), + }; + } + return { + ok: true, + value: { + ...(ttl.value !== undefined ? { ttlMs: ttl.value.ms } : {}), + ...(idle.value !== undefined ? { idleMs: idle.value.ms } : {}), + }, + }; +} + export type TeardownBody = { canonical_repo_id: string; pr_id: number; @@ -502,6 +541,7 @@ export function deploy( deps: LifecycleDeps & { materialization: PreviewMaterializationCtx; telemetry: TelemetryDeployHook; + governance: GovernanceConfig; }, ) { return async ({ @@ -572,6 +612,18 @@ export function deploy( if (!volumes.ok) { return unprocessable(set, volumes); } + const governance = resolveGovernanceRequest(body); + if (!governance.ok) { + return unprocessable(set, governance); + } + const admission = await checkDeployAdmission( + deps.db, + { repo: target.value.repo, prId: target.value.prId }, + deps.governance, + governance.value, + ); + if (!admission.ok) return mapResult(admission, set); + const governanceMs = admission.value; const plan = resolvePreviewPlan(deps.materialization, { spec: deploySpecs.value.spec, slug: body.slug, @@ -624,6 +676,7 @@ export function deploy( : {}), plan, reseed: body.reseed === true, + governanceMs, ...(deps.materialization.traefikTls !== undefined ? { traefikTls: deps.materialization.traefikTls } : {}), @@ -648,13 +701,14 @@ export function deploy( return presentPreviewSnapshot( accepted.value.row, deps.materialization.mail?.uiUrl, + deps.legacyTtlMs, ); }; } export function getPreview( deps: LifecycleDeps, - mailboxUrl?: string, + mailboxUrl: string | undefined, ) { return async ({ query, @@ -672,7 +726,11 @@ export function getPreview( query.pr_id, ); if (!result.ok) return mapResult(result, set); - return presentPreviewSnapshot(result.value, mailboxUrl); + 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 a961565b..c1916a96 100644 --- a/apps/server/src/http/health-deploy.test.ts +++ b/apps/server/src/http/health-deploy.test.ts @@ -148,7 +148,16 @@ 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, }); + // 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; + }; const [row] = await testApp!.db .select() @@ -157,6 +166,7 @@ describe("POST /v1/deploy health polling", () => { and(eq(previews.canonicalRepoId, REPO), eq(previews.prId, 42)), ) .limit(1); + expect(snap.expires_at).toBeNull(); expect(row?.status).toBe("running"); expect(healthHits[0]).toBe(`http://${PLAN_FIRST_IP}:3000/health`); }); @@ -266,8 +276,20 @@ 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: 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 88f8e1fe..2967b9d4 100644 --- a/apps/server/src/http/introspection.test.ts +++ b/apps/server/src/http/introspection.test.ts @@ -103,8 +103,19 @@ 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, }, ]); + // 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).toBeNull(); }); }); diff --git a/apps/server/src/http/introspection.ts b/apps/server/src/http/introspection.ts index 1cefacc0..0fcc45b7 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, +) { 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 253be445..0ee788fb 100644 --- a/apps/server/src/http/none-deploy.test.ts +++ b/apps/server/src/http/none-deploy.test.ts @@ -151,8 +151,19 @@ 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, }, ]); + // 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).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 new file mode 100644 index 00000000..3151680e --- /dev/null +++ b/apps/server/src/http/preview-governance.test.ts @@ -0,0 +1,148 @@ +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"; +import { + governanceConfig, +} from "../preview/governance-fixtures.ts"; +import type { GovernanceConfig } from "@sprout/preview-env"; + +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: GovernanceConfig) { + 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(governanceConfig({ previewTtlMs: 7 * 86400_000 })); + 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(governanceConfig({ previewTtlMs: 7 * 86400_000 })); + 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(governanceConfig({ previewTtlMs: 7 * 86400_000 })); + 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 60e90b17..ea1fc8b2 100644 --- a/apps/server/src/http/preview-lifecycle.test.ts +++ b/apps/server/src/http/preview-lifecycle.test.ts @@ -155,6 +155,9 @@ 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(fakePreviewDb!.created).toEqual(["sprout_myapp_pr42"]); @@ -165,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: 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).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 d8338260..14d672b8 100644 --- a/apps/server/src/http/routes.ts +++ b/apps/server/src/http/routes.ts @@ -4,6 +4,7 @@ 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 } 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"; @@ -41,6 +42,10 @@ export type RouteDeps = { extraGitlabHosts?: ReadonlySet; 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; }; function stubNotImplemented({ @@ -58,11 +63,13 @@ export function createRoutes(deps: RouteDeps) { previewDb: deps.previewDb, app: deps.app, dataVolumes: deps.dataVolumes, + legacyTtlMs: deps.legacyTtlMs, }; const deployDeps = { ...lifecycle, materialization: deps.materialization, telemetry: deps.telemetry, + governance: deps.governance, }; const mailboxUrl = deps.materialization.mail?.uiUrl; const accessDeps = { @@ -104,9 +111,13 @@ export function createRoutes(deps: RouteDeps) { }) .delete("/tokens/:id", revokeToken(deps.db)), ) - .get("/previews", listPreviews(deps.db, mailboxUrl), { - beforeHandle: requireAdmin, - }) + .get( + "/previews", + listPreviews(deps.db, mailboxUrl, deps.legacyTtlMs), + { + beforeHandle: requireAdmin, + }, + ) .get("/previews/access", getPreviewAccess(accessDeps), { query: accessQuery, }) @@ -125,7 +136,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), { + 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 97b48cd5..5f43aa8d 100644 --- a/apps/server/src/http/test-helpers.ts +++ b/apps/server/src/http/test-helpers.ts @@ -30,6 +30,8 @@ import type { PostgresConfig, 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"; @@ -109,6 +111,8 @@ export async function createTestApp( extraGitlabHosts?: ReadonlySet; telemetry?: TelemetryDeployHook; tracesPlugin?: TracesHandle["plugin"]; + governance?: GovernanceConfig; + legacyTtlMs?: number; } | string = {}, ): Promise { @@ -165,6 +169,8 @@ export async function createTestApp( extraGitlabHosts: opts.extraGitlabHosts, telemetry: opts.telemetry ?? NO_TELEMETRY, ...(opts.tracesPlugin ? { tracesPlugin: opts.tracesPlugin } : {}), + governance: opts.governance ?? governanceConfig(), + legacyTtlMs: opts.legacyTtlMs ?? 72 * 3600_000, }), db, adminToken, 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..c25609bc 100644 --- a/apps/server/src/infrastructure/db/schema.ts +++ b/apps/server/src/infrastructure/db/schema.ts @@ -33,6 +33,10 @@ export const previews = sqliteTable( authSecret: text("auth_secret"), authBasicUser: text("auth_basic_user"), authBasicPassword: text("auth_basic_password"), + lastActivityAt: text("last_activity_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/admission.test.ts b/apps/server/src/preview/admission.test.ts new file mode 100644 index 00000000..028b6776 --- /dev/null +++ b/apps/server/src/preview/admission.test.ts @@ -0,0 +1,131 @@ +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 { governanceConfig } from "./governance-fixtures.ts"; + +const REPO = "https://github.com/acme/widgets"; + +const overBudget = governanceConfig({ + connectionBudget: { perPreview: 12, ceiling: 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; " + + "2 previews 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 }, + }); + }); +}); + +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 }, + governanceConfig({ maxPreviewsPerRepo: 1 }), + 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 }, + governanceConfig({ maxPreviews: 1 }), + 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 }, + governanceConfig({ maxPreviews: 1, maxPreviewsPerRepo: 1 }), + 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 new file mode 100644 index 00000000..0d65599f --- /dev/null +++ b/apps/server/src/preview/admission.ts @@ -0,0 +1,65 @@ +import { ne } from "drizzle-orm"; +import { + type EffectiveGovernanceMs, + type GovernanceConfig, + type GovernanceManifest, +} 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"; + +/** + * Single admission gate for deploy: per-repo/total caps plus the connection + * 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. + * + * 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, + input: { repo: string; prId: number }, + gov: GovernanceConfig, + 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); + + if (isNew) { + const violation = evaluateGovernance(gov, status, { repo: input.repo })[0]; + if (violation !== undefined) { + return { + ok: false, + status: 429, + error: violation.code, + detail: violationMessage(violation), + }; + } + } + + 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 61680dc2..e65017e7 100644 --- a/apps/server/src/preview/bring-up.ts +++ b/apps/server/src/preview/bring-up.ts @@ -177,6 +177,7 @@ async function syncPreviewServices( async function closeRunning( deps: LifecycleDeps, row: PreviewRow, + input: ProvisionInput, ): Promise> { const now = utcIsoNow(); const updated = await updatePreviewRow( @@ -186,13 +187,17 @@ async function closeRunning( status: "running", bringUpPlan: null, ...clearLastError, + lastActivityAt: now, + ttlMs: input.governanceMs.ttlMs, + idleMs: input.governanceMs.idleMs, + expiryReason: null, updatedAt: now, }, "preview_row_missing_on_running", ); return { ok: true, - value: previewSnapshotFromRow(updated), + value: previewSnapshotFromRow(updated, deps.legacyTtlMs), }; } @@ -217,7 +222,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 +231,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); } @@ -421,7 +426,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/governance-fixtures.ts b/apps/server/src/preview/governance-fixtures.ts new file mode 100644 index 00000000..6a714ff8 --- /dev/null +++ b/apps/server/src/preview/governance-fixtures.ts @@ -0,0 +1,19 @@ +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, + connectionBudget: 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 new file mode 100644 index 00000000..cd9f1abf --- /dev/null +++ b/apps/server/src/preview/governance.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, test } from "bun:test"; +import type { GovernanceConfig } from "@sprout/preview-env"; +import { + connectionProjection, + evaluateGovernance, + governanceStatus, + resolveEffectiveGovernanceMs, + 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 = governanceConfig(); + +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, budget: null })).toBeNull(); + expect( + connectionProjection({ + previews: 8, + budget: { perPreview: 12, ceiling: 100 }, + }), + ).toEqual({ projected: 96, over: false }); + expect( + connectionProjection({ + previews: 9, + budget: { 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", + code: "preview_limit_reached", + 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, + connectionBudget: { perPreview: 12, ceiling: 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, + connectionBudget: { perPreview: 12, ceiling: 20 }, + }; + const status = governanceStatus([{ canonicalRepoId: REPO_A }]); + expect(evaluateGovernance(gov, status, { repo: REPO_B })).toEqual([ + { + kind: "connection-budget", + code: "preview_connection_budget_exceeded", + projected: 24, + ceiling: 20, + perPreview: 12, + previews: 2, + }, + ]); + }); + + test("one message per violation kind", () => { + 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", + 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, + previews: 2, + }), + ).toBe( + "preview connection budget exceeded " + + "(projected 24 > ceiling 20; 2 previews x 12 per preview)", + ); + }); + + test("manifest overrides gateway and off disables", () => { + const gateway = { previewTtlMs: 2 * 3600_000, previewIdleMs: 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, + }); + 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: 72 * 3600_000, + }), + ).toEqual({ expiresAtMs: base + 7 * 86400_000, bound: "ttl" }); + expect( + resolvePreviewExpiry({ + lastActivityMs: base, + createdAtMs: base, + ttlMs: 7 * 86400_000, + idleMs: 2 * 3600_000, + legacyTtlMs: 72 * 3600_000, + }), + ).toEqual({ expiresAtMs: base + 2 * 3600_000, bound: "idle" }); + expect( + resolvePreviewExpiry({ + lastActivityMs: null, + createdAtMs: base, + ttlMs: 7 * 86400_000, + idleMs: null, + legacyTtlMs: 72 * 3600_000, + }), + ).toEqual({ expiresAtMs: base + 7 * 86400_000, bound: "ttl" }); + expect( + resolvePreviewExpiry({ + lastActivityMs: null, + createdAtMs: null, + ttlMs: 7 * 86400_000, + idleMs: null, + legacyTtlMs: 72 * 3600_000, + }), + ).toEqual({ expiresAtMs: null, bound: null }); + expect( + resolvePreviewExpiry({ + lastActivityMs: base, + createdAtMs: base, + ttlMs: null, + idleMs: null, + legacyTtlMs: 72 * 3600_000, + }), + ).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" }); + // 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({ + lastActivityMs: null, + createdAtMs: null, + ttlMs: null, + idleMs: null, + legacyTtlMs, + }), + ).toEqual({ expiresAtMs: null, bound: null }); + }); +}); diff --git a/apps/server/src/preview/governance.ts b/apps/server/src/preview/governance.ts new file mode 100644 index 00000000..325ff287 --- /dev/null +++ b/apps/server/src/preview/governance.ts @@ -0,0 +1,237 @@ +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 + * re-parse and nothing to throw. */ +export function resolveEffectiveGovernanceMs( + manifest: GovernanceManifest | undefined, + gateway: Pick, +): EffectiveGovernanceMs { + return { + ttlMs: + manifest?.ttlMs !== undefined ? manifest.ttlMs : gateway.previewTtlMs, + idleMs: + manifest?.idleMs !== undefined ? manifest.idleMs : 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 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; + createdAtMs: number | null; + ttlMs: number | null; + idleMs: 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 }; + 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.lastActivityMs === null && input.createdAtMs !== null) { + return { + expiresAtMs: input.createdAtMs + input.legacyTtlMs, + bound: "ttl", + }; + } + 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; +}; + +/** + * One pass over live previews for the cap checks. Connection budget is not + * 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 }[], +): 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. 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; + budget: ConnectionBudget | null; +}): { projected: number; over: boolean } | null { + if (input.budget === null) return null; + const projected = input.previews * input.budget.perPreview; + return { projected, over: projected > input.budget.ceiling }; +} + +export type GovernanceViolation = + | { + 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; + 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` and the 429 wire code on the + * violation itself, so callers emit without re-switching on `kind`. + */ +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) { + 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, + cap: perRepo, + count, + }); + } + } + } + + const total = gov.maxPreviews; + if (total !== null && status.total + incoming > total) { + violations.push({ + kind: "total-cap", + code: "preview_limit_reached", + cap: total, + count: status.total, + }); + } + + const budget = gov.connectionBudget; + const projection = connectionProjection({ + previews: status.total + incoming, + budget, + }); + if (budget !== null && projection?.over) { + violations.push({ + kind: "connection-budget", + code: "preview_connection_budget_exceeded", + projected: projection.projected, + ceiling: budget.ceiling, + perPreview: budget.perPreview, + previews: status.total + incoming, + }); + } + + return violations; +} diff --git a/apps/server/src/preview/lifecycle.ts b/apps/server/src/preview/lifecycle.ts index df2f327d..04e01227 100644 --- a/apps/server/src/preview/lifecycle.ts +++ b/apps/server/src/preview/lifecycle.ts @@ -3,7 +3,11 @@ import { previewAuthMode } 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 { + tryWithPreviewLock, + withDbNameLock, + withPreviewLock, +} from "./locks.ts"; import { markPreviewFailed } from "./mark-failed.ts"; import { desiredPreviewAuthState } from "./auth.ts"; import { @@ -22,6 +26,7 @@ import type { BringUpPlan, DisplayPreviewStatus, LifecycleDeps, + PreviewExpiryReason, PreviewSnapshot, PreviewStatus, ProvisionInput, @@ -36,6 +41,7 @@ export type { BringUpPlan, DisplayPreviewStatus, LifecycleDeps, + PreviewExpiryReason, PreviewSnapshot, PreviewStatus, ProvisionInput, @@ -44,7 +50,11 @@ export type { TeardownInput, TeardownSnapshot, } from "./types.ts"; -export { withPreviewLock, withDbNameLock } from "./locks.ts"; +export { + tryWithPreviewLock, + withDbNameLock, + withPreviewLock, +} from "./locks.ts"; export { markPreviewFailed, markStickyPreviewFailed, @@ -185,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", @@ -216,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, }, @@ -427,6 +443,7 @@ async function destroyPreviewRow( deps: TeardownDeps, existing: PreviewRow, disposition: DestroyDisposition, + expiryReason?: PreviewExpiryReason, ): Promise> { const repo = existing.canonicalRepoId; const prId = existing.prId; @@ -469,6 +486,13 @@ async function destroyPreviewRow( .set({ status: "removed", containerId: 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( @@ -573,28 +597,56 @@ 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 }; + } + if ((existing.lastActivityAt ?? null) !== input.expectedLastActivityAt) { + 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 }; +} + +export function removePreview( + deps: TeardownDeps, + input: RemovePreviewInput, +): Promise> { + return withPreviewLock(input.repo, input.prId, () => + removePreviewUnlocked(deps, input), + ); +} - const result = await destroyPreviewRow(deps, existing, "tombstone"); - 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, () => + removePreviewUnlocked(deps, input), + ).then((attempt) => { + if (!attempt.acquired) return { ok: true as const, value: false }; + return attempt.value; }); } 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..c19ca46f 100644 --- a/apps/server/src/preview/locks.ts +++ b/apps/server/src/preview/locks.ts @@ -1,37 +1,78 @@ /** Control-plane mutations serialize per (repo, prId) via an in-process queue. */ -const previewLocks = new Map>(); +function previewKey(repo: string, prId: number): string { + return `${repo}\0${prId}`; +} + +/** + * 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 dbNameLocks = new Map>(); +const dbNameLock = createKeyedLock(); -function withKeyedLock( - locks: Map>, - key: string, +export function withPreviewLock( + repo: string, + prId: number, 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; + return previewLock.withLock(previewKey(repo, prId), fn); } -export function withPreviewLock( +/** Non-blocking variant: returns acquired:false when a deploy holds the lock. */ +export function tryWithPreviewLock( repo: string, prId: number, fn: () => Promise, -): Promise { - return withKeyedLock(previewLocks, `${repo}\0${prId}`, fn); +): Promise<{ acquired: true; value: T } | { acquired: false }> { + 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 16f0be96..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,44 +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, - sweepCron: "*/30 * * * *", - previewPortDefault: 8080, - seedTimeout: 180, - port: 7331, - 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, - sweepCron: "*/30 * * * *", - previewPortDefault: 8080, - seedTimeout: 180, - port: 7331, - dashboard: { enabled: false, user: "", password: "" }, - telemetry: { enabled: true, endpoint: "", auth: "" }, - otlp: { endpoint: "", headers: {} }, - }); + 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 1304689c..39bc06b0 100644 --- a/apps/server/src/preview/snapshot.ts +++ b/apps/server/src/preview/snapshot.ts @@ -1,4 +1,5 @@ import { deriveMailFromName } from "@sprout/preview-env"; +import { resolveRowExpiry } from "./governance.ts"; import type { Result } from "./result.ts"; import type { PreviewRow } from "./row.ts"; import type { @@ -27,7 +28,28 @@ export function parsePreviewStatus(status: string): Result { } } -export function previewSnapshotFromRow(row: PreviewRow): PreviewSnapshot { +/** + * 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. + */ +function expiresAtForRow( + row: Pick< + PreviewRow, + "status" | "lastActivityAt" | "createdAt" | "ttlMs" | "idleMs" + >, + legacyTtlMs: number, +): string | null { + if (row.status === "removed") return null; + const { expiresAtMs } = resolveRowExpiry(row, legacyTtlMs); + return expiresAtMs === null ? null : new Date(expiresAtMs).toISOString(); +} + +export function previewSnapshotFromRow( + row: PreviewRow, + legacyTtlMs: number, +): PreviewSnapshot { const status = parsePreviewStatus(row.status); const parsed = status.ok ? status.value : "failed"; const effectiveFrom = row.mailFrom ?? undefined; @@ -52,6 +74,9 @@ export function previewSnapshotFromRow(row: PreviewRow): PreviewSnapshot { ...(row.lastErrorDetail != null ? { last_error_detail: row.lastErrorDetail } : {}), + last_activity_at: row.lastActivityAt ?? null, + expires_at: expiresAtForRow(row, legacyTtlMs), + expiry_reason: row.expiryReason ?? null, }; } @@ -63,8 +88,9 @@ export function previewSnapshotFromRow(row: PreviewRow): PreviewSnapshot { export function presentPreviewSnapshot( row: PreviewRow, mailboxUrl: string | undefined, + legacyTtlMs: number, ): PreviewSnapshot { - const snapshot = previewSnapshotFromRow(row); + const snapshot = previewSnapshotFromRow(row, legacyTtlMs); if (snapshot.mail_from === undefined || mailboxUrl === undefined) { return snapshot; } @@ -82,6 +108,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; }; /** @@ -93,8 +122,9 @@ export function presentListedPreview( row: PreviewRow, mailboxUrl: string | undefined, status: DisplayPreviewStatus, + legacyTtlMs: number, ): 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, @@ -103,6 +133,9 @@ export function presentListedPreview( hostname: snap.hostname, status, created_at: row.createdAt, + 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 01e2eb72..c843c0e6 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, @@ -54,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; }; /** Bring-up phases with a telemetry interest, nothing more. */ @@ -114,6 +122,7 @@ export type ProvisionInput = { traefikForwardAuth?: TraefikForwardAuth; auth?: PreviewAuthSpec; previewAuth?: PreviewAuthConfig; + governanceMs: EffectiveGovernanceMs; }; export type TeardownInput = { @@ -121,11 +130,24 @@ 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; + /** 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; }; export type PreviewSnapshot = { @@ -142,6 +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; }; 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..7a3e9642 --- /dev/null +++ b/apps/server/src/sweep/governance.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, setSystemTime, test, afterEach } from "bun:test"; +import { governanceConfig } from "../preview/governance-fixtures.ts"; +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", + lastActivityAt: null, + status: "running", + ttlMs: null, + idleMs: null, + ...over, + }; +} + +function activityAt(ms: number): string { + return new Date(ms).toISOString(); +} + +describe("governance expiry", () => { + afterEach(() => setSystemTime()); + + 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({ + lastActivityAt: activityAt(base), + ttlMs: 7 * 86400_000, + idleMs: null, + }); + // now is past 7d + setSystemTime(new Date(base + 8 * 86400_000)); + expect( + planGovernanceExpiry(p, Date.now(), 72 * 3600_000), + ).toBe("sweep:ttl-expired"); + }); + + test("expires by idle with idle reason", () => { + const base = Date.parse("2026-09-02T12:00:00.000Z"); + const p = preview({ + lastActivityAt: activityAt(base), + ttlMs: null, + idleMs: 2 * 3600_000, + }); + expect( + planGovernanceExpiry(p, base + 3 * 3600_000, 72 * 3600_000), + ).toBe("sweep:idle-expired"); + expect(planGovernanceExpiry(p, base + 3600_000, 72 * 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({ + lastActivityAt: activityAt(base), + 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, 72 * 3600_000)).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(); + expect(planGovernanceExpiry(p, base + legacyTtlMs, legacyTtlMs)).toBe( + "sweep:ttl-expired", + ); + expect( + planGovernanceExpiry(p, base + legacyTtlMs - 1, legacyTtlMs), + ).toBeNull(); + }); + + test("refreshed activity postpones expiry", () => { + const base = Date.parse("2026-09-02T12:00:00.000Z"); + const refreshed = base + 8 * 86400_000; + const stale = preview({ + lastActivityAt: activityAt(refreshed), + ttlMs: 7 * 86400_000, + idleMs: null, + }); + expect( + planGovernanceExpiry(stale, base + 8 * 86400_000, 72 * 3600_000), + ).toBeNull(); + expect( + planGovernanceExpiry(stale, refreshed + 7 * 86400_000, 72 * 3600_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)); + const deletions: unknown[] = []; + const forgeCalls: string[] = []; + const result = await runSweepPass({ + listPreviews: async () => [ + preview({ + lastActivityAt: activityAt(base), + ttlMs: 7 * 86400_000, + idleMs: null, + }), + ], + listCatalogDatabases: async () => [], + listDataVolumes: async () => [], + listPreviewContainers: async () => [], + listOpenPrIds: async (repo) => { + forgeCalls.push(repo); + return [5]; + }, + drop: async (d) => { + deletions.push(d); + return true; + }, + legacyTtlMs: 72 * 365 * 3600_000, + governance: governanceConfig(), + }); + 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, + legacyTtlMs: 72 * 365 * 3600_000, + governance: governanceConfig({ + 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.test.ts b/apps/server/src/sweep/live-ports.test.ts index 2ae117c3..fe872e88 100644 --- a/apps/server/src/sweep/live-ports.test.ts +++ b/apps/server/src/sweep/live-ports.test.ts @@ -8,8 +8,11 @@ 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 { offGovernanceConfig } from "../preview/governance-fixtures.ts"; import { runSweepPass } from "./reconcile.ts"; +const offGovernance = offGovernanceConfig(); + function stubPreviewDb( partial: Partial & Pick, @@ -60,6 +63,7 @@ describe("createLiveSweepPorts", () => { containerId: "ctr-10", status: "running", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, updatedAt: "2026-09-02T12:00:00.000Z", }); @@ -80,7 +84,8 @@ describe("createLiveSweepPorts", () => { forge: { listOpenPrIds: async () => [], }, - ttlHours: 72, + governance: offGovernance, + legacyTtlMs: 72 * 3600_000, log: () => {}, }); @@ -95,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; @@ -112,9 +117,12 @@ describe("createLiveSweepPorts", () => { containerId: null, status: "running", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, updatedAt: "2026-09-02T12:00:00.000Z", }); + const droppedDbs: string[] = []; + const logs: string[] = []; const docker = createFakeDockerClient(); const ports = createLiveSweepPorts({ db: testDb.db, @@ -124,14 +132,16 @@ describe("createLiveSweepPorts", () => { listPreviewDatabases: async () => [], }), forge: { listOpenPrIds: async () => [1] }, - ttlHours: 72, + 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 () => { @@ -152,6 +162,7 @@ describe("createLiveSweepPorts", () => { containerId: null, status: "running", createdAt: "not-a-timestamp", + lastActivityAt: null, updatedAt: "2026-09-02T12:00:00.000Z", }); @@ -169,7 +180,8 @@ describe("createLiveSweepPorts", () => { forDrop: dropRecorder(droppedDbs), }), forge: { listOpenPrIds: async () => [1] }, - ttlHours: 72, + governance: offGovernance, + legacyTtlMs: 72 * 3600_000, log: (message) => { logs.push(message); }, @@ -177,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); @@ -206,6 +217,7 @@ describe("createLiveSweepPorts", () => { containerId: null, status: "running", createdAt: "2026-09-02 12:00:00", + lastActivityAt: null, updatedAt: "2026-09-02 12:00:00", }); @@ -223,7 +235,8 @@ describe("createLiveSweepPorts", () => { forDrop: dropRecorder(droppedDbs), }), forge: { listOpenPrIds: async () => [1] }, - ttlHours: 1, + governance: offGovernance, + legacyTtlMs: 3600_000, log: (message) => { logs.push(message); }, @@ -231,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); @@ -260,6 +272,7 @@ describe("createLiveSweepPorts", () => { containerId: "ctr-10", status: "running", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, updatedAt: "2026-09-02T12:00:00.000Z", }); @@ -278,7 +291,8 @@ describe("createLiveSweepPorts", () => { }), }), forge: { listOpenPrIds: async () => [] }, - ttlHours: 72, + governance: offGovernance, + legacyTtlMs: 72 * 3600_000, log: (message) => { logs.push(message); }, @@ -312,6 +326,7 @@ describe("createLiveSweepPorts", () => { containerId: "ctr-10", status: "running", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, updatedAt: "2026-09-02T12:00:00.000Z", }); @@ -332,7 +347,8 @@ describe("createLiveSweepPorts", () => { forDrop: dropRecorder(droppedDbs), }), forge: { listOpenPrIds: async () => [] }, - ttlHours: 72, + governance: offGovernance, + legacyTtlMs: 72 * 3600_000, log: () => {}, }); @@ -346,6 +362,7 @@ describe("createLiveSweepPorts", () => { slug: "widgets", dbName: "sprout_widgets_pr10", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, }, ]); expect(droppedDbs).toEqual(["sprout_widgets_pr10"]); @@ -373,6 +390,7 @@ describe("createLiveSweepPorts", () => { containerId: null, status: "running", createdAt: "2026-09-03T11:00:00.000Z", + lastActivityAt: null, updatedAt: "2026-09-03T11:00:00.000Z", }); @@ -387,7 +405,8 @@ describe("createLiveSweepPorts", () => { forDrop: dropRecorder(droppedDbs), }), forge: { listOpenPrIds: async () => [42] }, - ttlHours: 72, + governance: offGovernance, + legacyTtlMs: 72 * 3600_000, }); const removed = await ports.drop({ @@ -397,6 +416,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([]); @@ -423,6 +443,7 @@ describe("createLiveSweepPorts", () => { containerId: null, status: "running", createdAt: "2026-09-03T11:55:00.000Z", + lastActivityAt: null, updatedAt: "2026-09-03T11:55:00.000Z", }); @@ -437,7 +458,8 @@ describe("createLiveSweepPorts", () => { forDrop: dropRecorder(droppedDbs), }), forge: { listOpenPrIds: async () => [42] }, - ttlHours: 72, + governance: offGovernance, + legacyTtlMs: 72 * 3600_000, }); const removed = await ports.drop({ @@ -447,6 +469,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([]); @@ -472,6 +495,7 @@ describe("createLiveSweepPorts", () => { containerId: null, status: "running", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, updatedAt: "2026-09-02T12:00:00.000Z", }); @@ -492,7 +516,8 @@ describe("createLiveSweepPorts", () => { return [42]; }, }, - ttlHours: 72, + governance: offGovernance, + legacyTtlMs: 72 * 3600_000, }); const removed = await ports.drop({ @@ -502,6 +527,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); @@ -525,6 +551,7 @@ describe("createLiveSweepPorts", () => { containerId: null, status: "provisioning", createdAt: "2026-09-03T12:00:00.000Z", + lastActivityAt: null, updatedAt: "2026-09-03T12:00:00.000Z", }); @@ -539,7 +566,8 @@ describe("createLiveSweepPorts", () => { forDrop: dropRecorder(droppedDbs), }), forge: { listOpenPrIds: async () => [42] }, - ttlHours: 72, + governance: offGovernance, + legacyTtlMs: 72 * 3600_000, }); const removed = await ports.drop({ @@ -569,6 +597,7 @@ describe("createLiveSweepPorts", () => { containerId: null, status: "provisioning", createdAt: "2026-09-03T12:00:00.000Z", + lastActivityAt: null, updatedAt: "2026-09-03T12:00:00.000Z", }); @@ -581,7 +610,8 @@ describe("createLiveSweepPorts", () => { listPreviewDatabases: async () => [], }), forge: { listOpenPrIds: async () => [42] }, - ttlHours: 72, + governance: offGovernance, + legacyTtlMs: 72 * 3600_000, }); const removed = await ports.drop({ @@ -608,7 +638,8 @@ describe("createLiveSweepPorts", () => { listPreviewDatabases: async () => [], }), forge: { listOpenPrIds: async () => [] }, - ttlHours: 72, + governance: offGovernance, + legacyTtlMs: 72 * 3600_000, }); const removed = await ports.drop({ @@ -642,7 +673,8 @@ describe("createLiveSweepPorts", () => { }), }), forge: { listOpenPrIds: async () => [] }, - ttlHours: 72, + governance: offGovernance, + legacyTtlMs: 72 * 3600_000, log: () => {}, }); diff --git a/apps/server/src/sweep/live-ports.ts b/apps/server/src/sweep/live-ports.ts index ee041cb9..1ac27f5e 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"; @@ -8,9 +9,11 @@ import { dropOrphanDatabase, dropOrphanDataVolume, removePreview, + tryRemovePreview, 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, @@ -23,7 +26,8 @@ export type LiveSweepDeps = { app: Pick; dataVolumes: PreviewDataVolumes; forge: ForgeClient; - ttlHours: number; + legacyTtlMs: number; + governance: GovernanceConfig; log?: SweepPorts["log"]; }; @@ -38,31 +42,52 @@ function teardownDeps(deps: LiveSweepDeps): TeardownDeps { async function removeControlPlane( deps: LiveSweepDeps, - deletion: Extract< - SweepDeletion, - { reason: "sweep:ttl-expired" | "sweep:pr-not-open" } - >, + deletion: Extract, ): Promise { - const result = await removePreview(teardownDeps(deps), { + 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, expectedCreatedAt: deletion.createdAt, - }); - if (!result.ok) throw new Error(result.error); - return result.value; + expectedLastActivityAt: deletion.lastActivityAt, + expiryReason: deletion.reason, + }; } export function createLiveSweepPorts(deps: LiveSweepDeps): SweepPorts { return { - ttlHours: deps.ttlHours, + legacyTtlMs: deps.legacyTtlMs, + governance: deps.governance, log: deps.log, listPreviews: async () => { 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})`, ); @@ -73,8 +98,10 @@ export function createLiveSweepPorts(deps: LiveSweepDeps): SweepPorts { slug: row.slug, dbName: row.dbName, createdAt: row.createdAt, - createdAtMs, + lastActivityAt: row.lastActivityAt ?? null, status: row.status, + ttlMs: row.ttlMs ?? null, + idleMs: row.idleMs ?? null, }); } return out; @@ -109,7 +136,8 @@ async function dropDeletion( ): Promise { switch (deletion.reason) { case "sweep:ttl-expired": - return removeControlPlane(deps, deletion); + case "sweep:idle-expired": + 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.test.ts b/apps/server/src/sweep/reconcile.test.ts index e1132d38..94c27dab 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, @@ -60,7 +61,8 @@ function memoryPorts(seed: { deletions.push(deletion); return true; }, - ttlHours: 72, + legacyTtlMs: 72 * 3600_000, + governance: governanceConfig(), log: (message, deletion) => { logs.push(message); seed.log?.(message, deletion); @@ -85,8 +87,10 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr10", createdAt: "2026-09-02T12:00:00.000Z", - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), + lastActivityAt: null, status: "running", + ttlMs: null, + idleMs: null, }, ], openPrs: { "https://github.com/acme/widgets": [99] }, @@ -102,6 +106,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr10", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, }, ]); }); @@ -116,8 +121,10 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr5", createdAt: "2026-08-01T12:00:00.000Z", - createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), + lastActivityAt: null, status: "running", + ttlMs: null, + idleMs: null, }, ], openPrs: { "https://github.com/acme/widgets": [5] }, @@ -132,6 +139,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr5", createdAt: "2026-08-01T12:00:00.000Z", + lastActivityAt: null, }, ]); expect(forgeCalls).toEqual([]); @@ -214,8 +222,10 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr3", createdAt: "2026-09-02T12:00:00.000Z", - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), + lastActivityAt: null, status: "running", + ttlMs: null, + idleMs: null, }, ], catalog: [{ dbName: "sprout_widgets_pr3", slug: "widgets", prId: 3 }], @@ -296,8 +306,10 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr10", createdAt: "2026-08-01T12:00:00.000Z", - createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), + lastActivityAt: null, status: "running", + ttlMs: null, + idleMs: null, }, { canonicalRepoId: "https://github.com/acme/widgets", @@ -305,8 +317,10 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr11", createdAt: "2026-09-02T12:00:00.000Z", - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), + lastActivityAt: null, status: "running", + ttlMs: null, + idleMs: null, }, ], catalog: [{ dbName: "sprout_widgets_pr99", slug: "widgets", prId: 99 }], @@ -325,6 +339,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr10", createdAt: "2026-08-01T12:00:00.000Z", + lastActivityAt: null, }, { reason: "sweep:orphan-db", @@ -346,8 +361,10 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr10", createdAt: "2026-08-01T12:00:00.000Z", - createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), + lastActivityAt: null, status: "running", + ttlMs: null, + idleMs: null, }, { canonicalRepoId: "https://github.com/acme/widgets", @@ -355,8 +372,10 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr11", createdAt: "2026-09-02T12:00:00.000Z", - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), + lastActivityAt: null, status: "running", + ttlMs: null, + idleMs: null, }, ], catalog: [{ dbName: "sprout_widgets_pr99", slug: "widgets", prId: 99 }], @@ -377,6 +396,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr10", createdAt: "2026-08-01T12:00:00.000Z", + lastActivityAt: null, }, { reason: "sweep:orphan-db", @@ -397,8 +417,10 @@ describe("runSweepPass", () => { slug: "repoa", dbName: "sprout_repoa_pr1", createdAt: "2026-09-02T12:00:00.000Z", - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), + lastActivityAt: null, status: "running", + ttlMs: null, + idleMs: null, }, { canonicalRepoId: "https://github.com/acme/repo-b", @@ -406,8 +428,10 @@ describe("runSweepPass", () => { slug: "repob", dbName: "sprout_repob_pr2", createdAt: "2026-09-02T12:00:00.000Z", - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), + lastActivityAt: null, status: "running", + ttlMs: null, + idleMs: null, }, ], openPrs: { @@ -428,6 +452,7 @@ describe("runSweepPass", () => { slug: "repob", dbName: "sprout_repob_pr2", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, }, ]); }); @@ -456,8 +481,10 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr10", createdAt: "2026-09-02T12:00:00.000Z", - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), + lastActivityAt: null, status: "running", + ttlMs: null, + idleMs: null, }, { canonicalRepoId: "https://gitlab.com/acme/sprouts", @@ -465,8 +492,10 @@ describe("runSweepPass", () => { slug: "sprouts", dbName: "sprout_sprouts_pr20", createdAt: "2026-09-02T12:00:00.000Z", - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), + lastActivityAt: null, status: "running", + ttlMs: null, + idleMs: null, }, { canonicalRepoId: "https://github.com/acme/widgets", @@ -474,8 +503,10 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr11", createdAt: "2026-09-02T12:00:00.000Z", - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), + lastActivityAt: null, status: "running", + ttlMs: null, + idleMs: null, }, { canonicalRepoId: "https://gitlab.com/acme/sprouts", @@ -483,8 +514,10 @@ describe("runSweepPass", () => { slug: "sprouts", dbName: "sprout_sprouts_pr21", createdAt: "2026-09-02T12:00:00.000Z", - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), + lastActivityAt: null, status: "running", + ttlMs: null, + idleMs: null, }, ], }); @@ -500,6 +533,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr11", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, }, { reason: "sweep:pr-not-open", @@ -508,6 +542,7 @@ describe("runSweepPass", () => { slug: "sprouts", dbName: "sprout_sprouts_pr21", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, }, ]); }); @@ -522,8 +557,10 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr3", createdAt: "2026-09-02T12:00:00.000Z", - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), + lastActivityAt: null, status: "running", + ttlMs: null, + idleMs: null, }, ], openPrs: { @@ -551,8 +588,10 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr3", createdAt: "2026-09-02T12:00:00.000Z", - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), + lastActivityAt: null, status: "running", + ttlMs: null, + idleMs: null, }, ], openPrs: { @@ -575,8 +614,10 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr10", createdAt: "2026-08-01T12:00:00.000Z", - createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), + lastActivityAt: null, status: "running", + ttlMs: null, + idleMs: null, }, { canonicalRepoId: "https://github.com/acme/widgets", @@ -584,8 +625,10 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr11", createdAt: "2026-09-02T12:00:00.000Z", - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), + lastActivityAt: null, status: "running", + ttlMs: null, + idleMs: null, }, ], catalog: [{ dbName: "sprout_widgets_pr99", slug: "widgets", prId: 99 }], @@ -606,6 +649,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr10", createdAt: "2026-08-01T12:00:00.000Z", + lastActivityAt: null, }, { reason: "sweep:orphan-db", @@ -620,6 +664,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr11", createdAt: "2026-09-02T12:00:00.000Z", + lastActivityAt: null, }, ]); }); @@ -634,8 +679,10 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr10", createdAt: "2026-08-01T12:00:00.000Z", - createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), + lastActivityAt: null, status: "running", + ttlMs: null, + idleMs: null, }, ], catalog: new Error("postgres catalog boom"), @@ -654,6 +701,7 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr10", createdAt: "2026-08-01T12:00:00.000Z", + lastActivityAt: null, }, { reason: "sweep:orphan-container", @@ -683,8 +731,10 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr3", createdAt: "2026-09-02T12:00:00.000Z", - createdAtMs: Date.parse("2026-09-02T12:00:00.000Z"), + lastActivityAt: null, status: "running", + ttlMs: null, + idleMs: null, }, ], catalog: [{ dbName: "sprout_widgets_pr3", slug: "widgets", prId: 3 }], @@ -707,8 +757,10 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr1", createdAt: "2026-08-01T12:00:00.000Z", - createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), + lastActivityAt: null, status: "running", + ttlMs: null, + idleMs: null, }, { canonicalRepoId: "https://github.com/acme/widgets", @@ -716,8 +768,10 @@ describe("runSweepPass", () => { slug: "widgets", dbName: "sprout_widgets_pr2", createdAt: "2026-08-01T12:00:00.000Z", - createdAtMs: Date.parse("2026-08-01T12:00:00.000Z"), + lastActivityAt: null, status: "running", + ttlMs: null, + idleMs: null, }, ], dropErrorFor: (deletion) => @@ -734,6 +788,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 b623974f..099dfcda 100644 --- a/apps/server/src/sweep/reconcile.ts +++ b/apps/server/src/sweep/reconcile.ts @@ -1,12 +1,12 @@ -import type { DataVolumeRef } from "@sprout/preview-env"; +import type { DataVolumeRef, GovernanceConfig } from "@sprout/preview-env"; import { isForgeApiError } from "../forge/types.ts"; - -export type SweepReason = - | "sweep:pr-not-open" - | "sweep:ttl-expired" - | "sweep:orphan-db" - | "sweep:orphan-container" - | "sweep:orphan-data-volume"; +import { + evaluateGovernance, + governanceStatus, + resolveRowExpiry, + violationMessage, +} from "../preview/governance.ts"; +import type { PreviewExpiryReason } from "../preview/types.ts"; export type PreviewRef = { slug: string; prId: number }; @@ -18,18 +18,21 @@ export type SweepPreview = { slug: string; dbName: string | null; createdAt: string; - createdAtMs: number | null; + lastActivityAt: string | null; status: string; + ttlMs: number | null; + idleMs: number | null; }; export type SweepDeletion = | { - reason: "sweep:pr-not-open" | "sweep:ttl-expired"; + reason: PreviewExpiryReason; canonicalRepoId: string; prId: number; slug: string; dbName: string | null; createdAt: string; + lastActivityAt: string | null; } | { reason: "sweep:orphan-db"; slug: string; prId: number; dbName: string } | { @@ -46,7 +49,10 @@ 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; + governance: GovernanceConfig; log?: (message: string, deletion?: SweepDeletion) => void; }; @@ -171,29 +177,36 @@ export async function runSweepPass(ports: SweepPorts): Promise ); } - const cutoff = Date.now() - ports.ttlHours * 60 * 60 * 1000; + const nowMs = Date.now(); + const legacyTtlMs = ports.legacyTtlMs; const previewKeys = new Set(); + const live: SweepPreview[] = []; 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({ - reason: "sweep:ttl-expired", + live.push(preview); + const governanceExpiry = planGovernanceExpiry(preview, nowMs, legacyTtlMs); + if (governanceExpiry) { + expiryDeletions.push({ + reason: governanceExpiry, canonicalRepoId: preview.canonicalRepoId, prId: preview.prId, slug: preview.slug, dbName: preview.dbName, createdAt: preview.createdAt, + lastActivityAt: preview.lastActivityAt, }); } else { remainingPreviews.push(preview); } } + logOverCap(ports, live); + const orphanDeletions = planOrphans({ previewKeys, catalog, @@ -241,14 +254,43 @@ export async function runSweepPass(ports: SweepPorts): Promise slug: preview.slug, dbName: preview.dbName, createdAt: preview.createdAt, + lastActivityAt: preview.lastActivityAt, }); } const deletions = await dropSettled( ports, - [...ttlDeletions, ...orphanDeletions, ...prNotOpenDeletions], + [...expiryDeletions, ...orphanDeletions, ...prNotOpenDeletions], (d) => `deleted (${d.reason})`, ); return { forgeRepoFailures, deletions }; } + +/** + * 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( + preview: SweepPreview, + nowMs: number, + legacyTtlMs: number, +): "sweep:ttl-expired" | "sweep:idle-expired" | null { + 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, 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/apps/server/src/sweep/start.ts b/apps/server/src/sweep/start.ts index da61508c..59078a04 100644 --- a/apps/server/src/sweep/start.ts +++ b/apps/server/src/sweep/start.ts @@ -26,7 +26,8 @@ export function startGatewaySweep(deps: { app: deps.app, dataVolumes: deps.dataVolumes, forge, - ttlHours: deps.config.ttlHours, + legacyTtlMs: deps.config.legacyTtlMs, + 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 dc993554..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,25 +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, - sweepCron: "*/30 * * * *", - previewPortDefault: 8080, - seedTimeout: 180, - port: 7331, - dashboard: { enabled: false, user: "", password: "" }, - telemetry: { enabled: true, endpoint: "", auth: "" }, - otlp: { endpoint: "", headers: {} }, - ...overrides, - }; -} - const INSTALL_ID = "3f9d7a1e-8b2c-4d5e-9f01-23456789abcd"; const NOW = Date.parse("2026-09-30T12:00:00.000Z"); 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..e5b8c8b7 100644 --- a/docs/operator-deploy.md +++ b/docs/operator-deploy.md @@ -574,16 +574,55 @@ 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 +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. + +### 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`. 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 +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..eaef42c9 100644 --- a/docs/previews.md +++ b/docs/previews.md @@ -23,6 +23,42 @@ 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; 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. + +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. 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`) 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..be31c48c --- /dev/null +++ b/packages/preview-env/src/governance.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import { + governanceIssueMessage, + parseDurationMs, + parsePreviewGovernanceField, +} from "./governance.ts"; + +describe("preview governance durations", () => { + test("parses manifest ttl/idle with off", () => { + expect(parsePreviewGovernanceField("7d")).toEqual({ + ok: true, + value: { raw: "7d", ms: 7 * 86400_000 }, + }); + expect(parsePreviewGovernanceField("off")).toEqual({ + ok: true, + value: { raw: "off", ms: null }, + }); + expect(parsePreviewGovernanceField("OFF")).toEqual({ + ok: true, + value: { raw: "off", ms: null }, + }); + expect(parsePreviewGovernanceField(undefined)).toEqual({ + ok: true, + value: undefined, + }); + }); + + 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("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")', + ); + }); + + 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 new file mode 100644 index 00000000..65b5570d --- /dev/null +++ b/packages/preview-env/src/governance.ts @@ -0,0 +1,96 @@ +const DURATION_RE = /^(\d+)(s|m|h|d)$/; + +/** + * 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; + 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; +} + +/** 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. */ + 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; + /** 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 + * threaded from admission through bring-up. */ +export type EffectiveGovernanceMs = { + ttlMs: number | null; + idleMs: number | null; +}; + +/** Manifest-level governance overrides; undefined means inherit the gateway. + * 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 = { + ttlMs?: number | null; + idleMs?: number | null; +}; + +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 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): + | { 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, raw: String(raw) }; + } + const trimmed = raw.trim(); + if (trimmed === "") { + return { ok: false, raw }; + } + if (trimmed.toLowerCase() === "off") + return { ok: true, value: { raw: "off", ms: null } }; + const ms = parseDurationMs(trimmed); + if (ms === null) { + return { ok: false, raw }; + } + return { ok: true, value: { raw: trimmed, ms } }; +} diff --git a/packages/preview-env/src/index.ts b/packages/preview-env/src/index.ts index 66075d3a..3f24227a 100644 --- a/packages/preview-env/src/index.ts +++ b/packages/preview-env/src/index.ts @@ -111,6 +111,16 @@ export { type PreviewVolumeIssue, } from "./volumes.ts"; +export { + governanceIssueMessage, + parseDurationMs, + parsePreviewGovernanceField, + type ConnectionBudget, + type EffectiveGovernanceMs, + type GovernanceConfig, + type GovernanceManifest, +} from "./governance.ts"; + export { DEFAULT_MAIL_FROM_DOMAIN, deriveMailFromName,