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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ wires the repo. Live pitch:
## Docs

- **[Public docs](https://simpros.github.io/sprout/docs/index.html)** — guides and reference
([source](docs/site/index.html) · [llms.txt](llms.txt))
([source](docs/) · [llms.txt](llms.txt))
- [Getting started](docs/getting-started.md) — first preview in one sitting
- [Adopting a repo](docs/adopting-a-repo.md) — write `.sprout.yaml`, migrate at startup
- [CI integration](docs/ci-integration.md) — GitHub / GitLab wiring, reset
Expand Down
4 changes: 2 additions & 2 deletions apps/cli/src/yaml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -768,11 +768,11 @@ preview:
});
expect(base(" path: /health\n interval: 2x\n timeout: 120s\n expect: 200\n")).toEqual({
ok: false,
error: "health.interval is invalid (expected Ns, e.g. 2s)",
error: "health.interval is invalid (expected e.g. 2s, 30m, 2h, 7d)",
});
expect(base(" path: /health\n interval: 2s\n timeout: 0s\n expect: 200\n")).toEqual({
ok: false,
error: "health.timeout is invalid (expected Ns, e.g. 2s)",
error: "health.timeout is invalid (expected e.g. 2s, 30m, 2h, 7d)",
});
expect(base(" path: /health\n interval: 2s\n timeout: 120s\n expect: 99\n")).toEqual({
ok: false,
Expand Down
38 changes: 5 additions & 33 deletions apps/cli/src/yaml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
dbRolesIssueMessage,
dbSpecIssueMessage,
governanceIssueMessage,
healthIssueMessage,
isServicePort,
labelIssueMessage,
mailSpecIssueMessage,
Expand All @@ -15,6 +16,7 @@ import {
parsePreviewGovernanceField,
parsePreviewVolumes,
parseServiceEnvMap,
previewEnvIssueMessage,
previewVolumeIssueMessage,
requiresDatabase,
resolveDbRoles,
Expand Down Expand Up @@ -106,19 +108,6 @@ function unknownKey(path: string): Result<never> {
return { ok: false, error: `unknown key: ${path}` };
}

function healthIssueMessage(issue: HealthIssue): string {
switch (issue.code) {
case "invalid_health_path":
return "health.path must start with /";
case "invalid_health_interval":
return "health.interval is invalid (expected Ns, e.g. 2s)";
case "invalid_health_timeout":
return "health.timeout is invalid (expected Ns, e.g. 2s)";
case "invalid_health_expect":
return "health.expect must be a number between 100 and 599";
}
}

function parseHostnameField(
raw: string,
label: string,
Expand Down Expand Up @@ -152,25 +141,7 @@ function parsePreviewEnv(

const parsed = parsePreviewEnvForProvider(raw, provider);
if (!parsed.ok) {
const { issue } = parsed;
switch (issue.code) {
case "unknown_env_key":
return unknownKey(`preview.env.${issue.key}`);
case "empty_env_target":
return { ok: false, error: `preview.env.${issue.key} is required` };
case "invalid_env_target":
return { ok: false, error: `preview.env.${issue.key} is invalid` };
case "env_target_collision":
return {
ok: false,
error: `preview.env: target collision: ${issue.target}`,
};
case "env_requires_provider":
return {
ok: false,
error: `preview.env.${issue.key} requires db.provider ${issue.home}`,
};
}
return { ok: false, error: previewEnvIssueMessage("preview.env", parsed.issue) };
}
return { ok: true, value: parsed.value };
}
Expand Down Expand Up @@ -635,7 +606,8 @@ export function parseSproutYaml(raw: string): Result<SproutYaml> {
expect: parsed.health.expect,
});
if (!resolved.ok) {
return { ok: false, error: healthIssueMessage(resolved.issue) };
const issue: HealthIssue = resolved.issue;
return { ok: false, error: healthIssueMessage(issue) };
}
value.health = {
path: resolved.value.path,
Expand Down
7 changes: 2 additions & 5 deletions apps/server/src/app-deployment/pg-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ import {
} from "@sprout/preview-env";
import {
deriveRestrictedPassword,
companionRoleLimitMessage,
companionRoleName,
COMPANION_ROLE_SUFFIX,
PG_IDENT_MAX,
} from "@sprout/preview-db";
import { applyEnvRemap } from "./env-remap.ts";

Expand All @@ -35,9 +34,7 @@ export function pgConnectionEnv(
if (roles === "dual") {
const restrictedUser = companionRoleName(dbName);
if (restrictedUser === null) {
throw new Error(
`companion role name exceeds Postgres identifier limit (${PG_IDENT_MAX}): ${dbName}${COMPANION_ROLE_SUFFIX}`,
);
throw new Error(companionRoleLimitMessage(dbName));
}
const restrictedPassword = deriveRestrictedPassword(pg.password, dbName);
fields.push(["PGAPPUSER", restrictedUser]);
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/http/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
parsePreviewVolumes,
parseServiceEnvMap,
previewAuthMode,
previewEnvIssueMessage,
previewVolumeIssueMessage,
requiresDatabase,
resolveDbRoles,
Expand Down Expand Up @@ -238,7 +239,7 @@ function resolveDeploySpecs(
ok: false,
status: 422,
error: "invalid_env_for_provider",
detail: `preview.env.${connectionEnv.issue.key} requires db.provider ${connectionEnv.issue.home}`,
detail: previewEnvIssueMessage("preview.env", connectionEnv.issue),
};
}
if (connectionEnv.issue.code === "empty_env_target") {
Expand Down
18 changes: 9 additions & 9 deletions apps/server/src/preview-db/names.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { COMPANION_ROLE_SUFFIX, PG_IDENT_MAX } from "@sprout/preview-db";
import { COMPANION_ROLE_SUFFIX, PG_IDENT_MAX, previewDbName } from "@sprout/preview-db";
import { SERVICE_NAME_RE, type DbRolesMode } from "@sprout/preview-env";

export { previewDbName };

const PREVIEW_DB_NAME_RE = /^sprout_([a-z][a-z0-9]*)_pr([1-9][0-9]*)$/;

/** Max dbName length so <dbName>_app fits Postgres NAMEDATALEN (dual mode). */
Expand All @@ -14,14 +16,16 @@ export type IdentifierError =
| "invalid_pr_id"
| "invalid_service_name";

function validateName(value: string, error: IdentifierError): IdentifierError | null {
return SERVICE_NAME_RE.test(value) ? null : error;
}

export function validateSlug(slug: string): IdentifierError | null {
if (!SERVICE_NAME_RE.test(slug)) return "invalid_slug";
return null;
return validateName(slug, "invalid_slug");
}

export function validateServiceName(name: string): IdentifierError | null {
if (!SERVICE_NAME_RE.test(name)) return "invalid_service_name";
return null;
return validateName(name, "invalid_service_name");
}

export function validatePrId(prId: number): IdentifierError | null {
Expand All @@ -46,10 +50,6 @@ export function validatePreviewIdentity(
return null;
}

export function previewDbName(slug: string, prId: number): string {
return `sprout_${slug}_pr${prId}`;
}

export function isPreviewDbName(dbName: string): boolean {
return (
PREVIEW_DB_NAME_RE.test(dbName) &&
Expand Down
7 changes: 3 additions & 4 deletions apps/server/src/preview/naming.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
// Container names only; volume names live in @sprout/preview-env.
import { previewContainerName } from "@sprout/preview-env";
import type { CatalogContainer } from "../docker/port.ts";

export { previewContainerName };

const PREVIEW_APP_CONTAINER_RE = /^sprout-([a-zA-Z0-9]+)-pr-(\d+)$/;
const PREVIEW_SERVICE_CONTAINER_RE =
/^sprout-([a-zA-Z0-9]+)-pr-(\d+)-svc-([a-zA-Z0-9]+)$/;

export function previewContainerName(slug: string, prId: number): string {
return `sprout-${slug}-pr-${prId}`;
}

export function previewServiceContainerName(
slug: string,
prId: number,
Expand Down
2 changes: 1 addition & 1 deletion docs/adopting-a-repo.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ value grammar, merge order) are the contract. Seeding order lives in
| `db.path` | no | `/data` | Container directory the SQLite volume mounts at (`sqlite` only). |
| `db.file` | no | `preview.db` | SQLite file name inside `db.path` (`sqlite` only). |
| `health.path` | when seeding | `/health` | HTTP path the gateway polls on the Postgres-network container IP. |
| `health.interval` | when seeding | `2s` | Poll interval (`Ns` form; malformed durations fail at manifest parse). |
| `health.interval` | when seeding | `2s` | Poll interval (duration like `2s`, `30m`, `2h`, `7d`; malformed durations fail at manifest parse). |
| `health.timeout` | when seeding | `120s` | How long the gateway polls before `health_timeout`. Never starts the seed. |
| `health.expect` | when seeding | `200` | Expected status (100–599). Gates the after-healthy seed hook. |
| `build.dockerfile` | no | `Dockerfile` | App Dockerfile for `sprout ci preview`. An empty `build: {}` takes the default. |
Expand Down
12 changes: 7 additions & 5 deletions e2e/harness/docker.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { dataVolumeName, sqliteVolumeName } from "@sprout/preview-env";
import {
dataVolumeName,
previewContainerName,
sqliteVolumeName,
} from "@sprout/preview-env";
import { run } from "./exec.ts";

export { dataVolumeName, sqliteVolumeName };
export { dataVolumeName, previewContainerName, sqliteVolumeName };

export function previewAppContainerName(slug: string, prId: number): string {
return `sprout-${slug}-pr-${prId}`;
}
export const previewAppContainerName = previewContainerName;

async function dockerText(args: string[]): Promise<string> {
const { stdout } = await run(["docker", ...args]);
Expand Down
5 changes: 3 additions & 2 deletions e2e/lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createApiClient } from "@sprout/api-client";
import { previewDbName } from "@sprout/preview-db";
import { describe, expect, test } from "bun:test";
import {
COMPOSE_E2E_ENV_PATH,
Expand Down Expand Up @@ -103,7 +104,7 @@ describe.skipIf(!enabled)("preview lifecycle", () => {
expect(env.has("PGUSER")).toBe(false);
expect(env.has("PGPASSWORD")).toBe(false);

const dbName = `sprout_${e2eConfig.slug}_pr${prId}`;
const dbName = previewDbName(e2eConfig.slug, prId);
expect(await companionRoleExists(dbName)).toBe(true);
} finally {
const torn = await client.v1.teardown.post({
Expand Down Expand Up @@ -146,7 +147,7 @@ describe.skipIf(!enabled)("preview lifecycle", () => {
expect(env.has("PGAPPUSER")).toBe(false);
expect(env.has("PGAPPPASSWORD")).toBe(false);

const dbName = `sprout_${e2eConfig.slug}_pr${prId}`;
const dbName = previewDbName(e2eConfig.slug, prId);
expect(await companionRoleExists(dbName)).toBe(false);
} finally {
const torn = await client.v1.teardown.post({
Expand Down
2 changes: 2 additions & 0 deletions packages/preview-db/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ export {
} from "./ensure-role.ts";
export {
COMPANION_ROLE_SUFFIX,
companionRoleLimitMessage,
companionRoleName,
deriveRestrictedPassword,
dropRestrictedRole,
ensureRestrictedRole,
PG_IDENT_MAX,
} from "./restricted-role.ts";
export { previewDbName } from "./preview-names.ts";
export {
isWorktreeInputError,
} from "./errors.ts";
Expand Down
3 changes: 3 additions & 0 deletions packages/preview-db/src/preview-names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function previewDbName(slug: string, prId: number): string {
return `sprout_${slug}_pr${prId}`;
}
12 changes: 12 additions & 0 deletions packages/preview-db/src/restricted-role.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { ensureDatabase } from "./catalog.ts";
import { assertSafeRole, ensureLoginRole } from "./ensure-role.ts";
import { dockerAvailable, startTempPostgres } from "./postgres-it.ts";
import {
companionRoleLimitMessage,
companionRoleName,
COMPANION_ROLE_SUFFIX,
deriveRestrictedPassword,
dropRestrictedRole,
ensureRestrictedRole,
PG_IDENT_MAX,
} from "./restricted-role.ts";
import { previewDbName } from "./preview-names.ts";

const hasDocker = await dockerAvailable();

Expand All @@ -21,6 +23,16 @@ describe("companionRoleName / deriveRestrictedPassword", () => {
);
});

test("limit message names the db and the identifier budget", () => {
expect(companionRoleLimitMessage("sprout_myapp_pr1")).toBe(
`companion role name exceeds Postgres identifier limit (${PG_IDENT_MAX}): sprout_myapp_pr1${COMPANION_ROLE_SUFFIX}`,
);
});

test("previewDbName builds the canonical database name", () => {
expect(previewDbName("widgets", 42)).toBe("sprout_widgets_pr42");
});

test("returns null for companion names longer than Postgres NAMEDATALEN", () => {
const tooLongDb = "a".repeat(PG_IDENT_MAX - COMPANION_ROLE_SUFFIX.length + 1);
expect(companionRoleName(tooLongDb)).toBeNull();
Expand Down
8 changes: 5 additions & 3 deletions packages/preview-db/src/restricted-role.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export function companionRoleName(dbName: string): string | null {
return role;
}

export function companionRoleLimitMessage(dbName: string): string {
return `companion role name exceeds Postgres identifier limit (${PG_IDENT_MAX}): ${dbName}${COMPANION_ROLE_SUFFIX}`;
}

export function deriveRestrictedPassword(
ownerPassword: string,
dbName: string,
Expand All @@ -30,9 +34,7 @@ export async function ensureRestrictedRole(
assertSafeRole(opts.dbName);
const role = companionRoleName(opts.dbName);
if (role === null) {
throw new Error(
`companion role name exceeds Postgres identifier limit (${PG_IDENT_MAX}): ${opts.dbName}${COMPANION_ROLE_SUFFIX}`,
);
throw new Error(companionRoleLimitMessage(opts.dbName));
}
assertSafeRole(role);
const password = deriveRestrictedPassword(opts.ownerPassword, opts.dbName);
Expand Down
24 changes: 24 additions & 0 deletions packages/preview-env/src/env-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,30 @@ export type PreviewEnvIssue =
priorKey: string;
};

export type PreviewEnvProviderIssue = {
code: "env_requires_provider";
key: CanonicalEnvKey;
home: DbProvider;
};

export function previewEnvIssueMessage(
path: string,
issue: PreviewEnvIssue | PreviewEnvProviderIssue,
): string {
switch (issue.code) {
case "unknown_env_key":
return `unknown key: ${path}.${issue.key}`;
case "empty_env_target":
return `${path}.${issue.key} is required`;
case "invalid_env_target":
return `${path}.${issue.key} is invalid`;
case "env_target_collision":
return `${path}: target collision: ${issue.target}`;
case "env_requires_provider":
return `${path}.${issue.key} requires db.provider ${issue.home}`;
}
}

export function parsePreviewEnvMap(
raw: Record<string, unknown> | undefined,
):
Expand Down
Loading
Loading