@caelo-cms/provisioning 0.10.52 → 0.10.55

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 (145) hide show
  1. package/dist/agent/command.js +1 -0
  2. package/dist/agent/command.js.map +1 -1
  3. package/dist/agent-db.js +198 -102
  4. package/dist/audit-job-setup.d.ts +69 -0
  5. package/dist/audit-job-setup.d.ts.map +1 -0
  6. package/dist/audit-job-setup.js +317 -0
  7. package/dist/audit-job-setup.js.map +1 -0
  8. package/dist/aws-task-roles.d.ts +1 -1
  9. package/dist/aws-task-roles.d.ts.map +1 -1
  10. package/dist/aws-task-roles.js +0 -1
  11. package/dist/aws-task-roles.js.map +1 -1
  12. package/dist/caddy.d.ts +9 -0
  13. package/dist/caddy.d.ts.map +1 -1
  14. package/dist/caddy.js +38 -0
  15. package/dist/caddy.js.map +1 -1
  16. package/dist/cli-routing.d.ts +20 -0
  17. package/dist/cli-routing.d.ts.map +1 -1
  18. package/dist/cli-routing.js +106 -0
  19. package/dist/cli-routing.js.map +1 -1
  20. package/dist/cli.js +37 -14
  21. package/dist/cli.js.map +1 -1
  22. package/dist/compose.d.ts +4 -3
  23. package/dist/compose.d.ts.map +1 -1
  24. package/dist/compose.js +3 -2
  25. package/dist/compose.js.map +1 -1
  26. package/dist/gateway-credentials.d.ts +3 -5
  27. package/dist/gateway-credentials.d.ts.map +1 -1
  28. package/dist/gateway-credentials.js +8 -18
  29. package/dist/gateway-credentials.js.map +1 -1
  30. package/dist/gcp-names.d.ts +16 -1
  31. package/dist/gcp-names.d.ts.map +1 -1
  32. package/dist/gcp-names.js +23 -0
  33. package/dist/gcp-names.js.map +1 -1
  34. package/dist/infra-contract.d.ts +1 -1
  35. package/dist/infra-contract.d.ts.map +1 -1
  36. package/dist/infra-contract.js +14 -1
  37. package/dist/infra-contract.js.map +1 -1
  38. package/dist/private-file.d.ts +26 -0
  39. package/dist/private-file.d.ts.map +1 -0
  40. package/dist/private-file.js +49 -0
  41. package/dist/private-file.js.map +1 -0
  42. package/dist/release-contract.d.ts.map +1 -1
  43. package/dist/release-contract.js +3 -0
  44. package/dist/release-contract.js.map +1 -1
  45. package/dist/runtime-secrets.d.ts +1 -3
  46. package/dist/runtime-secrets.d.ts.map +1 -1
  47. package/dist/runtime-secrets.js +2 -6
  48. package/dist/runtime-secrets.js.map +1 -1
  49. package/dist/self-hosted-roles.d.ts +9 -8
  50. package/dist/self-hosted-roles.d.ts.map +1 -1
  51. package/dist/self-hosted-roles.js +9 -9
  52. package/dist/self-hosted-roles.js.map +1 -1
  53. package/dist/self-hosted-upgrade.d.ts +7 -1
  54. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  55. package/dist/self-hosted-upgrade.js +17 -4
  56. package/dist/self-hosted-upgrade.js.map +1 -1
  57. package/dist/stack-contract.d.ts +60 -3
  58. package/dist/stack-contract.d.ts.map +1 -1
  59. package/dist/stack-contract.js +49 -37
  60. package/dist/stack-contract.js.map +1 -1
  61. package/dist/tar-entries.d.ts.map +1 -1
  62. package/dist/tar-entries.js +84 -9
  63. package/dist/tar-entries.js.map +1 -1
  64. package/dist/upgrade/app-roll.d.ts.map +1 -1
  65. package/dist/upgrade/app-roll.js +35 -0
  66. package/dist/upgrade/app-roll.js.map +1 -1
  67. package/dist/upgrade/audit-job-roll.d.ts +38 -0
  68. package/dist/upgrade/audit-job-roll.d.ts.map +1 -0
  69. package/dist/upgrade/audit-job-roll.js +43 -0
  70. package/dist/upgrade/audit-job-roll.js.map +1 -0
  71. package/dist/upgrade/converge.d.ts.map +1 -1
  72. package/dist/upgrade/converge.js +38 -15
  73. package/dist/upgrade/converge.js.map +1 -1
  74. package/dist/upgrader-setup.d.ts.map +1 -1
  75. package/dist/upgrader-setup.js +9 -3
  76. package/dist/upgrader-setup.js.map +1 -1
  77. package/dist/wizards/gcp-cost.js +3 -3
  78. package/dist/wizards/gcp-cost.js.map +1 -1
  79. package/dist/wizards/gcp-pulumi.d.ts +6 -0
  80. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  81. package/dist/wizards/gcp-pulumi.js +7 -0
  82. package/dist/wizards/gcp-pulumi.js.map +1 -1
  83. package/dist/wizards/gcp.d.ts.map +1 -1
  84. package/dist/wizards/gcp.js +32 -30
  85. package/dist/wizards/gcp.js.map +1 -1
  86. package/package.json +4 -4
  87. package/src/admin-memory.test.ts +1 -1
  88. package/src/agent/command.ts +1 -0
  89. package/src/agent/run.test.ts +1 -0
  90. package/src/audit-job-setup.test.ts +234 -0
  91. package/src/audit-job-setup.ts +360 -0
  92. package/src/aws-task-roles.ts +0 -2
  93. package/src/caddy-redirects.docker.test.ts +19 -6
  94. package/src/caddy-redirects.test.ts +1 -1
  95. package/src/caddy.test.ts +30 -0
  96. package/src/caddy.ts +43 -0
  97. package/src/checkpoint-secrets.test.ts +0 -2
  98. package/src/cli-init-validation.test.ts +44 -0
  99. package/src/cli-routing.test.ts +27 -1
  100. package/src/cli-routing.ts +131 -0
  101. package/src/cli.ts +41 -15
  102. package/src/compose.ts +13 -5
  103. package/src/gateway-credentials.test.ts +0 -29
  104. package/src/gateway-credentials.ts +11 -25
  105. package/src/gateway-env-isolation.test.ts +7 -2
  106. package/src/gcp-names.ts +28 -1
  107. package/src/index.test.ts +7 -6
  108. package/src/infra-contract.test.ts +4 -3
  109. package/src/infra-contract.ts +14 -1
  110. package/src/private-file.test.ts +80 -0
  111. package/src/private-file.ts +50 -0
  112. package/src/release-contract.ts +3 -0
  113. package/src/runtime-secrets.test.ts +9 -81
  114. package/src/runtime-secrets.ts +2 -6
  115. package/src/self-hosted-compose.test.ts +1 -1
  116. package/src/self-hosted-roles.integration.test.ts +23 -38
  117. package/src/self-hosted-roles.test.ts +11 -7
  118. package/src/self-hosted-roles.ts +17 -16
  119. package/src/self-hosted-upgrade.test.ts +28 -1
  120. package/src/self-hosted-upgrade.ts +29 -4
  121. package/src/stack-contract.test.ts +19 -11
  122. package/src/stack-contract.ts +54 -5
  123. package/src/stack-converge-secrets.test.ts +2 -0
  124. package/src/stack-converge.test.ts +3 -1
  125. package/src/tar-entries.test.ts +149 -7
  126. package/src/tar-entries.ts +90 -9
  127. package/src/upgrade/app-roll.ts +39 -0
  128. package/src/upgrade/audit-job-roll.ts +68 -0
  129. package/src/upgrade/converge.ts +47 -20
  130. package/src/upgrade/upgrade.test.ts +76 -2
  131. package/src/upgrader-assess.test.ts +1 -0
  132. package/src/upgrader-job.test.ts +1 -0
  133. package/src/upgrader-setup.test.ts +1 -0
  134. package/src/upgrader-setup.ts +9 -2
  135. package/src/wizards/gcp-cost.ts +3 -3
  136. package/src/wizards/gcp-pulumi.ts +7 -0
  137. package/src/wizards/gcp.ts +38 -31
  138. package/stacks/aws/README.md +1 -1
  139. package/stacks/aws/index.ts +9 -6
  140. package/stacks/azure/README.md +1 -1
  141. package/stacks/azure/index.ts +18 -6
  142. package/stacks/gcp/README.md +1 -1
  143. package/stacks/gcp/index.ts +14 -4
  144. package/stacks/gcp-firebase/index.ts +14 -4
  145. package/stacks/self-hosted/index.ts +4 -11
