@caelo-cms/provisioning 0.10.49 → 0.10.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/caddy/Caddyfile.production +4 -0
  2. package/caddy/Caddyfile.staging +4 -0
  3. package/dist/adapter.d.ts +4 -6
  4. package/dist/adapter.d.ts.map +1 -1
  5. package/dist/admin-internal.d.ts +41 -0
  6. package/dist/admin-internal.d.ts.map +1 -0
  7. package/dist/admin-internal.js +169 -0
  8. package/dist/admin-internal.js.map +1 -0
  9. package/dist/agent-db.js +93 -20
  10. package/dist/aws-task-roles.d.ts +27 -0
  11. package/dist/aws-task-roles.d.ts.map +1 -0
  12. package/dist/aws-task-roles.js +35 -0
  13. package/dist/aws-task-roles.js.map +1 -0
  14. package/dist/caddy-redirects.d.ts +15 -0
  15. package/dist/caddy-redirects.d.ts.map +1 -0
  16. package/dist/caddy-redirects.js +65 -0
  17. package/dist/caddy-redirects.js.map +1 -0
  18. package/dist/caddy.d.ts +22 -2
  19. package/dist/caddy.d.ts.map +1 -1
  20. package/dist/caddy.js +66 -17
  21. package/dist/caddy.js.map +1 -1
  22. package/dist/cli-routing.d.ts.map +1 -1
  23. package/dist/cli-routing.js +7 -0
  24. package/dist/cli-routing.js.map +1 -1
  25. package/dist/cli.js +282 -255
  26. package/dist/cli.js.map +1 -1
  27. package/dist/compose.d.ts +54 -2
  28. package/dist/compose.d.ts.map +1 -1
  29. package/dist/compose.js +88 -34
  30. package/dist/compose.js.map +1 -1
  31. package/dist/deployed-release.d.ts +3 -2
  32. package/dist/deployed-release.d.ts.map +1 -1
  33. package/dist/deployed-release.js +12 -5
  34. package/dist/deployed-release.js.map +1 -1
  35. package/dist/gateway-credentials.d.ts.map +1 -1
  36. package/dist/gateway-credentials.js +6 -3
  37. package/dist/gateway-credentials.js.map +1 -1
  38. package/dist/gcloud.d.ts +2 -2
  39. package/dist/gcloud.d.ts.map +1 -1
  40. package/dist/gcloud.js +3 -3
  41. package/dist/gcloud.js.map +1 -1
  42. package/dist/gcp-names.d.ts +12 -0
  43. package/dist/gcp-names.d.ts.map +1 -1
  44. package/dist/gcp-names.js +21 -0
  45. package/dist/gcp-names.js.map +1 -1
  46. package/dist/gcp-url-map.d.ts +27 -0
  47. package/dist/gcp-url-map.d.ts.map +1 -0
  48. package/dist/gcp-url-map.js +62 -0
  49. package/dist/gcp-url-map.js.map +1 -0
  50. package/dist/index.d.ts +0 -1
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +0 -1
  53. package/dist/index.js.map +1 -1
  54. package/dist/infra-contract.d.ts +20 -1
  55. package/dist/infra-contract.d.ts.map +1 -1
  56. package/dist/infra-contract.js +44 -1
  57. package/dist/infra-contract.js.map +1 -1
  58. package/dist/owner-reset.d.ts +37 -0
  59. package/dist/owner-reset.d.ts.map +1 -0
  60. package/dist/owner-reset.js +80 -0
  61. package/dist/owner-reset.js.map +1 -0
  62. package/dist/public-role-isolation.d.ts +19 -0
  63. package/dist/public-role-isolation.d.ts.map +1 -0
  64. package/dist/public-role-isolation.js +69 -0
  65. package/dist/public-role-isolation.js.map +1 -0
  66. package/dist/release-contract.d.ts.map +1 -1
  67. package/dist/release-contract.js +3 -0
  68. package/dist/release-contract.js.map +1 -1
  69. package/dist/release-manifest.d.ts.map +1 -1
  70. package/dist/release-manifest.js +2 -1
  71. package/dist/release-manifest.js.map +1 -1
  72. package/dist/self-hosted-backup.d.ts +86 -0
  73. package/dist/self-hosted-backup.d.ts.map +1 -0
  74. package/dist/self-hosted-backup.js +350 -0
  75. package/dist/self-hosted-backup.js.map +1 -0
  76. package/dist/self-hosted-release.d.ts +12 -3
  77. package/dist/self-hosted-release.d.ts.map +1 -1
  78. package/dist/self-hosted-release.js +6 -4
  79. package/dist/self-hosted-release.js.map +1 -1
  80. package/dist/self-hosted-start.d.ts +50 -0
  81. package/dist/self-hosted-start.d.ts.map +1 -0
  82. package/dist/self-hosted-start.js +59 -0
  83. package/dist/self-hosted-start.js.map +1 -0
  84. package/dist/self-hosted-upgrade.d.ts +30 -0
  85. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  86. package/dist/self-hosted-upgrade.js +29 -7
  87. package/dist/self-hosted-upgrade.js.map +1 -1
  88. package/dist/stack-contract.d.ts +9 -10
  89. package/dist/stack-contract.d.ts.map +1 -1
  90. package/dist/stack-contract.js +21 -10
  91. package/dist/stack-contract.js.map +1 -1
  92. package/dist/staging-admin.d.ts +12 -20
  93. package/dist/staging-admin.d.ts.map +1 -1
  94. package/dist/staging-admin.js +7 -159
  95. package/dist/staging-admin.js.map +1 -1
  96. package/dist/tar-entries.d.ts +14 -0
  97. package/dist/tar-entries.d.ts.map +1 -0
  98. package/dist/tar-entries.js +104 -0
  99. package/dist/tar-entries.js.map +1 -0
  100. package/dist/upgrade/converge.d.ts +2 -2
  101. package/dist/upgrade/converge.d.ts.map +1 -1
  102. package/dist/upgrade/converge.js +44 -2
  103. package/dist/upgrade/converge.js.map +1 -1
  104. package/dist/upgrader-assess.d.ts.map +1 -1
  105. package/dist/upgrader-assess.js +2 -1
  106. package/dist/upgrader-assess.js.map +1 -1
  107. package/dist/url-map-redirects-access.d.ts +15 -0
  108. package/dist/url-map-redirects-access.d.ts.map +1 -0
  109. package/dist/url-map-redirects-access.js +81 -0
  110. package/dist/url-map-redirects-access.js.map +1 -0
  111. package/dist/wizard.d.ts.map +1 -1
  112. package/dist/wizard.js +3 -1
  113. package/dist/wizard.js.map +1 -1
  114. package/dist/wizards/gcp-cost.js +1 -1
  115. package/dist/wizards/gcp.d.ts.map +1 -1
  116. package/dist/wizards/gcp.js +45 -0
  117. package/dist/wizards/gcp.js.map +1 -1
  118. package/package.json +2 -2
  119. package/src/adapter.ts +4 -6
  120. package/src/admin-internal.ts +212 -0
  121. package/src/admin-memory.test.ts +2 -2
  122. package/src/aws-task-roles.ts +58 -0
  123. package/src/caddy-redirects.docker.test.ts +255 -0
  124. package/src/caddy-redirects.test.ts +115 -0
  125. package/src/caddy-redirects.ts +68 -0
  126. package/src/caddy-validate.test.ts +119 -0
  127. package/src/caddy.test.ts +108 -0
  128. package/src/caddy.ts +89 -20
  129. package/src/checkpoint-secrets.test.ts +0 -1
  130. package/src/cli-routing.test.ts +12 -0
  131. package/src/cli-routing.ts +7 -0
  132. package/src/cli.ts +340 -288
  133. package/src/compose.ts +108 -36
  134. package/src/deployed-release.test.ts +22 -4
  135. package/src/deployed-release.ts +14 -7
  136. package/src/gateway-credentials.ts +6 -3
  137. package/src/gateway-env-isolation.test.ts +212 -0
  138. package/src/gcloud.ts +5 -3
  139. package/src/gcp-names.ts +23 -0
  140. package/src/gcp-url-map.test.ts +150 -0
  141. package/src/gcp-url-map.ts +81 -0
  142. package/src/index.test.ts +24 -152
  143. package/src/index.ts +0 -9
  144. package/src/infra-contract.test.ts +40 -0
  145. package/src/infra-contract.ts +47 -1
  146. package/src/owner-reset.test.ts +165 -0
  147. package/src/owner-reset.ts +106 -0
  148. package/src/public-role-isolation.test.ts +89 -0
  149. package/src/public-role-isolation.ts +83 -0
  150. package/src/release-contract.ts +3 -0
  151. package/src/release-manifest.test.ts +12 -0
  152. package/src/release-manifest.ts +2 -1
  153. package/src/runtime-secrets.test.ts +25 -8
  154. package/src/self-hosted-backup.test.ts +344 -0
  155. package/src/self-hosted-backup.ts +480 -0
  156. package/src/self-hosted-compose.test.ts +88 -4
  157. package/src/self-hosted-release.ts +22 -7
  158. package/src/self-hosted-start.test.ts +68 -0
  159. package/src/self-hosted-start.ts +123 -0
  160. package/src/self-hosted-upgrade.ts +35 -8
  161. package/src/stack-contract.test.ts +11 -20
  162. package/src/stack-contract.ts +25 -16
  163. package/src/stack-converge-secrets.test.ts +45 -10
  164. package/src/stack-converge.test.ts +19 -10
  165. package/src/staging-admin.ts +18 -181
  166. package/src/tar-entries.test.ts +52 -0
  167. package/src/tar-entries.ts +116 -0
  168. package/src/upgrade/converge.ts +51 -4
  169. package/src/upgrade/upgrade.test.ts +78 -1
  170. package/src/upgrader-assess.test.ts +22 -0
  171. package/src/upgrader-assess.ts +2 -1
  172. package/src/url-map-redirects-access.test.ts +88 -0
  173. package/src/url-map-redirects-access.ts +89 -0
  174. package/src/wizard.ts +3 -1
  175. package/src/wizards/gcp-cost.ts +1 -1
  176. package/src/wizards/gcp.ts +48 -0
  177. package/stacks/aws/README.md +1 -1
  178. package/stacks/aws/index.ts +89 -45
  179. package/stacks/azure/README.md +1 -1
  180. package/stacks/azure/index.ts +21 -14
  181. package/stacks/gcp/README.md +1 -1
  182. package/stacks/gcp/index.ts +26 -22
  183. package/stacks/gcp-firebase/index.ts +7 -3
  184. package/stacks/self-hosted/Pulumi.yaml +2 -2
  185. package/stacks/self-hosted/README.md +3 -3
  186. package/stacks/self-hosted/index.ts +45 -22
  187. package/dist/redirects-emit.d.ts +0 -52
  188. package/dist/redirects-emit.d.ts.map +0 -1
  189. package/dist/redirects-emit.js +0 -111
  190. package/dist/redirects-emit.js.map +0 -1
  191. package/src/redirects-emit.ts +0 -175
