Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions apps/cli/src/commands/deploy-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 };
Expand Down
54 changes: 54 additions & 0 deletions apps/cli/src/commands/deploy-governance.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
8 changes: 8 additions & 0 deletions apps/cli/src/commands/deploy-outcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ 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. */
export type DeploySettled = {
previewUrl: string;
mailboxUrl?: string;
mailFrom?: string;
expiresAt?: string;
};

export type DeployOutcome =
Expand Down Expand Up @@ -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 } : {}),
},
};
}
Expand All @@ -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}`);
}
}
3 changes: 3 additions & 0 deletions apps/cli/src/commands/forge-note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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");
Expand Down Expand Up @@ -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 } : {}),
Expand Down
53 changes: 53 additions & 0 deletions apps/cli/src/yaml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")',
});
});
});
30 changes: 29 additions & 1 deletion apps/cli/src/yaml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
authSpecIssueMessage,
dbRolesIssueMessage,
dbSpecIssueMessage,
governanceIssueMessage,
isServicePort,
labelIssueMessage,
mailSpecIssueMessage,
Expand All @@ -11,6 +12,7 @@ import {
parseMailSpec,
parsePreviewAuthSpec,
parsePreviewEnvForProvider,
parsePreviewGovernanceField,
parsePreviewVolumes,
parseServiceEnvMap,
previewVolumeIssueMessage,
Expand Down Expand Up @@ -79,6 +81,8 @@ export type SproutYaml = {
labels?: PreviewLabels;
volumes?: string[];
auth?: PreviewAuthSpec;
ttl?: string;
idle_teardown?: string;
};
db?: DbSpec;
mail?: MailSpec;
Expand All @@ -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"]);
Expand Down Expand Up @@ -404,6 +408,19 @@ function parsePreviewVolumesField(
};
}

function parseGovernanceField(
raw: unknown,
path: "preview.ttl" | "preview.idle_teardown",
): Result<string | undefined> {
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<SproutYamlService[] | undefined> {
Expand Down Expand Up @@ -552,6 +569,14 @@ export function parseSproutYaml(raw: string): Result<SproutYaml> {
);
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;

Expand All @@ -576,6 +601,9 @@ export function parseSproutYaml(raw: string): Result<SproutYaml> {
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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading