@caelo-cms/provisioning 0.10.53 → 0.10.57

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 (244) hide show
  1. package/dist/agent-db.js +272 -128
  2. package/dist/audit-job-setup.d.ts +69 -0
  3. package/dist/audit-job-setup.d.ts.map +1 -0
  4. package/dist/audit-job-setup.js +317 -0
  5. package/dist/audit-job-setup.js.map +1 -0
  6. package/dist/caddy.d.ts +9 -0
  7. package/dist/caddy.d.ts.map +1 -1
  8. package/dist/caddy.js +38 -0
  9. package/dist/caddy.js.map +1 -1
  10. package/dist/cli-routing.js +1 -1
  11. package/dist/cli-routing.js.map +1 -1
  12. package/dist/cli.js +36 -9
  13. package/dist/cli.js.map +1 -1
  14. package/dist/compose.js +5 -0
  15. package/dist/compose.js.map +1 -1
  16. package/dist/converge-checkpoint.d.ts +66 -0
  17. package/dist/converge-checkpoint.d.ts.map +1 -0
  18. package/dist/converge-checkpoint.js +61 -0
  19. package/dist/converge-checkpoint.js.map +1 -0
  20. package/dist/converge-job-gcp.d.ts +46 -0
  21. package/dist/converge-job-gcp.d.ts.map +1 -0
  22. package/dist/converge-job-gcp.js +368 -0
  23. package/dist/converge-job-gcp.js.map +1 -0
  24. package/dist/converge-job-setup.d.ts +62 -0
  25. package/dist/converge-job-setup.d.ts.map +1 -0
  26. package/dist/converge-job-setup.js +228 -0
  27. package/dist/converge-job-setup.js.map +1 -0
  28. package/dist/converge-job.d.ts +141 -0
  29. package/dist/converge-job.d.ts.map +1 -0
  30. package/dist/converge-job.js +325 -0
  31. package/dist/converge-job.js.map +1 -0
  32. package/dist/gcp-names.d.ts +25 -1
  33. package/dist/gcp-names.d.ts.map +1 -1
  34. package/dist/gcp-names.js +36 -0
  35. package/dist/gcp-names.js.map +1 -1
  36. package/dist/index.d.ts +2 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +2 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/infra-contract.d.ts +1 -1
  41. package/dist/infra-contract.d.ts.map +1 -1
  42. package/dist/infra-contract.js +28 -1
  43. package/dist/infra-contract.js.map +1 -1
  44. package/dist/install-job.d.ts +11 -3
  45. package/dist/install-job.d.ts.map +1 -1
  46. package/dist/install-job.js +22 -12
  47. package/dist/install-job.js.map +1 -1
  48. package/dist/lifecycle.d.ts +13 -1
  49. package/dist/lifecycle.d.ts.map +1 -1
  50. package/dist/lifecycle.js +115 -58
  51. package/dist/lifecycle.js.map +1 -1
  52. package/dist/operator-access.d.ts.map +1 -1
  53. package/dist/operator-access.js +13 -3
  54. package/dist/operator-access.js.map +1 -1
  55. package/dist/private-file.d.ts +26 -0
  56. package/dist/private-file.d.ts.map +1 -0
  57. package/dist/private-file.js +49 -0
  58. package/dist/private-file.js.map +1 -0
  59. package/dist/release-contract.d.ts +3 -2
  60. package/dist/release-contract.d.ts.map +1 -1
  61. package/dist/release-contract.js +8 -2
  62. package/dist/release-contract.js.map +1 -1
  63. package/dist/release-manifest.d.ts +2 -0
  64. package/dist/release-manifest.d.ts.map +1 -1
  65. package/dist/release-manifest.js.map +1 -1
  66. package/dist/remote-mcp-edge.d.ts +114 -0
  67. package/dist/remote-mcp-edge.d.ts.map +1 -0
  68. package/dist/remote-mcp-edge.js +116 -0
  69. package/dist/remote-mcp-edge.js.map +1 -0
  70. package/dist/remote-mcp.d.ts +117 -0
  71. package/dist/remote-mcp.d.ts.map +1 -0
  72. package/dist/remote-mcp.js +379 -0
  73. package/dist/remote-mcp.js.map +1 -0
  74. package/dist/runtime-secrets.d.ts +12 -0
  75. package/dist/runtime-secrets.d.ts.map +1 -1
  76. package/dist/runtime-secrets.js +32 -4
  77. package/dist/runtime-secrets.js.map +1 -1
  78. package/dist/seal-key-rotation.d.ts +38 -0
  79. package/dist/seal-key-rotation.d.ts.map +1 -0
  80. package/dist/seal-key-rotation.js +206 -0
  81. package/dist/seal-key-rotation.js.map +1 -0
  82. package/dist/stack-contract.d.ts +175 -3
  83. package/dist/stack-contract.d.ts.map +1 -1
  84. package/dist/stack-contract.js +183 -3
  85. package/dist/stack-contract.js.map +1 -1
  86. package/dist/stack-converge.d.ts +38 -1
  87. package/dist/stack-converge.d.ts.map +1 -1
  88. package/dist/stack-converge.js +74 -4
  89. package/dist/stack-converge.js.map +1 -1
  90. package/dist/state-lock-lease.d.ts +142 -0
  91. package/dist/state-lock-lease.d.ts.map +1 -0
  92. package/dist/state-lock-lease.js +347 -0
  93. package/dist/state-lock-lease.js.map +1 -0
  94. package/dist/state-store.d.ts +66 -9
  95. package/dist/state-store.d.ts.map +1 -1
  96. package/dist/state-store.js +249 -30
  97. package/dist/state-store.js.map +1 -1
  98. package/dist/tar-entries.d.ts.map +1 -1
  99. package/dist/tar-entries.js +84 -9
  100. package/dist/tar-entries.js.map +1 -1
  101. package/dist/upgrade/app-roll.d.ts +36 -7
  102. package/dist/upgrade/app-roll.d.ts.map +1 -1
  103. package/dist/upgrade/app-roll.js +88 -19
  104. package/dist/upgrade/app-roll.js.map +1 -1
  105. package/dist/upgrade/audit-job-roll.d.ts +38 -0
  106. package/dist/upgrade/audit-job-roll.d.ts.map +1 -0
  107. package/dist/upgrade/audit-job-roll.js +43 -0
  108. package/dist/upgrade/audit-job-roll.js.map +1 -0
  109. package/dist/upgrade/converge.d.ts.map +1 -1
  110. package/dist/upgrade/converge.js +141 -8
  111. package/dist/upgrade/converge.js.map +1 -1
  112. package/dist/upgrade/gcp-lookup.d.ts +27 -10
  113. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  114. package/dist/upgrade/gcp-lookup.js +46 -7
  115. package/dist/upgrade/gcp-lookup.js.map +1 -1
  116. package/dist/upgrade/record-rolled.d.ts +40 -0
  117. package/dist/upgrade/record-rolled.d.ts.map +1 -0
  118. package/dist/upgrade/record-rolled.js +46 -0
  119. package/dist/upgrade/record-rolled.js.map +1 -0
  120. package/dist/upgrade/upgrade.d.ts +45 -1
  121. package/dist/upgrade/upgrade.d.ts.map +1 -1
  122. package/dist/upgrade/upgrade.js +60 -17
  123. package/dist/upgrade/upgrade.js.map +1 -1
  124. package/dist/upgrader-assess.d.ts +15 -1
  125. package/dist/upgrader-assess.d.ts.map +1 -1
  126. package/dist/upgrader-assess.js +36 -6
  127. package/dist/upgrader-assess.js.map +1 -1
  128. package/dist/upgrader-gcp.d.ts +15 -0
  129. package/dist/upgrader-gcp.d.ts.map +1 -1
  130. package/dist/upgrader-gcp.js +42 -7
  131. package/dist/upgrader-gcp.js.map +1 -1
  132. package/dist/upgrader-iam-preflight.d.ts +123 -0
  133. package/dist/upgrader-iam-preflight.d.ts.map +1 -0
  134. package/dist/upgrader-iam-preflight.js +414 -0
  135. package/dist/upgrader-iam-preflight.js.map +1 -0
  136. package/dist/upgrader-job.d.ts +33 -9
  137. package/dist/upgrader-job.d.ts.map +1 -1
  138. package/dist/upgrader-job.js +169 -27
  139. package/dist/upgrader-job.js.map +1 -1
  140. package/dist/upgrader-records.d.ts +58 -0
  141. package/dist/upgrader-records.d.ts.map +1 -0
  142. package/dist/upgrader-records.js +87 -0
  143. package/dist/upgrader-records.js.map +1 -0
  144. package/dist/upgrader-reporter.d.ts +3 -2
  145. package/dist/upgrader-reporter.d.ts.map +1 -1
  146. package/dist/upgrader-reporter.js +10 -2
  147. package/dist/upgrader-reporter.js.map +1 -1
  148. package/dist/upgrader-setup.d.ts +25 -3
  149. package/dist/upgrader-setup.d.ts.map +1 -1
  150. package/dist/upgrader-setup.js +132 -14
  151. package/dist/upgrader-setup.js.map +1 -1
  152. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  153. package/dist/wizards/gcp-cost.js +14 -6
  154. package/dist/wizards/gcp-cost.js.map +1 -1
  155. package/dist/wizards/gcp-pulumi.d.ts +6 -0
  156. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  157. package/dist/wizards/gcp-pulumi.js +7 -0
  158. package/dist/wizards/gcp-pulumi.js.map +1 -1
  159. package/dist/wizards/gcp.d.ts.map +1 -1
  160. package/dist/wizards/gcp.js +130 -4
  161. package/dist/wizards/gcp.js.map +1 -1
  162. package/package.json +4 -4
  163. package/src/agent/run.test.ts +15 -1
  164. package/src/audit-job-setup.test.ts +234 -0
  165. package/src/audit-job-setup.ts +360 -0
  166. package/src/caddy-redirects.docker.test.ts +19 -6
  167. package/src/caddy.test.ts +30 -0
  168. package/src/caddy.ts +43 -0
  169. package/src/cli-init-validation.test.ts +44 -0
  170. package/src/cli-routing.ts +1 -1
  171. package/src/cli.ts +40 -8
  172. package/src/compose.ts +5 -0
  173. package/src/converge-checkpoint.ts +75 -0
  174. package/src/converge-entrypoint.test.ts +213 -0
  175. package/src/converge-job-gcp.test.ts +222 -0
  176. package/src/converge-job-gcp.ts +443 -0
  177. package/src/converge-job-setup.test.ts +152 -0
  178. package/src/converge-job-setup.ts +268 -0
  179. package/src/converge-job.test.ts +486 -0
  180. package/src/converge-job.ts +542 -0
  181. package/src/gateway-env-isolation.test.ts +8 -1
  182. package/src/gcp-names.ts +43 -1
  183. package/src/gcp-url-map.test.ts +9 -1
  184. package/src/index.ts +8 -0
  185. package/src/infra-contract.test.ts +7 -4
  186. package/src/infra-contract.ts +28 -1
  187. package/src/install-job.test.ts +3 -1
  188. package/src/install-job.ts +31 -14
  189. package/src/lifecycle.ts +137 -71
  190. package/src/operator-access.test.ts +14 -0
  191. package/src/operator-access.ts +13 -3
  192. package/src/private-file.test.ts +80 -0
  193. package/src/private-file.ts +50 -0
  194. package/src/release-contract.ts +8 -2
  195. package/src/release-manifest.test.ts +42 -0
  196. package/src/release-manifest.ts +2 -0
  197. package/src/remote-mcp-edge.ts +137 -0
  198. package/src/remote-mcp.test.ts +517 -0
  199. package/src/remote-mcp.ts +460 -0
  200. package/src/runtime-secrets.test.ts +31 -6
  201. package/src/runtime-secrets.ts +41 -3
  202. package/src/seal-key-rotation.test.ts +131 -0
  203. package/src/seal-key-rotation.ts +258 -0
  204. package/src/stack-contract-parity.test.ts +20 -3
  205. package/src/stack-contract.test.ts +34 -2
  206. package/src/stack-contract.ts +305 -3
  207. package/src/stack-converge-secrets.test.ts +3 -0
  208. package/src/stack-converge.test.ts +21 -5
  209. package/src/stack-converge.ts +101 -7
  210. package/src/state-attach.test.ts +7 -1
  211. package/src/state-lock-lease.test.ts +302 -0
  212. package/src/state-lock-lease.ts +423 -0
  213. package/src/state-lock.test.ts +568 -0
  214. package/src/state-store.ts +347 -27
  215. package/src/tar-entries.test.ts +149 -7
  216. package/src/tar-entries.ts +90 -9
  217. package/src/upgrade/app-roll.ts +135 -21
  218. package/src/upgrade/audit-job-roll.ts +68 -0
  219. package/src/upgrade/converge.ts +170 -7
  220. package/src/upgrade/gcp-lookup.test.ts +80 -0
  221. package/src/upgrade/gcp-lookup.ts +65 -8
  222. package/src/upgrade/record-rolled.test.ts +105 -0
  223. package/src/upgrade/record-rolled.ts +91 -0
  224. package/src/upgrade/upgrade.test.ts +279 -5
  225. package/src/upgrade/upgrade.ts +120 -24
  226. package/src/upgrader-assess.test.ts +93 -3
  227. package/src/upgrader-assess.ts +58 -9
  228. package/src/upgrader-gcp.ts +47 -8
  229. package/src/upgrader-iam-preflight.test.ts +393 -0
  230. package/src/upgrader-iam-preflight.ts +510 -0
  231. package/src/upgrader-job.test.ts +587 -15
  232. package/src/upgrader-job.ts +237 -53
  233. package/src/upgrader-records.ts +151 -0
  234. package/src/upgrader-reporter.test.ts +6 -0
  235. package/src/upgrader-reporter.ts +7 -2
  236. package/src/upgrader-setup.test.ts +4 -3
  237. package/src/upgrader-setup.ts +137 -13
  238. package/src/wizards/gcp-cost.ts +15 -6
  239. package/src/wizards/gcp-pulumi.ts +7 -0
  240. package/src/wizards/gcp.ts +162 -4
  241. package/stacks/aws/index.ts +18 -0
  242. package/stacks/azure/index.ts +18 -0
  243. package/stacks/gcp/index.ts +59 -7
  244. package/stacks/gcp-firebase/index.ts +50 -6