@@ -0,0 +1,80 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
3
+ import {
4
+ chmodSync,
5
+ closeSync,
6
+ mkdirSync,
7
+ mkdtempSync,
8
+ openSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ readSync,
12
+ rmSync,
13
+ statSync,
14
+ writeFileSync,
15
+ } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { tightenPrivateTree, writePrivateFile } from "./private-file.js";
19
+
20
+ let dir: string;
21
+ beforeEach(() => {
22
+ dir = mkdtempSync(join(tmpdir(), "caelo-private-file-"));
23
+ });
24
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
25
+
26
+ const mode = (path: string) => statSync(path).mode & 0o777;
27
+
28
+ describe("writePrivateFile", () => {
29
+ it("creates the file owner-only and replaces a world-readable file with an owner-only one", () => {
30
+ const fresh = join(dir, "fresh.json");
31
+ writePrivateFile(fresh, "{}");
32
+ expect(mode(fresh)).toBe(0o600);
33
+
34
+ const legacy = join(dir, "legacy.json");
35
+ writeFileSync(legacy, "{}", { mode: 0o644 });
36
+ chmodSync(legacy, 0o644);
37
+ writePrivateFile(legacy, '{"secret":"x"}');
38
+ expect(mode(legacy)).toBe(0o600);
39
+ expect(readFileSync(legacy, "utf8")).toBe('{"secret":"x"}');
40
+ expect(readdirSync(dir).sort()).toEqual(["fresh.json", "legacy.json"]);
41
+ });
42
+
43
+ it("never writes the new secret into the old world-readable inode", () => {
44
+ // A local user who opened the 0644 file before the rewrite keeps that
45
+ // descriptor; a write-then-chmod would let it read the new secret.
46
+ const legacy = join(dir, "internal-secret.json");
47
+ writeFileSync(legacy, "old");
48
+ chmodSync(legacy, 0o644);
49
+ const fd = openSync(legacy, "r");
50
+ try {
51
+ writePrivateFile(legacy, "new-secret");
52
+ const buf = Buffer.alloc(64);
53
+ const n = readSync(fd, buf, 0, buf.length, 0);
54
+ expect(buf.subarray(0, n).toString()).toBe("old");
55
+ } finally {
56
+ closeSync(fd);
57
+ }
58
+ });
59
+ });
60
+
61
+ describe("tightenPrivateTree", () => {
62
+ it("takes group/other access off an older install's directory and secrets, skipping missing ones", () => {
63
+ const install = join(dir, ".caelo");
64
+ mkdirSync(join(install, "bootstrap"), { recursive: true });
65
+ chmodSync(install, 0o755);
66
+ chmodSync(join(install, "bootstrap"), 0o755);
67
+ writeFileSync(join(install, "config.json"), "{}");
68
+ chmodSync(join(install, "config.json"), 0o644);
69
+ writeFileSync(join(install, "Caddyfile"), "");
70
+ chmodSync(join(install, "Caddyfile"), 0o644);
71
+
72
+ tightenPrivateTree(install, ["bootstrap", "backups"], ["config.json", "internal-secret.json"]);
73
+
74
+ expect(mode(install)).toBe(0o700);
75
+ expect(mode(join(install, "bootstrap"))).toBe(0o700);
76
+ expect(mode(join(install, "config.json"))).toBe(0o600);
77
+ // Not a secret: the Caddy container reads it through its bind mount.
78
+ expect(mode(join(install, "Caddyfile"))).toBe(0o644);
79
+ });
80
+ });
@@ -0,0 +1,50 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ import { randomUUID } from "node:crypto";
3
+ import { chmodSync, existsSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+
5
+ /**
6
+ * Write a file only its owner may read (0600), replacing any file at `path`.
7
+ *
8
+ * The data goes into a fresh temp file created 0600 (`wx`, so never an
9
+ * existing file or a planted symlink) and is renamed over `path`. Writing in
10
+ * place would not do: `writeFileSync`'s `mode` applies only when the call
11
+ * creates the file, so a secret file an older CLI wrote world-readable would
12
+ * hold the new secret world-readable until a chmod after the write — and a
13
+ * reader that opened it before any chmod keeps its descriptor. The rename
14
+ * gives the new contents a new inode that was never readable by anyone else.
15
+ */
16
+ export function writePrivateFile(path: string, data: string): void {
17
+ const tmp = `${path}.${randomUUID()}.tmp`;
18
+ writeFileSync(tmp, data, { mode: 0o600, flag: "wx" });
19
+ try {
20
+ renameSync(tmp, path);
21
+ } catch (e) {
22
+ rmSync(tmp, { force: true });
23
+ throw e;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Take group/other access off an install's secrets directory and the secret
29
+ * files in it, for installs an older CLI created with the default umask
30
+ * (`.caelo/` 0755, `config.json` / `internal-secret.json` 0644). Those files
31
+ * are written once and rarely rewritten, so tightening only on the next write
32
+ * would leave them readable to every local user. Missing paths are skipped:
33
+ * which secrets exist depends on the provider and how far `init` got.
34
+ *
35
+ * @param dir the install's directory, tightened to 0700
36
+ * @param privateDirs subdirectories (relative to `dir`) tightened to 0700
37
+ * @param privateFiles files (relative to `dir`) tightened to 0600
38
+ */
39
+ export function tightenPrivateTree(
40
+ dir: string,
41
+ privateDirs: readonly string[],
42
+ privateFiles: readonly string[],
43
+ ): void {
44
+ const tighten = (path: string, mode: number) => {
45
+ if (existsSync(path) && (statSync(path).mode & 0o077) !== 0) chmodSync(path, mode);
46
+ };
47
+ tighten(dir, 0o700);
48
+ for (const sub of privateDirs) tighten(`${dir}/${sub}`, 0o700);
49
+ for (const file of privateFiles) tighten(`${dir}/${file}`, 0o600);
50
+ }
@@ -49,6 +49,9 @@ export const INFRA_CONTRACT_PATHS: readonly string[] = [
49
49
  // The in-cloud upgrader job's service account, custom roles and bucket
50
50
  // grants (#799 added one without the checks noticing).
51
51
  "packages/provisioning/src/upgrader-setup.ts",
52
+ // #875 — the quality-audit job, its service account, results bucket and
53
+ // grants.
54
+ "packages/provisioning/src/audit-job-setup.ts",
52
55
  ];
53
56
 
54
57
  /**
@@ -115,13 +115,7 @@ describe("ensureGeneratedSecrets", () => {
115
115
  { ...secretsInstall, replication: regional },
116
116
  { run },
117
117
  );
118
- expect(outcomes.map((o) => o.status)).toEqual([
119
- "present",
120
- "present",
121
- "present",
122
- "present",
123
- "present",
124
- ]);
118
+ expect(outcomes.map((o) => o.status)).toEqual(["present", "present", "present", "present"]);
125
119
  expect(lines().some((l) => l.includes(" create ") || l.includes(" add "))).toBe(false);
126
120
  });
127
121
 
@@ -132,13 +126,7 @@ describe("ensureGeneratedSecrets", () => {
132
126
  { ...secretsInstall, replication: regional },
133
127
  { run, generate: () => `generated-value-${++n}` },
134
128
  );
135
- expect(outcomes.map((o) => o.status)).toEqual([
136
- "applied",
137
- "applied",
138
- "applied",
139
- "applied",
140
- "applied",
141
- ]);
129
+ expect(outcomes.map((o) => o.status)).toEqual(["applied", "applied", "applied", "applied"]);
142
130
  const creates = calls.filter((c) => c.line.startsWith("secrets create"));
143
131
  expect(creates).toEqual([
144
132
  {
@@ -149,19 +137,15 @@ describe("ensureGeneratedSecrets", () => {
149
137
  line: "secrets create caelo-production-tool-approval-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
150
138
  stdin: "generated-value-2",
151
139
  },
152
- // #613 — the gateway's role passwords: fresh values, never admin_role's.
140
+ // #613 — the gateway's role password: a fresh value, never admin_role's.
153
141
  {
154
142
  line: "secrets create caelo-production-public-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
155
143
  stdin: "generated-value-3",
156
144
  },
157
- {
158
- line: "secrets create caelo-production-gateway-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
159
- stdin: "generated-value-4",
160
- },
161
145
  // #650 — the staging access gate's key.
162
146
  {
163
147
  line: "secrets create caelo-production-staging-gate-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
164
- stdin: "generated-value-5",
148
+ stdin: "generated-value-4",
165
149
  },
166
150
  ]);
167
151
  for (const c of calls) expect(c.line).not.toContain("generated-value");
@@ -186,7 +170,6 @@ describe("ensureGeneratedSecrets", () => {
186
170
  "generated",
187
171
  "generated",
188
172
  "generated",
189
- "generated",
190
173
  ]);
191
174
  for (const c of calls) expect(c.line).not.toContain("operator-set");
192
175
  });
@@ -197,20 +180,13 @@ describe("ensureGeneratedSecrets", () => {
197
180
  "secrets versions list caelo-production-internal-secret": [ok("")],
198
181
  "secrets versions list caelo-production-tool-approval-secret": [ok("v/1")],
199
182
  "secrets versions list caelo-production-public-role-password": [ok("v/1")],
200
- "secrets versions list caelo-production-gateway-role-password": [ok("v/1")],
201
183
  "secrets versions list caelo-production-staging-gate-secret": [ok("v/1")],
202
184
  });
203
185
  const outcomes = await ensureGeneratedSecrets(
204
186
  { ...secretsInstall, replication: { kind: "automatic" } },
205
187
  { run, generate: () => "v" },
206
188
  );
207
- expect(outcomes.map((o) => o.status)).toEqual([
208
- "applied",
209
- "present",
210
- "present",
211
- "present",
212
- "present",
213
- ]);
189
+ expect(outcomes.map((o) => o.status)).toEqual(["applied", "present", "present", "present"]);
214
190
  expect(calls.filter((c) => c.line.startsWith("secrets versions add"))).toEqual([
215
191
  {
216
192
  line: "secrets versions add caelo-production-internal-secret --project=acme --data-file=-",
@@ -244,13 +220,7 @@ describe("ensureGeneratedSecrets", () => {
244
220
  { ...secretsInstall, replication: { kind: "automatic" } },
245
221
  { run, generate: () => "v" },
246
222
  );
247
- expect(outcomes.map((o) => o.status)).toEqual([
248
- "applied",
249
- "applied",
250
- "applied",
251
- "applied",
252
- "applied",
253
- ]);
223
+ expect(outcomes.map((o) => o.status)).toEqual(["applied", "applied", "applied", "applied"]);
254
224
  expect(outcomes.every((o) => o.detail?.includes("gcp.resourceLocations"))).toBe(true);
255
225
  const creates = lines().filter((l) => l.startsWith("secrets create"));
256
226
  // One refused automatic attempt; then every secret — the refused one
@@ -260,7 +230,6 @@ describe("ensureGeneratedSecrets", () => {
260
230
  "secrets create caelo-production-internal-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
261
231
  "secrets create caelo-production-tool-approval-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
262
232
  "secrets create caelo-production-public-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
263
- "secrets create caelo-production-gateway-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
264
233
  // #652 — the staging gate's key follows the same path.
265
234
  "secrets create caelo-production-staging-gate-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
266
235
  ]);
@@ -290,7 +259,7 @@ describe("ensureGeneratedSecrets", () => {
290
259
  { run, generate: () => "v" },
291
260
  );
292
261
  expect(outcomes.every((o) => o.status === "failed")).toBe(true);
293
- expect(lines().filter((l) => l.startsWith("secrets create"))).toHaveLength(5);
262
+ expect(lines().filter((l) => l.startsWith("secrets create"))).toHaveLength(4);
294
263
  });
295
264
  });
296
265
 
@@ -299,7 +268,6 @@ describe("rotationRefusal", () => {
299
268
  for (const s of [
300
269
  "postgres-password",
301
270
  "public-role-password",
302
- "gateway-role-password",
303
271
  "internal-secret",
304
272
  "tool-approval-secret",
305
273
  "staging-gate-secret",
@@ -311,6 +279,8 @@ describe("rotationRefusal", () => {
311
279
  it("refuses the KEK (re-encryption needed) and secrets nothing reads", () => {
312
280
  expect(rotationRefusal("secret-kek")).toContain("re-encryption");
313
281
  expect(rotationRefusal("csrf-secret")).toContain("Rotatable");
282
+ // #635 — the former gateway_role is gone; there is nothing to rotate.
283
+ expect(rotationRefusal("gateway-role-password")).toContain("Rotatable");
314
284
  });
315
285
  });
316
286
 
@@ -411,28 +381,6 @@ describe("rotateRuntimeSecret", () => {
411
381
  ).toBe(true);
412
382
  });
413
383
 
414
- it("gateway-role-password: changes gateway_role only and rolls no service (#613, #635)", async () => {
415
- const { run, lines } = fakeGcloud({
416
- ...converged(),
417
- "secrets versions access": [ok("old-pw")],
418
- });
419
- const sql = fakeSqlApi();
420
- const report = await rotateRuntimeSecret(target, "gateway-role-password", {
421
- run,
422
- http: sql.http,
423
- sleep: async () => {},
424
- generate: () => "new-pw",
425
- });
426
- expect(report.ok).toBe(true);
427
- expect(sql.passwords()).toEqual(["gateway_role new-pw"]);
428
- // No service logs in as gateway_role any more: the gateway reads
429
- // cms_public only (#635).
430
- expect(lines().filter((l) => l.startsWith("run services update "))).toEqual([]);
431
- expect(lines()).toContain(
432
- "secrets versions access latest --secret=caelo-production-gateway-role-password --project=acme",
433
- );
434
- });
435
-
436
384
  it("public-role-password: stores nothing and rolls nothing when public_role can't be changed", async () => {
437
385
  const { run, lines } = fakeGcloud({
438
386
  ...converged(),
@@ -474,26 +422,6 @@ describe("rotateRuntimeSecret", () => {
474
422
  expect(lines().every((l) => l.startsWith("run services describe"))).toBe(true);
475
423
  });
476
424
 
477
- it("refuses gateway-role-password while the gateway still logs in with it (before #635)", async () => {
478
- const answers = converged();
479
- answers["run services describe gw-svc"] = [
480
- service([
481
- ref("GATEWAY_DATABASE_PASSWORD", "gateway-role-password"),
482
- ref("PUBLIC_DATABASE_PASSWORD", "public-role-password"),
483
- ]),
484
- ];
485
- const { run } = fakeGcloud(answers);
486
- const sql = fakeSqlApi();
487
- const report = await rotateRuntimeSecret(target, "gateway-role-password", {
488
- run,
489
- http: sql.http,
490
- generate: () => "new-pw",
491
- });
492
- expect(report.ok).toBe(false);
493
- expect(report.error).toContain("GATEWAY_DATABASE_PASSWORD");
494
- expect(sql.requests).toHaveLength(0);
495
- });
496
-
497
425
  it("refuses before changing anything on an install upgrade hasn't moved over", async () => {
498
426
  const { run, lines } = fakeGcloud({
499
427
  "run services describe adm-svc": [
@@ -13,9 +13,8 @@
13
13
  * Secret Manager on stdin — never on argv, never to disk.
14
14
  * - {@link ensureDatabaseRolePassword}: a database role's password set
15
15
  * from its secret (`DATABASE_ROLE_SECRET`), through the Cloud SQL Admin
16
- * API — how `gateway_role` (created by migration 0248, without a
17
- * password) and `public_role` (moved off admin_role's password, #613)
18
- * get theirs.
16
+ * API — how `public_role` (moved off admin_role's password, #613) gets
17
+ * its own.
19
18
  * - {@link rotateRuntimeSecret}: `cms-provision rotate-secret`.
20
19
  *
21
20
  * Every function reports instead of throwing on a gcloud failure.
@@ -368,7 +367,6 @@ async function addVersion(
368
367
  export const ROTATABLE_SECRETS = [
369
368
  "postgres-password",
370
369
  "public-role-password",
371
- "gateway-role-password",
372
370
  "internal-secret",
373
371
  "tool-approval-secret",
374
372
  // #650 — rotating it signs every staging session out and replaces the
@@ -726,8 +724,6 @@ export async function readSecretValue(
726
724
  * the role already has is a no-op for every client, so upgrade runs this on
727
725
  * every roll and an install converges whatever state it was left in.
728
726
  *
729
- * The role must exist: `gateway_role` is created by migration 0248, so this
730
- * runs after migrations.
731
727
  */
732
728
  export async function ensureDatabaseRolePassword(
733
729
  target: DatabaseRoleTarget,
@@ -19,7 +19,7 @@ const GATEWAY = `ghcr.io/caelo-cms/gateway@sha256:${"b".repeat(64)}`;
19
19
  const base: Omit<ComposeSpec, "images"> = {
20
20
  domain: "example.com",
21
21
  postgresPassword: "pgpw",
22
- rolePasswords: { admin: "adminpw", public: "publicpw", gateway: "gatewaypw" },
22
+ rolePasswords: { admin: "adminpw", public: "publicpw" },
23
23
  caeloSecretKek: "0".repeat(64),
24
24
  anthropicApiKey: "sk-ant-x",
25
25
  diskSize: "20Gi",
@@ -1,11 +1,12 @@
1
1
  // SPDX-License-Identifier: MPL-2.0
2
2
 
3
3
  /**
4
- * #613 review — `cms-provision up` on a self-hosted volume created before
5
- * the per-role passwords: no `gateway_role`, `public_role` on another
6
- * password. The convergence script, run as the superuser exactly as `up`
7
- * runs it (psql, script on stdin), must leave the roles on the configured
8
- * passwords with the gateway's grants in place — and be safe to re-run.
4
+ * #613 review, #635 — `cms-provision up` on a self-hosted volume from
5
+ * before: `public_role` on another password, and the gateway's former
6
+ * `gateway_role` login still there with its grants. The convergence
7
+ * script, run as the superuser exactly as `up` runs it (psql, script on
8
+ * stdin), must leave the roles on the configured passwords and the former
9
+ * gateway_role gone — and be safe to re-run.
9
10
  *
10
11
  * Runs against the test database with the passwords the environment already
11
12
  * uses, so the other suites' connection URLs keep working afterwards.
@@ -25,9 +26,8 @@ const required = [
25
26
  "POSTGRES_PORT",
26
27
  "ADMIN_ROLE_PASSWORD",
27
28
  "PUBLIC_ROLE_PASSWORD",
28
- "GATEWAY_ROLE_PASSWORD",
29
29
  "ADMIN_DATABASE_URL",
30
- "GATEWAY_DATABASE_URL",
30
+ "PUBLIC_DATABASE_URL",
31
31
  ] as const;
32
32
  const missing = required.filter((name) => !env[name]);
33
33
  if (missing.length > 0) throw new Error(`${missing.join(", ")} required (see .env.example)`);
@@ -63,19 +63,20 @@ const psql: SuperuserPsql = async (script) => {
63
63
  const passwords = {
64
64
  admin: env.ADMIN_ROLE_PASSWORD as string,
65
65
  public: env.PUBLIC_ROLE_PASSWORD as string,
66
- gateway: env.GATEWAY_ROLE_PASSWORD as string,
67
66
  };
68
67
 
69
- describe("self-hosted role convergence against Postgres (#613 review)", () => {
70
- it("brings a pre-#613 volume to the configured roles, grants and passwords — twice", async () => {
71
- // The pre-#613 volume: no gateway_role, public_role on another password.
68
+ describe("self-hosted role convergence against Postgres (#613 review, #635)", () => {
69
+ it("brings an old volume to the configured roles and passwords, without gateway_role — twice", async () => {
70
+ // The old volume: the former gateway_role with a cms_admin grant, and
71
+ // public_role on another password.
72
72
  const reset = await psql(
73
73
  [
74
74
  "\\set ON_ERROR_STOP on",
75
+ "SELECT 'CREATE ROLE gateway_role NOINHERIT LOGIN' WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') \\gexec",
76
+ "GRANT CONNECT ON DATABASE cms_admin TO gateway_role;",
75
77
  "\\connect cms_admin",
76
- "DROP OWNED BY gateway_role;",
78
+ "GRANT SELECT (gateway_cookie_secret) ON site_settings TO gateway_role;",
77
79
  "\\connect postgres",
78
- "DROP ROLE gateway_role;",
79
80
  "ALTER ROLE public_role WITH PASSWORD 'the-old-shared-password';",
80
81
  "",
81
82
  ].join("\n"),
@@ -87,36 +88,20 @@ describe("self-hosted role convergence against Postgres (#613 review)", () => {
87
88
  expect(r).toEqual({ ok: true });
88
89
  }
89
90
 
90
- // The gateway logs in with its configured password and reads what 0248 grants.
91
- const gateway = new SQL(env.GATEWAY_DATABASE_URL as string);
91
+ // public_role logs in with its configured password again.
92
+ const pub = new SQL(env.PUBLIC_DATABASE_URL as string);
92
93
  try {
93
- const rows = (await gateway.begin(async (tx) => {
94
- await tx.unsafe("SET LOCAL caelo.actor_kind = 'system'");
95
- return tx`SELECT current_user::text AS u, length(gateway_cookie_secret) > 0 AS s FROM site_settings`;
96
- })) as { u: string; s: boolean }[];
97
- expect(rows[0]).toEqual({ u: "gateway_role", s: true });
94
+ const [who] = (await pub`SELECT current_user::text AS u`) as { u: string }[];
95
+ expect(who?.u).toBe("public_role");
98
96
  } finally {
99
- await gateway.end();
97
+ await pub.end();
100
98
  }
99
+ // The former gateway_role is gone, with its grants (#635).
101
100
  const admin = new SQL(env.ADMIN_DATABASE_URL as string);
102
101
  try {
103
- const [attrs] = (await admin`
104
- SELECT rolcanlogin, rolinherit FROM pg_roles WHERE rolname = 'gateway_role'`) as {
105
- rolcanlogin: boolean;
106
- rolinherit: boolean;
107
- }[];
108
- expect(attrs).toEqual({ rolcanlogin: true, rolinherit: false });
109
- const policies = (await admin`
110
- SELECT policyname::text AS p FROM pg_policies WHERE 'gateway_role' = ANY (roles) ORDER BY 1`) as {
111
- p: string;
112
- }[];
113
- expect(policies.map((p) => p.p)).toEqual([
114
- "audit_events_gateway_plugin_only",
115
- // #634 (migration 0252): the visitor paths' row limits. The A/B
116
- // ones went with the experiment tables (#676, migration 0259).
117
- "comment_archive_gateway_approved_only",
118
- "rate_limit_buckets_gateway_keys",
119
- ]);
102
+ const roles =
103
+ (await admin`SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role'`) as unknown[];
104
+ expect(roles).toEqual([]);
120
105
  } finally {
121
106
  await admin.end();
122
107
  }
@@ -8,26 +8,30 @@ import {
8
8
  selfHostedBootstrapScript,
9
9
  } from "./self-hosted-roles.js";
10
10
 
11
- const PASSWORDS = { admin: "a'pw", public: "p-pw", gateway: "g-pw" };
11
+ const PASSWORDS = { admin: "a'pw", public: "p-pw" };
12
12
 
13
13
  describe("roleConvergenceScript (#613 review)", () => {
14
14
  const script = roleConvergenceScript(PASSWORDS);
15
15
 
16
16
  it("creates missing roles and (re)sets every role's password, quoted", () => {
17
- for (const role of ["admin_role", "public_role", "gateway_role"]) {
17
+ for (const role of ["admin_role", "public_role"]) {
18
18
  expect(script).toContain(
19
19
  `WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}')`,
20
20
  );
21
21
  }
22
22
  expect(script).toContain("ALTER ROLE admin_role WITH LOGIN PASSWORD 'a''pw';");
23
- expect(script).toContain("ALTER ROLE gateway_role WITH LOGIN PASSWORD 'g-pw';");
23
+ expect(script).not.toContain("CREATE ROLE gateway_role");
24
24
  });
25
25
 
26
- it("re-applies migration 0248's gateway grants in cms_admin, and stops on the first error", () => {
26
+ it("drops the former gateway_role with what it holds in both databases, and stops on the first error (#635)", () => {
27
27
  expect(script.startsWith("\\set ON_ERROR_STOP on")).toBe(true);
28
- expect(script.indexOf("\\connect cms_admin")).toBeLessThan(
29
- script.indexOf("PERFORM caelo_grant_gateway_role()"),
30
- );
28
+ const publicDrop = script.indexOf("\\connect cms_public");
29
+ const adminDrop = script.indexOf("\\connect cms_admin");
30
+ expect(publicDrop).toBeGreaterThan(-1);
31
+ expect(adminDrop).toBeGreaterThan(publicDrop);
32
+ expect(script.match(/DROP OWNED BY gateway_role/g)).toHaveLength(2);
33
+ expect(script.indexOf("THEN DROP ROLE gateway_role;")).toBeGreaterThan(adminDrop);
34
+ expect(script).not.toContain("caelo_grant_gateway_role");
31
35
  });
32
36
  });
33
37
 
@@ -7,13 +7,14 @@
7
7
  * bootstrap.sh creates the application roles, but only on the Postgres
8
8
  * container's FIRST start (docker-entrypoint-initdb.d). An install created
9
9
  * before the per-role passwords has a data volume whose roles have other
10
- * passwords — and no `gateway_role` at all — so emitting Compose URLs with
11
- * the new passwords would lock the admin and the gateway out on the next
12
- * restart. `cms-provision up` therefore runs {@link roleConvergenceScript}
13
- * through the running container as the Postgres superuser BEFORE it writes
14
- * the new Compose file: it creates missing roles and databases, sets each
15
- * role's password to the configured one, and re-applies migration 0248's
16
- * gateway grants. Every statement is idempotent.
10
+ * passwords, so emitting Compose URLs with the new passwords would lock the
11
+ * admin and the gateway out on the next restart. `cms-provision up`
12
+ * therefore runs {@link roleConvergenceScript} through the running container
13
+ * as the Postgres superuser BEFORE it writes the new Compose file: it
14
+ * creates missing roles and databases and sets each role's password to the
15
+ * configured one. It also drops the former `gateway_role` (#635: the
16
+ * gateway has no cms_admin login), with whatever it still holds. Every
17
+ * statement is idempotent.
17
18
  *
18
19
  * Passwords travel on psql's stdin, never on argv.
19
20
  */
@@ -34,7 +35,7 @@ function literal(s: string): string {
34
35
  export const AGENT_DB_ROLE = "caelo_agent";
35
36
 
36
37
  /** The application roles bootstrap.sh and the convergence create. */
37
- const APP_ROLES = ["admin_role", "public_role", "gateway_role"] as const;
38
+ const APP_ROLES = ["admin_role", "public_role"] as const;
38
39
  type AppRole = (typeof APP_ROLES)[number];
39
40
 
40
41
  /** How a role's password appears in the SQL: a literal, or a psql variable. */
@@ -69,12 +70,14 @@ function convergenceSql(password: PasswordSql, agent: boolean): string {
69
70
  "REVOKE ALL ON DATABASE cms_public FROM PUBLIC;",
70
71
  "GRANT CONNECT ON DATABASE cms_admin TO admin_role;",
71
72
  "GRANT CONNECT ON DATABASE cms_public TO public_role, admin_role;",
73
+ // #635 — the gateway's former cms_admin login goes, with whatever it
74
+ // still holds in either database (the superuser may drop what
75
+ // migration 0285 could only revoke).
76
+ "\\connect cms_public",
77
+ "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP OWNED BY gateway_role; END IF; END $$;",
72
78
  "\\connect cms_admin",
73
- // A database that ran migration 0248 before gateway_role existed has no
74
- // grants for it yet; one that has not run it yet gets them from it.
75
- "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'caelo_grant_gateway_role') THEN PERFORM caelo_grant_gateway_role(); END IF; END $$;",
76
- // Migration 0251's staging-gate grants (issue #650), same convergence.
77
- "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'caelo_grant_gateway_staging_access') THEN PERFORM caelo_grant_gateway_staging_access(); END IF; END $$;",
79
+ "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP OWNED BY gateway_role; END IF; END $$;",
80
+ "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP ROLE gateway_role; END IF; END $$;",
78
81
  // Migrations 0267 + 0270 (#696): the upgrader role's grants, applied
79
82
  // now that the role exists.
80
83
  "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'caelo_grant_platform_updater') THEN PERFORM caelo_grant_platform_updater(); END IF; END $$;",
@@ -84,7 +87,7 @@ function convergenceSql(password: PasswordSql, agent: boolean): string {
84
87
 
85
88
  /**
86
89
  * The psql script (run as the superuser, connected to any database) that
87
- * brings roles, databases and the gateway's grants to the configured state.
90
+ * brings roles and databases to the configured state.
88
91
  * Mirrors bootstrap.sh, plus the password reset bootstrap.sh never does.
89
92
  * With `agentPassword`, also the host agent's login (#696 Phase 4c).
90
93
  */
@@ -92,7 +95,6 @@ export function roleConvergenceScript(passwords: RolePasswords, agentPassword?:
92
95
  const byRole: Record<AppRole, string> = {
93
96
  admin_role: passwords.admin,
94
97
  public_role: passwords.public,
95
- gateway_role: passwords.gateway,
96
98
  };
97
99
  return convergenceSql(
98
100
  (role) => literal(role === AGENT_DB_ROLE ? (agentPassword ?? "") : byRole[role]),
@@ -104,7 +106,6 @@ export function roleConvergenceScript(passwords: RolePasswords, agentPassword?:
104
106
  const PASSWORD_ENV: Record<AppRole | typeof AGENT_DB_ROLE, string> = {
105
107
  admin_role: "ADMIN_ROLE_PASSWORD",
106
108
  public_role: "PUBLIC_ROLE_PASSWORD",
107
- gateway_role: "GATEWAY_ROLE_PASSWORD",
108
109
  [AGENT_DB_ROLE]: "AGENT_ROLE_PASSWORD",
109
110
  };
110
111
 
@@ -19,6 +19,7 @@ import {
19
19
  readRelease,
20
20
  recordRelease,
21
21
  } from "./self-hosted-release.js";
22
+ import type { ConvergeOutcome } from "./self-hosted-roles.js";
22
23
  import {
23
24
  composePath,
24
25
  legacyComposePath,
@@ -121,11 +122,15 @@ beforeEach(() => {
121
122
  });
122
123
  afterEach(() => rmSync(dir, { recursive: true, force: true }));
123
124
 
124
- function deps(runner: HostRunner) {
125
+ function deps(runner: HostRunner & { calls: string[] }, roles: ConvergeOutcome = { ok: true }) {
125
126
  return {
126
127
  caeloDir: dir,
127
128
  runner,
128
129
  renderCompose: render,
130
+ convergeRoles: async () => {
131
+ runner.calls.push("converge roles");
132
+ return roles;
133
+ },
129
134
  log,
130
135
  fetchImpl: registry,
131
136
  sleep: async () => {},
@@ -151,6 +156,7 @@ describe("upgradeSelfHosted", () => {
151
156
  "pull",
152
157
  "migrate admin",
153
158
  "migrate public",
159
+ "converge roles",
154
160
  "up caelo-admin",
155
161
  "health admin",
156
162
  "up caelo-gateway",
@@ -269,6 +275,27 @@ describe("upgradeSelfHosted", () => {
269
275
  expect(readFileSync(composePath(dir), "utf8")).toBe(render(OLD));
270
276
  });
271
277
 
278
+ it("converges the database roles on every upgrade, after the migrations (#635: drops gateway_role)", async () => {
279
+ const runner = fakeRunner();
280
+ expect((await upgradeSelfHosted({ version: "0.11.0" }, deps(runner))).ok).toBe(true);
281
+ const s = steps(runner.calls);
282
+ expect(s.indexOf("converge roles")).toBeGreaterThan(s.indexOf("migrate public"));
283
+ expect(s.indexOf("converge roles")).toBeLessThan(s.indexOf("up caelo-admin"));
284
+ });
285
+
286
+ it("restores the Compose file and restarts nothing when the role convergence fails", async () => {
287
+ const runner = fakeRunner();
288
+ const r = await upgradeSelfHosted(
289
+ { version: "0.11.0" },
290
+ deps(runner, { ok: false, reachable: true, error: "role is busy" }),
291
+ );
292
+ expect(r).toMatchObject({ ok: false, stage: "roles", previousRunning: true });
293
+ if (!r.ok) expect(r.error).toContain("role is busy");
294
+ expect(steps(runner.calls).some((x) => x.startsWith("up "))).toBe(false);
295
+ expect(readFileSync(composePath(dir), "utf8")).toBe(render(OLD));
296
+ expect(readRelease(dir)?.version).toBe("0.10.0");
297
+ });
298
+
272
299
  it("rolls back to the previous images when the gateway fails its health check", async () => {
273
300
  let compose = "";
274
301
  const runner = fakeRunner([