@@ -0,0 +1,68 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import type { CommandResult, HostRunner } from "./self-hosted-release.js";
5
+ import { startSelfHosted } from "./self-hosted-start.js";
6
+
7
+ const DIR = "/srv/install/.caelo";
8
+
9
+ /** A runner that records every command (minus the `docker compose -f <file>` prefix). */
10
+ function fakeRunner(fail?: (cmd: string) => boolean): HostRunner & { calls: string[] } {
11
+ const calls: string[] = [];
12
+ return {
13
+ calls,
14
+ async run(argv): Promise<CommandResult> {
15
+ const cmd = argv.join(" ").replace(`docker compose -f ${DIR}/docker-compose.yml `, "");
16
+ calls.push(cmd);
17
+ return { exitCode: fail?.(cmd) ? 1 : 0, stdout: "", stderr: fail?.(cmd) ? "boom" : "" };
18
+ },
19
+ };
20
+ }
21
+
22
+ const MIGRATE =
23
+ "run --rm --no-deps -T caelo-admin bun --bun /app/packages/migrations/src/migrate.ts";
24
+
25
+ describe("startSelfHosted (#707 — one command ends with a serving install)", () => {
26
+ it("pulls, starts Postgres, migrates, starts everything, then health-checks both services", async () => {
27
+ const runner = fakeRunner();
28
+ const r = await startSelfHosted({ caeloDir: DIR, runner, log: () => {} });
29
+ expect(r).toEqual({ ok: true });
30
+ expect(runner.calls.map((c) => c.replace(/ -e .*/, " -e <probe>"))).toEqual([
31
+ "pull",
32
+ "up -d --wait postgres",
33
+ `${MIGRATE} admin`,
34
+ `${MIGRATE} public`,
35
+ "up -d --remove-orphans",
36
+ "exec -T caelo-admin bun -e <probe>",
37
+ "exec -T caelo-gateway bun -e <probe>",
38
+ "exec -T caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile",
39
+ ]);
40
+ });
41
+
42
+ it("stops at a failed migration — nothing else is started", async () => {
43
+ const runner = fakeRunner((c) => c.endsWith("migrate.ts admin"));
44
+ const r = await startSelfHosted({ caeloDir: DIR, runner, log: () => {} });
45
+ expect(r.ok).toBe(false);
46
+ if (!r.ok) {
47
+ expect(r.stage).toBe("migrate");
48
+ expect(r.error).toContain("admin migrations failed");
49
+ }
50
+ expect(runner.calls).not.toContain("up -d --remove-orphans");
51
+ });
52
+
53
+ it("reports an unhealthy admin with where to look", async () => {
54
+ const runner = fakeRunner((c) => c.startsWith("exec -T caelo-admin"));
55
+ const r = await startSelfHosted({
56
+ caeloDir: DIR,
57
+ runner,
58
+ log: () => {},
59
+ healthAttempts: 2,
60
+ sleep: async () => {},
61
+ });
62
+ expect(r.ok).toBe(false);
63
+ if (!r.ok) {
64
+ expect(r.stage).toBe("health");
65
+ expect(r.error).toContain("logs caelo-admin");
66
+ }
67
+ });
68
+ });
@@ -0,0 +1,123 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Start (or converge) a self-hosted Compose install — what `init` and `up`
5
+ * run after writing `.caelo/`, so one command ends with a serving install
6
+ * (CLAUDE.md §11.C, issue #707) instead of printing steps to copy.
7
+ *
8
+ * Order, and why:
9
+ * 1. pull every image, with the progress on the terminal (the admin image
10
+ * is large; a silent multi-minute step reads as a hang);
11
+ * 2. start Postgres and wait for its health check — on a fresh volume its
12
+ * first start runs bootstrap.sh (roles + databases);
13
+ * 3. apply the migrations with the install's admin image;
14
+ * 4. start everything (`--remove-orphans` drops services an older Compose
15
+ * file had, e.g. the pgBackRest sidecar);
16
+ * 5. health-check the admin (`/_caelo/health` proves the DB pool and its
17
+ * role) and the gateway;
18
+ * 6. reload Caddy — Compose does not restart a running Caddy when only
19
+ * its bind-mounted Caddyfile changed, so a re-run `up` would otherwise
20
+ * keep serving the old vhosts.
21
+ * Every step converges, so a re-run after a failure picks up where it
22
+ * stopped.
23
+ */
24
+
25
+ import type { HostRunner } from "./self-hosted-release.js";
26
+ import { compose, runMigrations, waitHealthy } from "./self-hosted-upgrade.js";
27
+
28
+ export interface StartDeps {
29
+ readonly caeloDir: string;
30
+ readonly runner: HostRunner;
31
+ readonly log: (message: string) => void;
32
+ /** Health probe attempts per service and the pause between them. */
33
+ readonly healthAttempts?: number;
34
+ readonly healthIntervalMs?: number;
35
+ readonly sleep?: (ms: number) => Promise<void>;
36
+ }
37
+
38
+ export type StartResult =
39
+ | { readonly ok: true }
40
+ | { readonly ok: false; readonly stage: StartStage; readonly error: string };
41
+
42
+ type StartStage = "pull" | "postgres" | "migrate" | "start" | "health" | "caddy";
43
+
44
+ /** Bring the install's Compose stack up and prove it serves. Never throws for an expected failure. */
45
+ export async function startSelfHosted(deps: StartDeps): Promise<StartResult> {
46
+ const { caeloDir, runner, log } = deps;
47
+ log("Pulling the images …");
48
+ const pull = await runner.run(compose(caeloDir, "pull"), { inherit: true });
49
+ if (pull.exitCode !== 0) {
50
+ return fail("pull", "docker compose pull failed (see above). Check the network and re-run.");
51
+ }
52
+
53
+ log("Starting Postgres …");
54
+ const pg = await runner.run(compose(caeloDir, "up", "-d", "--wait", "postgres"), {
55
+ inherit: true,
56
+ });
57
+ if (pg.exitCode !== 0) {
58
+ return fail(
59
+ "postgres",
60
+ `Postgres did not become healthy (see above; \`docker compose -f ${caeloDir}/docker-compose.yml logs postgres\`).`,
61
+ );
62
+ }
63
+
64
+ log("Applying the database migrations …");
65
+ const migrated = await runMigrations(caeloDir, runner);
66
+ if (!migrated.ok) return fail("migrate", migrated.error);
67
+
68
+ log("Starting Caddy, the admin and the gateway …");
69
+ const up = await runner.run(compose(caeloDir, "up", "-d", "--remove-orphans"), {
70
+ inherit: true,
71
+ });
72
+ if (up.exitCode !== 0) return fail("start", "docker compose up failed (see above).");
73
+
74
+ for (const service of ["admin", "gateway"] as const) {
75
+ log(`Waiting for the ${service} to answer its health check …`);
76
+ const healthy = await waitHealthy(service, caeloDir, {
77
+ runner,
78
+ ...(deps.sleep ? { sleep: deps.sleep } : {}),
79
+ ...(deps.healthAttempts !== undefined ? { healthAttempts: deps.healthAttempts } : {}),
80
+ ...(deps.healthIntervalMs !== undefined ? { healthIntervalMs: deps.healthIntervalMs } : {}),
81
+ });
82
+ if (!healthy.ok) {
83
+ return fail(
84
+ "health",
85
+ `The ${service} is not healthy: ${healthy.error}. See \`docker compose -f ${caeloDir}/docker-compose.yml logs caelo-${service}\`.`,
86
+ );
87
+ }
88
+ }
89
+ log("Reloading Caddy …");
90
+ const reloaded = await reloadCaddy(caeloDir, runner);
91
+ if (!reloaded.ok) return fail("caddy", reloaded.error);
92
+ return { ok: true };
93
+ }
94
+
95
+ /** `caddy reload` in the running Caddy container with the current Caddyfile. */
96
+ export async function reloadCaddy(
97
+ caeloDir: string,
98
+ runner: HostRunner,
99
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
100
+ const r = await runner.run(
101
+ compose(
102
+ caeloDir,
103
+ "exec",
104
+ "-T",
105
+ "caddy",
106
+ "caddy",
107
+ "reload",
108
+ "--config",
109
+ "/etc/caddy/Caddyfile",
110
+ "--adapter",
111
+ "caddyfile",
112
+ ),
113
+ );
114
+ if (r.exitCode === 0) return { ok: true };
115
+ return {
116
+ ok: false,
117
+ error: `caddy reload failed: ${r.stderr.trim().split("\n").slice(-3).join("\n")}`,
118
+ };
119
+ }
120
+
121
+ function fail(stage: StartStage, error: string): StartResult {
122
+ return { ok: false, stage, error };
123
+ }
@@ -196,7 +196,7 @@ export function composePath(caeloDir: string): string {
196
196
  }
197
197
 
198
198
  /** `docker compose -f <file>` — every Compose call targets the install's file explicitly. */
199
- function compose(caeloDir: string, ...args: string[]): string[] {
199
+ export function compose(caeloDir: string, ...args: string[]): string[] {
200
200
  return ["docker", "compose", "-f", composePath(caeloDir), ...args];
201
201
  }
202
202
 
@@ -415,12 +415,35 @@ export async function backupDatabases(
415
415
  };
416
416
  }
417
417
  const dir = join(caeloDir, "backups", `pre-upgrade-${at.toISOString().replace(/[:.]/g, "-")}`);
418
+ const dumped = await dumpDatabases(caeloDir, runner, dir);
419
+ if (!dumped.ok) return { ok: false, error: `${dumped.error} The upgrade did not start.` };
420
+ return { ok: true, dir };
421
+ }
422
+
423
+ /** The files {@link dumpDatabases} writes, and what each holds. */
424
+ export const DATABASE_DUMPS = {
425
+ /** The cluster's roles (reference only — restores re-create them from config.json). */
426
+ globals: "globals.sql",
427
+ cms_admin: "cms_admin.dump",
428
+ cms_public: "cms_public.dump",
429
+ } as const;
430
+
431
+ /**
432
+ * pg_dump both databases (custom format) plus the cluster's roles into
433
+ * `dir` (created mode 700) from the running Postgres container. Every dump
434
+ * must exit 0 and be non-empty.
435
+ */
436
+ export async function dumpDatabases(
437
+ caeloDir: string,
438
+ runner: HostRunner,
439
+ dir: string,
440
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
418
441
  mkdirSync(dir, { recursive: true, mode: 0o700 });
419
442
  chmodSync(dir, 0o700);
420
443
  const dumps: [string, string[]][] = [
421
- ["globals.sql", ["pg_dumpall", "-U", "caelo", "--globals-only"]],
422
- ["cms_admin.dump", ["pg_dump", "-U", "caelo", "-Fc", "cms_admin"]],
423
- ["cms_public.dump", ["pg_dump", "-U", "caelo", "-Fc", "cms_public"]],
444
+ [DATABASE_DUMPS.globals, ["pg_dumpall", "-U", "caelo", "--globals-only"]],
445
+ [DATABASE_DUMPS.cms_admin, ["pg_dump", "-U", "caelo", "-Fc", "cms_admin"]],
446
+ [DATABASE_DUMPS.cms_public, ["pg_dump", "-U", "caelo", "-Fc", "cms_public"]],
424
447
  ];
425
448
  for (const [name, cmd] of dumps) {
426
449
  const out = join(dir, name);
@@ -430,12 +453,12 @@ export async function backupDatabases(
430
453
  if (r.exitCode !== 0 || fileSize(out) === 0) {
431
454
  return {
432
455
  ok: false,
433
- error: `Backup failed (${cmd.join(" ")} → ${out}): ${tail(r.stderr) || "empty dump"}. The upgrade did not start.`,
456
+ error: `Backup failed (${cmd.join(" ")} → ${out}): ${tail(r.stderr) || "empty dump"}.`,
434
457
  };
435
458
  }
436
459
  chmodSync(out, 0o600);
437
460
  }
438
- return { ok: true, dir };
461
+ return { ok: true };
439
462
  }
440
463
 
441
464
  /**
@@ -483,10 +506,14 @@ const HEALTH_PROBE: Record<"admin" | "gateway", string> = {
483
506
  'const r=await fetch("http://127.0.0.1:"+(process.env.PORT||process.env.GATEWAY_PORT||"8090")+"/healthz").catch(()=>null);process.exit(r&&r.ok?0:1)',
484
507
  };
485
508
 
486
- async function waitHealthy(
509
+ /**
510
+ * Probe `service` inside its container until it answers healthy, or give up
511
+ * after `healthAttempts` tries (default 45, 2 s apart).
512
+ */
513
+ export async function waitHealthy(
487
514
  service: "admin" | "gateway",
488
515
  caeloDir: string,
489
- deps: SelfHostedUpgradeDeps,
516
+ deps: Pick<SelfHostedUpgradeDeps, "runner" | "sleep" | "healthAttempts" | "healthIntervalMs">,
490
517
  ): Promise<{ ok: true } | { ok: false; error: string }> {
491
518
  const attempts = deps.healthAttempts ?? 45;
492
519
  const interval = deps.healthIntervalMs ?? 2000;
@@ -49,12 +49,11 @@ const ADMIN_SECRETS = {
49
49
  };
50
50
 
51
51
  describe("databaseUrls", () => {
52
- it("builds password-less URLs for the four role/database pairs", () => {
52
+ it("builds password-less URLs for the three role/database pairs", () => {
53
53
  expect(databaseUrls("10.20.0.3")).toEqual({
54
54
  admin: "postgresql://admin_role@10.20.0.3:5432/cms_admin?sslmode=require",
55
55
  publicAdmin: "postgresql://admin_role@10.20.0.3:5432/cms_public?sslmode=require",
56
56
  public: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
57
- gateway: "postgresql://gateway_role@10.20.0.3:5432/cms_admin?sslmode=require",
58
57
  });
59
58
  });
60
59
  });
@@ -154,15 +153,13 @@ describe.each([
154
153
  describe("gatewayEnvContract", () => {
155
154
  const gateway = gatewayEnvContract({ ...base, provider: "gcp" });
156
155
 
157
- it("carries its two own logins and none of the admin's database or app secrets", () => {
156
+ it("carries its one own login and none of the admin's database or app secrets (#635)", () => {
158
157
  expect(asRecord(gateway)).toEqual({
159
158
  CAELO_PROVIDER: "gcp",
160
159
  CAELO_ENV: "production",
161
160
  MEDIA_STORAGE_URL: "gs://acme-caelo-production-media",
162
161
  PUBLIC_DATABASE_URL: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
163
- GATEWAY_DATABASE_URL: "postgresql://gateway_role@10.20.0.3:5432/cms_admin?sslmode=require",
164
162
  PUBLIC_DATABASE_PASSWORD: "secret:caelo-production-public-role-password:latest",
165
- GATEWAY_DATABASE_PASSWORD: "secret:caelo-production-gateway-role-password:latest",
166
163
  CAELO_STAGING_GATE_SECRET: "secret:caelo-production-staging-gate-secret:latest",
167
164
  });
168
165
  });
@@ -188,11 +185,7 @@ describe("gatewayEnvContract", () => {
188
185
  });
189
186
 
190
187
  it("the gateway's run SA reads no admin-only secret (admin password, KEK, internal, tool approval)", () => {
191
- expect(serviceSecrets("gateway")).toEqual([
192
- "gateway-role-password",
193
- "public-role-password",
194
- "staging-gate-secret",
195
- ]);
188
+ expect(serviceSecrets("gateway")).toEqual(["public-role-password", "staging-gate-secret"]);
196
189
  for (const v of gateway) {
197
190
  expect(v.name).not.toMatch(/KEK|INTERNAL|TOOL_APPROVAL|PUBLIC_ADMIN/);
198
191
  }
@@ -280,11 +273,6 @@ describe("runtime secrets", () => {
280
273
  secret: "staging-gate-secret",
281
274
  stackResource: "staging-gate-secret-binding",
282
275
  },
283
- {
284
- service: "gateway",
285
- secret: "gateway-role-password",
286
- stackResource: "gateway-gateway-role-password-binding",
287
- },
288
276
  {
289
277
  service: "gateway",
290
278
  secret: "public-role-password",
@@ -299,7 +287,11 @@ describe("runtime secrets", () => {
299
287
  });
300
288
 
301
289
  it("every CLI-generated secret is read by some service", () => {
302
- for (const s of CLI_GENERATED_SECRETS) {
290
+ // gateway-role-password is read by no service since #635: it only keeps
291
+ // the legacy gateway_role's password set until the contract release
292
+ // drops the role (the previous release's gateway logs in with it while
293
+ // an upgrade rolls).
294
+ for (const s of CLI_GENERATED_SECRETS.filter((x) => x !== "gateway-role-password")) {
303
295
  expect([...serviceSecrets("admin"), ...serviceSecrets("gateway")]).toContain(s);
304
296
  }
305
297
  });
@@ -311,10 +303,10 @@ describe("self-hosted compose", () => {
311
303
  domain: "acme.com",
312
304
  postgresPassword: "p",
313
305
  rolePasswords: { admin: "a", public: "b", gateway: "g" },
314
- minioRootUser: "u",
315
- minioRootPassword: "m",
316
306
  caeloSecretKek: "k",
317
307
  diskSize: "10G",
308
+ ports: { http: 80, https: 443 },
309
+ project: "caelo-example-com",
318
310
  images: {
319
311
  mode: "release" as const,
320
312
  admin: `ghcr.io/caelo-cms/admin@sha256:${"a".repeat(64)}`,
@@ -351,13 +343,12 @@ describe("stackIamInvariants", () => {
351
343
  }
352
344
  });
353
345
 
354
- it("grants the gateway SA only its two role passwords, the staging gate key + telemetry roles", () => {
346
+ it("grants the gateway SA only its own role password, the staging gate key + telemetry roles", () => {
355
347
  for (const provider of ["gcp", "gcp-firebase"] as const) {
356
348
  const gateway = stackIamInvariants(provider).filter((i) => i.member === "gateway-sa");
357
349
  expect(gateway.map((i) => `${i.role} ${JSON.stringify(i.target)}`).sort()).toEqual([
358
350
  'roles/logging.logWriter {"kind":"project"}',
359
351
  'roles/monitoring.metricWriter {"kind":"project"}',
360
- 'roles/secretmanager.secretAccessor {"kind":"secret","name":"gateway-role-password"}',
361
352
  'roles/secretmanager.secretAccessor {"kind":"secret","name":"public-role-password"}',
362
353
  'roles/secretmanager.secretAccessor {"kind":"secret","name":"staging-gate-secret"}',
363
354
  ]);
@@ -86,14 +86,12 @@ export interface DatabaseUrls<V = string> {
86
86
  readonly admin: V;
87
87
  /** admin_role on cms_public — the admin's second pool (DDL, migrations). */
88
88
  readonly publicAdmin: V;
89
- /** public_role on cms_public — the gateway's visitor-write pool. */
90
- readonly public: V;
91
89
  /**
92
- * gateway_role on cms_admin — the gateway's narrow pool (#613): its
93
- * settings, rate limits, captcha, request log, plugin op audit and the
94
- * plugin registry, read-mostly (migration 0248).
90
+ * public_role on cms_public — the gateway's only pool. The gateway has no
91
+ * cms_admin login (#635, CLAUDE.md §2): what it needs from authoring
92
+ * state the admin publishes into cms_public.
95
93
  */
96
- readonly gateway: V;
94
+ readonly public: V;
97
95
  }
98
96
 
99
97
  /** Port Cloud SQL Postgres listens on (private IP). */
@@ -111,7 +109,6 @@ export function databaseUrls(host: string): DatabaseUrls {
111
109
  admin: url("admin_role", "cms_admin"),
112
110
  publicAdmin: url("admin_role", "cms_public"),
113
111
  public: url("public_role", "cms_public"),
114
- gateway: url("gateway_role", "cms_admin"),
115
112
  };
116
113
  }
117
114
 
@@ -251,8 +248,8 @@ export type DatabaseRole = "admin_role" | "public_role" | "gateway_role";
251
248
 
252
249
  /**
253
250
  * Each database role's password secret — one per role (#613), so the
254
- * gateway's SA, which reads only the public_role and gateway_role
255
- * passwords, can never read admin_role's. `postgres-password` keeps its
251
+ * gateway's SA, which reads only the public_role password, can never read
252
+ * admin_role's (or, since #635, gateway_role's). `postgres-password` keeps its
256
253
  * name: it has always been admin_role's (and, before #613, shared with
257
254
  * public_role). `rotate-secret` changes exactly the role(s) listed here.
258
255
  */
@@ -306,9 +303,8 @@ export const SERVICE_SECRET_ENV: Readonly<
306
303
  CAELO_STAGING_GATE_SECRET: "staging-gate-secret",
307
304
  },
308
305
  gateway: {
309
- // CLAUDE.md §2: never an admin_role credential (#613). gateway_role on
310
- // cms_admin + public_role on cms_public, each with its own password.
311
- GATEWAY_DATABASE_PASSWORD: "gateway-role-password",
306
+ // CLAUDE.md §2: no cms_admin credential of any role (#613, #635).
307
+ // public_role on cms_public is the gateway's only login.
312
308
  PUBLIC_DATABASE_PASSWORD: "public-role-password",
313
309
  // #650 — verifies staging-host requests (api-gateway staging-proxy.ts).
314
310
  CAELO_STAGING_GATE_SECRET: "staging-gate-secret",
@@ -323,12 +319,20 @@ export function serviceSecrets(service: CloudRunSlug): RuntimeSecret[] {
323
319
  /**
324
320
  * Env vars an older stack set that the contract no longer gives a service;
325
321
  * upgrade removes them. The gateway never used the KEK, and its run SA
326
- * cannot read it. Its admin_role pool went with #613 — the gateway refuses
327
- * to boot while either var is set (apps/api-gateway `gatewayDatabaseUrls`).
322
+ * cannot read it. Its admin_role pool went with #613 and its gateway_role
323
+ * pool with #635 — the gateway refuses to boot while any cms_admin
324
+ * credential is in its env (apps/api-gateway `adminDatabaseAccessInEnv`).
328
325
  */
329
326
  export const RETIRED_SERVICE_ENV: Readonly<Record<CloudRunSlug, readonly string[]>> = {
330
327
  admin: [],
331
- gateway: ["CAELO_SECRET_KEK", "ADMIN_DATABASE_URL", "ADMIN_DATABASE_PASSWORD"],
328
+ gateway: [
329
+ "CAELO_SECRET_KEK",
330
+ "ADMIN_DATABASE_URL",
331
+ "ADMIN_DATABASE_PASSWORD",
332
+ // #635 — its gateway_role login on cms_admin is gone too.
333
+ "GATEWAY_DATABASE_URL",
334
+ "GATEWAY_DATABASE_PASSWORD",
335
+ ],
332
336
  };
333
337
 
334
338
  function secretEnv(service: CloudRunSlug, env: string): CloudRunEnvVar[] {
@@ -362,7 +366,6 @@ export function gatewayEnvContract<V>(inputs: RuntimeEnvInputs<V>): CloudRunEnvV
362
366
  return [
363
367
  ...commonEnv(inputs),
364
368
  { name: "PUBLIC_DATABASE_URL", value: inputs.databaseUrls.public },
365
- { name: "GATEWAY_DATABASE_URL", value: inputs.databaseUrls.gateway },
366
369
  ...secretEnv("gateway", inputs.env),
367
370
  ];
368
371
  }
@@ -679,6 +682,12 @@ export const RETIRED_IAM_BINDINGS: readonly RetiredIamBinding[] = [
679
682
  target: { kind: "secret", name: "postgres-password" },
680
683
  why: "the gateway no longer logs in as admin_role and must not be able to read its password (CLAUDE.md §2)",
681
684
  },
685
+ {
686
+ role: SECRET_ACCESSOR,
687
+ member: "gateway-sa",
688
+ target: { kind: "secret", name: "gateway-role-password" },
689
+ why: "the gateway has no cms_admin login at all and must not be able to read gateway_role's password (CLAUDE.md §2, #635)",
690
+ },
682
691
  ];
683
692
 
684
693
  /** Every IAM binding upgrade ensures on an install of `provider`. */
@@ -38,12 +38,14 @@ const SECRET_VALUES: Record<string, string> = {
38
38
  "caelo-production-gateway-role-password": "g".repeat(64),
39
39
  };
40
40
 
41
- /** Env vars that would hand the gateway an admin_role credential (apps/api-gateway ADMIN_CREDENTIAL_ENV). */
41
+ /** Env vars that would hand the gateway a cms_admin credential (a subset of what apps/api-gateway adminDatabaseAccessInEnv refuses). */
42
42
  const ADMIN_CREDENTIAL_ENV = [
43
43
  "ADMIN_DATABASE_URL",
44
44
  "ADMIN_DATABASE_PASSWORD",
45
45
  "PUBLIC_ADMIN_DATABASE_URL",
46
46
  "PUBLIC_ADMIN_DATABASE_PASSWORD",
47
+ "GATEWAY_DATABASE_URL",
48
+ "GATEWAY_DATABASE_PASSWORD",
47
49
  ];
48
50
 
49
51
  /** Split a gcloud list flag value, honouring the `^<delim>^` escape. */
@@ -122,16 +124,15 @@ function credentials(url: string | undefined): string {
122
124
  }
123
125
 
124
126
  /**
125
- * The gateway's process env after the upgrade: gateway_role on cms_admin and
126
- * public_role on cms_public, each with its own secret, and no admin_role
127
- * credential at all (the gateway refuses to boot with one).
127
+ * The gateway's process env after the upgrade: public_role on cms_public
128
+ * with its own secret, and no cms_admin credential of any role at all
129
+ * (#613, #635 — the gateway refuses to boot with one).
128
130
  */
129
131
  function expectGatewayOnItsOwnLogins(env: Record<string, string>): void {
130
132
  for (const name of ADMIN_CREDENTIAL_ENV) expect(Object.keys(env)).not.toContain(name);
131
133
  expect(Object.values(env).join("\n")).not.toContain(PW);
132
- expect(credentials(databaseUrlFromEnv(["GATEWAY_DATABASE_URL"], env))).toBe(
133
- `gateway_role:${"g".repeat(64)}@${HOST}:5432/cms_admin?sslmode=require`,
134
- );
134
+ expect(Object.values(env).join("\n")).not.toContain("g".repeat(64));
135
+ expect(Object.values(env).join("\n")).not.toContain("cms_admin");
135
136
  expect(credentials(databaseUrlFromEnv(["PUBLIC_DATABASE_URL"], env))).toBe(
136
137
  `public_role:${"p".repeat(64)}@${HOST}:5432/cms_public?sslmode=require`,
137
138
  );
@@ -331,7 +332,7 @@ function v01035Install() {
331
332
  }
332
333
 
333
334
  describe("upgrading a v0.10.35 install: the gateway leaves admin_role (#613)", () => {
334
- it("drops the admin_role credential and adds the gateway's own logins in one revision", () => {
335
+ it("drops the admin_role credential and adds the gateway's own login in one revision", () => {
335
336
  const services = v01035Install();
336
337
  const plan = upgrade(services);
337
338
  expect(services.gateway.revisions).toHaveLength(2);
@@ -343,8 +344,6 @@ describe("upgrading a v0.10.35 install: the gateway leaves admin_role (#613)", (
343
344
  "ADMIN_DATABASE_URL",
344
345
  // #650 — the staging gate key arrives in the same roll.
345
346
  "CAELO_STAGING_GATE_SECRET",
346
- "GATEWAY_DATABASE_PASSWORD",
347
- "GATEWAY_DATABASE_URL",
348
347
  "PUBLIC_DATABASE_PASSWORD",
349
348
  ]);
350
349
  });
@@ -374,6 +373,42 @@ describe("upgrading a v0.10.35 install: the gateway leaves admin_role (#613)", (
374
373
  });
375
374
  });
376
375
 
376
+ /**
377
+ * A v0.10.50 install (#613, before #635): the gateway on gateway_role's
378
+ * cms_admin pool next to public_role's.
379
+ */
380
+ function v01050Install() {
381
+ const services = v01035Install();
382
+ upgrade(services);
383
+ const env = new Map(services.gateway.env);
384
+ env.set("GATEWAY_DATABASE_URL", {
385
+ kind: "value",
386
+ value: `postgresql://gateway_role@${HOST}:5432/cms_admin?sslmode=require`,
387
+ });
388
+ env.set("GATEWAY_DATABASE_PASSWORD", {
389
+ kind: "secret",
390
+ secret: "caelo-production-gateway-role-password",
391
+ version: "latest",
392
+ });
393
+ services.gateway = new FakeService(env, SERVICE_ACCOUNTS.gateway);
394
+ return services;
395
+ }
396
+
397
+ describe("upgrading a v0.10.50 install: the gateway leaves cms_admin entirely (#635)", () => {
398
+ it("removes the gateway_role login in one revision and keeps the public pool", () => {
399
+ const services = v01050Install();
400
+ const plan = upgrade(services);
401
+ expect(plan.ok && plan.services.gateway.changes.map((c) => c.name).sort()).toEqual([
402
+ "GATEWAY_DATABASE_PASSWORD",
403
+ "GATEWAY_DATABASE_URL",
404
+ ]);
405
+ expectGatewayOnItsOwnLogins(containerEnv(services.gateway.env));
406
+ for (const revision of services.gateway.revisions) {
407
+ expect(revision.has("PUBLIC_DATABASE_URL")).toBe(true);
408
+ }
409
+ });
410
+ });
411
+
377
412
  describe("plain → Secret Manager migration of an existing var", () => {
378
413
  it("never leaves a revision without the var", () => {
379
414
  // An operator set CAELO_INTERNAL_SECRET by hand as a plain value.
@@ -268,12 +268,7 @@ const CONVERGED_GATEWAY_DB = [
268
268
  name: "PUBLIC_DATABASE_URL",
269
269
  value: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
270
270
  },
271
- {
272
- name: "GATEWAY_DATABASE_URL",
273
- value: "postgresql://gateway_role@10.20.0.3:5432/cms_admin?sslmode=require",
274
- },
275
271
  secretRef("PUBLIC_DATABASE_PASSWORD", "public-role-password"),
276
- secretRef("GATEWAY_DATABASE_PASSWORD", "gateway-role-password"),
277
272
  ];
278
273
 
279
274
  describe("planContractEnv", () => {
@@ -776,10 +771,10 @@ describe("ensureStackInvariants", () => {
776
771
  const gatewayGrants = calls.filter(
777
772
  (c) => c.startsWith("secrets add-iam-policy-binding") && c.includes(GW_SA),
778
773
  );
779
- // #613 — never admin_role's postgres-password. #650 — the staging
780
- // gate key is the one secret the gateway shares with the admin.
774
+ // #613 — never admin_role's postgres-password; #635 — nor
775
+ // gateway_role's. #650 — the staging gate key is the one secret the
776
+ // gateway shares with the admin.
781
777
  expect(gatewayGrants).toEqual([
782
- `secrets add-iam-policy-binding caelo-production-gateway-role-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
783
778
  `secrets add-iam-policy-binding caelo-production-public-role-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
784
779
  `secrets add-iam-policy-binding caelo-production-staging-gate-secret --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
785
780
  ]);
@@ -802,7 +797,8 @@ describe("removeRetiredIamBindings (#613)", () => {
802
797
  ],
803
798
  });
804
799
  const outcomes = await removeRetiredIamBindings(gcp, { run, sleep: async () => {} });
805
- expect(outcomes.map((o) => o.status)).toEqual(["removed"]);
800
+ // postgres-password removed; gateway-role-password (#635) already clean.
801
+ expect(outcomes.map((o) => o.status)).toEqual(["removed", "absent"]);
806
802
  expect(calls).toContain(REMOVE);
807
803
  // The admin's own binding on the same secret is never touched.
808
804
  expect(calls.some((c) => c.includes("remove") && c.includes(RUN_SA))).toBe(false);
@@ -815,10 +811,23 @@ describe("removeRetiredIamBindings (#613)", () => {
815
811
  ],
816
812
  });
817
813
  const outcomes = await removeRetiredIamBindings(firebase, { run, sleep: async () => {} });
818
- expect(outcomes.map((o) => o.status)).toEqual(["absent"]);
814
+ expect(outcomes.map((o) => o.status)).toEqual(["absent", "absent"]);
819
815
  expect(calls.filter((c) => c.includes("remove-iam-policy-binding"))).toEqual([]);
820
816
  });
821
817
 
818
+ it("takes the gateway SA off gateway_role's password (#635)", async () => {
819
+ const { run, calls } = fakeGcloud({
820
+ "secrets get-iam-policy caelo-production-gateway-role-password": [
821
+ policy([{ role: "roles/secretmanager.secretAccessor", members: [GW_SA] }]),
822
+ ],
823
+ });
824
+ const outcomes = await removeRetiredIamBindings(gcp, { run, sleep: async () => {} });
825
+ expect(outcomes.map((o) => o.status)).toEqual(["absent", "removed"]);
826
+ expect(calls).toContain(
827
+ `secrets remove-iam-policy-binding caelo-production-gateway-role-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
828
+ );
829
+ });
830
+
822
831
  it("reports a failed removal instead of throwing", async () => {
823
832
  const { run } = fakeGcloud({
824
833
  "secrets get-iam-policy caelo-production-postgres-password": [