package/src/compose.ts CHANGED
@@ -400,6 +400,11 @@ ${serviceRuntime(spec.images, "gateway", "bun run apps/api-gateway/src/server.ts
400
400
  GATEWAY_PORT: "${COMPOSE_GATEWAY_PORT}"
401
401
  # Caddy is the one proxy in front (client-ip.ts in the gateway).
402
402
  ${TRUSTED_PROXY_HOPS_ENV}: "${GATEWAY_TRUSTED_PROXY_HOPS["self-hosted"]}"
403
+ # #806 — no remote MCP here: it rides Google IAP, which this install
404
+ # does not have. CAELO_ADMIN_URL / CAELO_MCP_PUBLIC_URL /
405
+ # CAELO_MCP_OAUTH_CLIENT_FILE stay unset, so the gateway's /api/mcp/*
406
+ # answers 503 remote_mcp_not_configured and Settings → MCP says
407
+ # "not available on this install".
403
408
  NODE_ENV: production`;
404
409
  }
405
410
 
@@ -0,0 +1,75 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The converge job's checkpoint (#819): `converge/converge-job.json` in the
5
+ * install's ops bucket. NOT under `install/` next to install.json: the
6
+ * upgrader's service account may write `install/` (its object grant is
7
+ * conditioned on that prefix, upgrader-setup.ts), and this file steers a
8
+ * run as the provisioner — its backup and rollback targets would be
9
+ * tamperable from there (#883 review). Only the provisioner and the
10
+ * operator, who hold the whole bucket, can write `converge/`. It has the
11
+ * install job's status shape
12
+ * (install-job.ts {@link InstallJobStatusSchema}: which execution runs, its
13
+ * phase, whether it finished) plus what a killed run's successor must
14
+ * reuse instead of recomputing:
15
+ *
16
+ * - `targetVersion` / `fromVersion`: the update it belongs to. A run for
17
+ * another target starts fresh; a run for the same target resumes;
18
+ * - `backup`: the pre-upgrade backup (a new one could hold the
19
+ * half-migrated schema);
20
+ * - `priorRevisions`: the revisions that served before the update (the
21
+ * rollback targets; the serving one may already be the new release's).
22
+ *
23
+ * Whether a successor starts, attaches or is done is {@link decideInstallJob}'s
24
+ * call, as for the install job. No secret ever lands here (assertNoSecrets).
25
+ */
26
+
27
+ import { z } from "zod";
28
+ import { InstallJobStatusSchema, readJobStatusDoc, writeJobStatusDoc } from "./install-job.js";
29
+ import type { SyncGcloud } from "./state-store.js";
30
+
31
+ const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$/;
32
+
33
+ export const ConvergeCheckpointSchema = InstallJobStatusSchema.extend({
34
+ /** The release this converge installs. */
35
+ targetVersion: z.string().regex(VERSION),
36
+ /** install.json's release when the update first started (the version it is judged from). */
37
+ fromVersion: z.string().regex(VERSION),
38
+ /** The update request it serves (the last one that ran it). */
39
+ requestId: z.string().uuid(),
40
+ backup: z
41
+ .object({
42
+ projectId: z.string().min(1),
43
+ instance: z.string().min(1),
44
+ backupId: z.string().min(1),
45
+ })
46
+ .optional(),
47
+ priorRevisions: z.object({ admin: z.string().min(1), gateway: z.string().min(1) }).optional(),
48
+ });
49
+ /**
50
+ * The ops-bucket prefix of the converge job's own files — outside the
51
+ * upgrader's `install/` grant on purpose (see the file comment).
52
+ */
53
+ export const CONVERGE_STATE_PREFIX = "converge";
54
+
55
+ /** What `converge/converge-job.json` records. */
56
+ export type ConvergeCheckpoint = z.infer<typeof ConvergeCheckpointSchema>;
57
+
58
+ /** `gs://<bucket>/converge/converge-job.json`. */
59
+ export function convergeCheckpointUrl(bucket: string): string {
60
+ return `gs://${bucket}/${CONVERGE_STATE_PREFIX}/converge-job.json`;
61
+ }
62
+
63
+ /** The checkpoint as the bucket holds it now (never cached), or null. */
64
+ export function readConvergeCheckpoint(bucket: string, run: SyncGcloud): ConvergeCheckpoint | null {
65
+ return readJobStatusDoc(convergeCheckpointUrl(bucket), ConvergeCheckpointSchema, run);
66
+ }
67
+
68
+ /** Replace the checkpoint. */
69
+ export function writeConvergeCheckpoint(
70
+ bucket: string,
71
+ checkpoint: ConvergeCheckpoint,
72
+ run: SyncGcloud,
73
+ ): void {
74
+ writeJobStatusDoc(convergeCheckpointUrl(bucket), ConvergeCheckpointSchema, checkpoint, run);
75
+ }
@@ -0,0 +1,213 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #883 review — the converge job runs as caelo-provisioner, so an
5
+ * execution override must be useless even if someone held
6
+ * run.jobs.runWithOverrides:
7
+ *
8
+ * - the job's entry point (ops/caelo-converge-job) refuses every argument
9
+ * but `converge-job` before any code runs;
10
+ * - it starts the job with an empty environment plus a fixed allowlist,
11
+ * so CLOUDSDK_*, PULUMI_*, BUN_*, LD_* overrides never reach gcloud,
12
+ * Pulumi or Bun;
13
+ * - and nobody is granted a role that carries runWithOverrides: not the
14
+ * upgrader (anywhere), not the admin (on the converge or upgrader job).
15
+ *
16
+ * The script runs here under /bin/sh with its final `bun …` target swapped
17
+ * for `env`, so the test sees exactly the environment the job would get.
18
+ */
19
+
20
+ import { describe, expect, it } from "bun:test";
21
+ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { join, resolve } from "node:path";
24
+ import { CONVERGE_ADMIN_ROLES, convergeJobDeployArgs } from "./converge-job-setup.js";
25
+ import type { GcloudResult } from "./gcloud.js";
26
+ import {
27
+ ensureUpgraderJob,
28
+ RUN_WITH_OVERRIDES_ROLES,
29
+ UPGRADER_FORBIDDEN_ROLES,
30
+ UPGRADER_PROJECT_ROLES,
31
+ UPGRADER_RUN_ROLE,
32
+ } from "./upgrader-setup.js";
33
+
34
+ const SCRIPT = resolve(import.meta.dir, "..", "ops", "caelo-converge-job");
35
+ const TARGET_LINE = "bun /opt/caelo-ops/packages/platform-updater/src/converge-main.ts";
36
+
37
+ /** The entry point with its final target replaced by `env` (prints the environment). */
38
+ function scriptPrintingEnv(dir: string): string {
39
+ const text = readFileSync(SCRIPT, "utf8");
40
+ expect(text.split(TARGET_LINE).length).toBe(2);
41
+ const path = join(dir, "caelo-converge-job");
42
+ writeFileSync(path, text.replace(TARGET_LINE, "env"));
43
+ chmodSync(path, 0o755);
44
+ return path;
45
+ }
46
+
47
+ function runScript(path: string, args: string[], env: Record<string, string>) {
48
+ const r = Bun.spawnSync(["/bin/sh", path, ...args], {
49
+ env,
50
+ stdout: "pipe",
51
+ stderr: "pipe",
52
+ });
53
+ return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() };
54
+ }
55
+
56
+ describe("ops/caelo-converge-job (the converge job's entry point)", () => {
57
+ it("refuses every argument list but exactly `converge-job`, before any code runs", () => {
58
+ const dir = mkdtempSync(join(tmpdir(), "caelo-entry-"));
59
+ try {
60
+ const path = scriptPrintingEnv(dir);
61
+ for (const args of [
62
+ [],
63
+ ["destroy"],
64
+ ["owner-reset", "--email", "evil@example.com"],
65
+ ["rotate-secret"],
66
+ ["converge-job", "--extra"],
67
+ ["upgrader"],
68
+ ]) {
69
+ const r = runScript(path, args, { PATH: "/usr/bin:/bin" });
70
+ expect(r.code).toBe(64);
71
+ expect(r.err).toContain("refused");
72
+ expect(r.out).toBe("");
73
+ }
74
+ } finally {
75
+ rmSync(dir, { recursive: true, force: true });
76
+ }
77
+ });
78
+
79
+ it("starts the job with a scrubbed environment: overrides never reach gcloud, Pulumi or Bun", () => {
80
+ const dir = mkdtempSync(join(tmpdir(), "caelo-entry-"));
81
+ try {
82
+ const r = runScript(scriptPrintingEnv(dir), ["converge-job"], {
83
+ PATH: "/usr/bin:/bin",
84
+ CLOUDSDK_API_ENDPOINT_OVERRIDES_RUN: "https://evil.example/",
85
+ CLOUDSDK_AUTH_ACCESS_TOKEN: "stolen",
86
+ PULUMI_BACKEND_URL: "gs://evil",
87
+ PULUMI_CONFIG_PASSPHRASE: "x",
88
+ BUN_OPTIONS: "--preload=/tmp/x.ts",
89
+ LD_PRELOAD: "/tmp/x.so",
90
+ NODE_OPTIONS: "--require=/tmp/x.js",
91
+ CLOUD_RUN_EXECUTION: "caelo-production-converge-abc12",
92
+ CLOUD_RUN_JOB: "caelo-production-converge",
93
+ });
94
+ expect(r.code).toBe(0);
95
+ const env = Object.fromEntries(
96
+ r.out
97
+ .trim()
98
+ .split("\n")
99
+ .map((l) => [l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1)]),
100
+ );
101
+ expect(Object.keys(env).sort()).toEqual(
102
+ [
103
+ "CLOUDSDK_COMPONENT_MANAGER_DISABLE_UPDATE_CHECK",
104
+ "CLOUDSDK_CORE_DISABLE_PROMPTS",
105
+ "CLOUD_RUN_EXECUTION",
106
+ "CLOUD_RUN_JOB",
107
+ "CLOUD_RUN_TASK_ATTEMPT",
108
+ "CLOUD_RUN_TASK_COUNT",
109
+ "CLOUD_RUN_TASK_INDEX",
110
+ "HOME",
111
+ "PATH",
112
+ "PULUMI_SKIP_UPDATE_CHECK",
113
+ ].sort(),
114
+ );
115
+ expect(env.CLOUD_RUN_EXECUTION).toBe("caelo-production-converge-abc12");
116
+ expect(env.PATH).toBe("/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin");
117
+ } finally {
118
+ rmSync(dir, { recursive: true, force: true });
119
+ }
120
+ });
121
+
122
+ it("the deployed job runs exactly that entry point with that one argument", () => {
123
+ const args = convergeJobDeployArgs({
124
+ projectId: "acme",
125
+ region: "europe-west1",
126
+ env: "production",
127
+ opsImageRef: `x/ops@sha256:${"c".repeat(64)}`,
128
+ network: "n",
129
+ subnet: "s",
130
+ });
131
+ expect(args).toContain("--command=caelo-converge-job");
132
+ expect(args).toContain("--args=converge-job");
133
+ expect(args).toContain("--clear-env-vars");
134
+ });
135
+ });
136
+
137
+ describe("no upgrader or admin role carries run.jobs.runWithOverrides (#883 review)", () => {
138
+ it("the forbidden list covers every predefined role that does", () => {
139
+ for (const role of [
140
+ "roles/run.developer",
141
+ "roles/run.admin",
142
+ "roles/run.invoker",
143
+ "roles/run.jobsExecutorWithOverrides",
144
+ "roles/editor",
145
+ "roles/owner",
146
+ ]) {
147
+ expect(RUN_WITH_OVERRIDES_ROLES).toContain(role as (typeof RUN_WITH_OVERRIDES_ROLES)[number]);
148
+ expect(UPGRADER_FORBIDDEN_ROLES.some((f) => role.startsWith(f))).toBe(true);
149
+ }
150
+ });
151
+
152
+ it("the upgrader's own Cloud Run role leaves it out", () => {
153
+ expect(UPGRADER_RUN_ROLE.permissions).not.toContain("run.jobs.runWithOverrides");
154
+ expect(UPGRADER_RUN_ROLE.permissions.some((p) => /setIamPolicy|actAs/.test(p))).toBe(false);
155
+ for (const role of UPGRADER_PROJECT_ROLES) {
156
+ expect(RUN_WITH_OVERRIDES_ROLES as readonly string[]).not.toContain(role);
157
+ }
158
+ });
159
+
160
+ it("setup grants the upgrader no such role, and removes the run.developer an earlier setup gave it", async () => {
161
+ const calls: string[][] = [];
162
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
163
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
164
+ const upgrader = "serviceAccount:caelo-production-upgrader@acme.iam.gserviceaccount.com";
165
+ const rules: [RegExp, GcloudResult][] = [
166
+ [/^iam roles describe/, fail("NOT_FOUND")],
167
+ [/^iam service-accounts describe/, ok("x")],
168
+ [
169
+ /^projects get-iam-policy/,
170
+ ok(JSON.stringify({ bindings: [{ role: "roles/run.developer", members: [upgrader] }] })),
171
+ ],
172
+ [/^sql instances list/, ok("caelo-production-pg-abc\n")],
173
+ [/^sql instances describe/, ok('{"settings":{"databaseFlags":[]}}')],
174
+ [/^sql users list/, ok("")],
175
+ ];
176
+ const run = async (args: string[]) => {
177
+ calls.push(args);
178
+ return rules.find(([re]) => re.test(args.join(" ")))?.[1] ?? ok("");
179
+ };
180
+ const r = await ensureUpgraderJob(
181
+ {
182
+ projectId: "acme",
183
+ region: "europe-west1",
184
+ env: "production",
185
+ opsImageRef: `x/ops@sha256:${"c".repeat(64)}`,
186
+ network: "n",
187
+ subnet: "s",
188
+ },
189
+ { run, sleep: async () => {} },
190
+ );
191
+ expect(r.ok).toBe(true);
192
+ const roleOf = (c: string[]) => c.find((a) => a.startsWith("--role="))?.slice(7) ?? "";
193
+ const granted = calls.filter((c) => c.includes("add-iam-policy-binding")).map(roleOf);
194
+ for (const role of granted) {
195
+ expect(RUN_WITH_OVERRIDES_ROLES as readonly string[]).not.toContain(role);
196
+ }
197
+ const removed = calls.filter((c) => c.includes("remove-iam-policy-binding"));
198
+ expect(removed.map((c) => `${roleOf(c)} ${c.find((a) => a.startsWith("--member="))}`)).toEqual([
199
+ `roles/run.developer --member=${upgrader}`,
200
+ ]);
201
+ // The custom role is bound before the old one is removed.
202
+ const bindRun = calls.findIndex((c) => roleOf(c) === "projects/acme/roles/caeloUpgraderRun");
203
+ const removeDev = calls.findIndex((c) => c.includes("remove-iam-policy-binding"));
204
+ expect(bindRun).toBeGreaterThan(-1);
205
+ expect(bindRun).toBeLessThan(removeDev);
206
+ });
207
+
208
+ it("the admin's roles on the converge job carry none", () => {
209
+ for (const role of CONVERGE_ADMIN_ROLES) {
210
+ expect(RUN_WITH_OVERRIDES_ROLES as readonly string[]).not.toContain(role);
211
+ }
212
+ });
213
+ });
@@ -0,0 +1,222 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The parts of the converge job's Google Cloud wiring (#819) that parse
5
+ * what gcloud says — which image an execution runs, which execution a
6
+ * hand-over started — and its checkpoint document in the ops bucket.
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import {
11
+ type ConvergeCheckpoint,
12
+ convergeCheckpointUrl,
13
+ readConvergeCheckpoint,
14
+ writeConvergeCheckpoint,
15
+ } from "./converge-checkpoint.js";
16
+ import {
17
+ convergeExecutionNameError,
18
+ deployedOwnerMismatch,
19
+ deployedSiteMismatch,
20
+ executionImageDigest,
21
+ startedExecutionName,
22
+ } from "./converge-job-gcp.js";
23
+ import type { GcloudResult } from "./gcloud.js";
24
+ import type { SyncGcloud } from "./state-store.js";
25
+ import { installStateCondition } from "./upgrader-setup.js";
26
+
27
+ const D = `sha256:${"c".repeat(64)}`;
28
+ const OPS = `europe-west1-docker.pkg.dev/caelo-website/caelo-cms-images/ops@${D}`;
29
+
30
+ describe("executionImageDigest", () => {
31
+ it("reads the digest from gcloud's v1 execution (and the v2 shape)", () => {
32
+ const v1 = { spec: { template: { spec: { containers: [{ image: OPS }] } } } };
33
+ expect(executionImageDigest(JSON.stringify(v1))).toBe(D);
34
+ expect(
35
+ executionImageDigest(JSON.stringify({ template: { containers: [{ image: OPS }] } })),
36
+ ).toBe(D);
37
+ });
38
+
39
+ it("is null for a tag, no container or unreadable output — never a guess", () => {
40
+ const tag = { spec: { template: { spec: { containers: [{ image: "x/ops:0.11.0" }] } } } };
41
+ expect(executionImageDigest(JSON.stringify(tag))).toBeNull();
42
+ expect(executionImageDigest("{}")).toBeNull();
43
+ expect(executionImageDigest("not json")).toBeNull();
44
+ });
45
+ });
46
+
47
+ describe("startedExecutionName", () => {
48
+ it("takes the name gcloud prints, or the one it announces on stderr", () => {
49
+ expect(startedExecutionName("caelo-production-converge-x7k2p\n", "")).toBe(
50
+ "caelo-production-converge-x7k2p",
51
+ );
52
+ expect(
53
+ startedExecutionName(
54
+ "",
55
+ "Execution [caelo-production-converge-q1w2e] has successfully started",
56
+ ),
57
+ ).toBe("caelo-production-converge-q1w2e");
58
+ expect(startedExecutionName("", "")).toBeNull();
59
+ expect(startedExecutionName("Bad Name; rm -rf", "")).toBeNull();
60
+ });
61
+ });
62
+
63
+ describe("the converge checkpoint", () => {
64
+ const BUCKET = "acme-caelo-ops";
65
+ const fakeBucket = () => {
66
+ const objects = new Map<string, string>();
67
+ const run: SyncGcloud = (args, input): GcloudResult => {
68
+ if (args[1] === "cat") {
69
+ const v = objects.get(args[2] as string);
70
+ return v === undefined
71
+ ? { ok: false, stdout: "", stderr: "One or more URLs matched no objects.", exitCode: 1 }
72
+ : { ok: true, stdout: v, stderr: "", exitCode: 0 };
73
+ }
74
+ objects.set(args[3] as string, input ?? "");
75
+ return { ok: true, stdout: "", stderr: "", exitCode: 0 };
76
+ };
77
+ return { run, objects };
78
+ };
79
+ const CHECKPOINT: ConvergeCheckpoint = {
80
+ statusVersion: 1,
81
+ installId: "gcp-acme",
82
+ execution: "caelo-production-converge-x7k2p",
83
+ state: "running",
84
+ phase: "converge",
85
+ caeloVersion: "0.11.0",
86
+ startedAt: "2026-10-19T12:00:00Z",
87
+ updatedAt: "2026-10-19T12:05:00Z",
88
+ targetVersion: "0.11.0",
89
+ fromVersion: "0.10.53",
90
+ requestId: "11111111-2222-4333-8444-555555555555",
91
+ backup: { projectId: "acme", instance: "caelo-production-pg-x", backupId: "1760000000" },
92
+ priorRevisions: { admin: "a-00001", gateway: "g-00001" },
93
+ };
94
+
95
+ it("lives outside install/, where the upgrader may write, and round-trips (#883 review)", () => {
96
+ const { run, objects } = fakeBucket();
97
+ expect(readConvergeCheckpoint(BUCKET, run)).toBeNull();
98
+ writeConvergeCheckpoint(BUCKET, CHECKPOINT, run);
99
+ expect([...objects.keys()]).toEqual(["gs://acme-caelo-ops/converge/converge-job.json"]);
100
+ expect(convergeCheckpointUrl(BUCKET)).toBe("gs://acme-caelo-ops/converge/converge-job.json");
101
+ expect(readConvergeCheckpoint(BUCKET, run)).toEqual(CHECKPOINT);
102
+ // The upgrader's only object grant on the bucket is conditioned on
103
+ // install/ — the checkpoint is not under it.
104
+ const condition = installStateCondition(BUCKET);
105
+ const prefix = /objects\/([^"]+)"/.exec(condition)?.[1] ?? "";
106
+ expect(prefix).toBe("install/");
107
+ expect(convergeCheckpointUrl(BUCKET).startsWith(`gs://${BUCKET}/${prefix}`)).toBe(false);
108
+ });
109
+
110
+ it("refuses a document it does not understand", () => {
111
+ const { run, objects } = fakeBucket();
112
+ objects.set(convergeCheckpointUrl(BUCKET), JSON.stringify({ ...CHECKPOINT, fromVersion: "x" }));
113
+ expect(() => readConvergeCheckpoint(BUCKET, run)).toThrow(/not a job status/);
114
+ });
115
+ });
116
+
117
+ describe("deployedOwnerMismatch (#883 review)", () => {
118
+ const job = (members: string | null) =>
119
+ JSON.stringify({
120
+ spec: {
121
+ template: {
122
+ spec: {
123
+ template: {
124
+ spec: {
125
+ containers: [
126
+ {
127
+ env: [
128
+ { name: "CAELO_OPERATOR_ACCESS_IAP_WEB", value: "x" },
129
+ ...(members === null
130
+ ? []
131
+ : [{ name: "CAELO_OPERATOR_ACCESS_STATIC_MEMBERS", value: members }]),
132
+ ],
133
+ },
134
+ ],
135
+ },
136
+ },
137
+ },
138
+ },
139
+ },
140
+ });
141
+
142
+ it("accepts install.json's Owner when the deployed IAP members are exactly it", () => {
143
+ expect(deployedOwnerMismatch(job("user:owner@acme.com"), "Owner@Acme.com")).toBeNull();
144
+ });
145
+
146
+ it("refuses an Owner email the deployed access settings do not admit (a tampered install.json)", () => {
147
+ const why = deployedOwnerMismatch(job("user:owner@acme.com"), "attacker@evil.example");
148
+ expect(why).toContain("user:owner@acme.com");
149
+ expect(why).toContain("user:attacker@evil.example");
150
+ });
151
+
152
+ it("refuses when the deployed members cannot be read — never assumes they match", () => {
153
+ expect(deployedOwnerMismatch(job(null), "owner@acme.com")).toContain("sets no");
154
+ expect(deployedOwnerMismatch("not json", "owner@acme.com")).toContain("not JSON");
155
+ expect(
156
+ deployedOwnerMismatch(job("user:owner@acme.com,user:x@y.z"), "owner@acme.com"),
157
+ ).not.toBeNull();
158
+ });
159
+ });
160
+
161
+ describe("deployedSiteMismatch (#883 review, round 2)", () => {
162
+ const admin = (env: Record<string, string>) =>
163
+ JSON.stringify({
164
+ spec: {
165
+ template: {
166
+ spec: {
167
+ containers: [{ env: Object.entries(env).map(([name, value]) => ({ name, value })) }],
168
+ },
169
+ },
170
+ },
171
+ });
172
+ const META = { domain: "acme.com", region: "europe-west1" } as const;
173
+ const SERVED = { CAELO_SITE_URL: "https://acme.com", CAELO_STAGING_HOST: "staging.acme.com" };
174
+
175
+ it("accepts the records of the site the admin serves, in the job's own region", () => {
176
+ expect(deployedSiteMismatch(admin(SERVED), META, "europe-west1")).toBeNull();
177
+ // An admin from before #650 has no staging host yet: the default is fine.
178
+ expect(
179
+ deployedSiteMismatch(admin({ CAELO_SITE_URL: "https://acme.com" }), META, "europe-west1"),
180
+ ).toBeNull();
181
+ });
182
+
183
+ it("refuses a tampered domain, staging host or region: the provisioner would act on it", () => {
184
+ expect(
185
+ deployedSiteMismatch(admin(SERVED), { ...META, domain: "evil.example" }, "europe-west1"),
186
+ ).toContain("https://evil.example");
187
+ expect(
188
+ deployedSiteMismatch(
189
+ admin(SERVED),
190
+ { ...META, stagingDomain: "staging.evil.example" },
191
+ "europe-west1",
192
+ ),
193
+ ).toContain("staging.evil.example");
194
+ expect(
195
+ deployedSiteMismatch(
196
+ admin({ CAELO_SITE_URL: "https://acme.com" }),
197
+ { ...META, stagingDomain: "staging.evil.example" },
198
+ "europe-west1",
199
+ ),
200
+ ).not.toBeNull();
201
+ expect(
202
+ deployedSiteMismatch(admin(SERVED), { ...META, region: "us-central1" }, "europe-west1"),
203
+ ).toContain("us-central1");
204
+ });
205
+
206
+ it("refuses when the admin's site cannot be read — never assumes it matches", () => {
207
+ expect(deployedSiteMismatch("not json", META, "europe-west1")).not.toBeNull();
208
+ expect(deployedSiteMismatch(admin({}), META, "europe-west1")).toContain("CAELO_SITE_URL");
209
+ });
210
+ });
211
+
212
+ describe("convergeExecutionNameError (#883 review)", () => {
213
+ it("accepts a run of the converge job and no name at all (outside Cloud Run)", () => {
214
+ expect(convergeExecutionNameError("caelo-production-converge-x7k2p")).toBeNull();
215
+ expect(convergeExecutionNameError(null)).toBeNull();
216
+ });
217
+
218
+ it("refuses a run of another job or a value that is no Cloud Run name", () => {
219
+ expect(convergeExecutionNameError("caelo-production-upgrader-x1")).not.toBeNull();
220
+ expect(convergeExecutionNameError("caelo-production-converge-X;rm")).not.toBeNull();
221
+ });
222
+ });