@caelo-cms/provisioning 0.10.55 → 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 (209) hide show
  1. package/dist/agent-db.js +76 -19
  2. package/dist/cli-routing.js +1 -1
  3. package/dist/cli-routing.js.map +1 -1
  4. package/dist/cli.js +4 -1
  5. package/dist/cli.js.map +1 -1
  6. package/dist/compose.js +5 -0
  7. package/dist/compose.js.map +1 -1
  8. package/dist/converge-checkpoint.d.ts +66 -0
  9. package/dist/converge-checkpoint.d.ts.map +1 -0
  10. package/dist/converge-checkpoint.js +61 -0
  11. package/dist/converge-checkpoint.js.map +1 -0
  12. package/dist/converge-job-gcp.d.ts +46 -0
  13. package/dist/converge-job-gcp.d.ts.map +1 -0
  14. package/dist/converge-job-gcp.js +368 -0
  15. package/dist/converge-job-gcp.js.map +1 -0
  16. package/dist/converge-job-setup.d.ts +62 -0
  17. package/dist/converge-job-setup.d.ts.map +1 -0
  18. package/dist/converge-job-setup.js +228 -0
  19. package/dist/converge-job-setup.js.map +1 -0
  20. package/dist/converge-job.d.ts +141 -0
  21. package/dist/converge-job.d.ts.map +1 -0
  22. package/dist/converge-job.js +325 -0
  23. package/dist/converge-job.js.map +1 -0
  24. package/dist/gcp-names.d.ts +9 -0
  25. package/dist/gcp-names.d.ts.map +1 -1
  26. package/dist/gcp-names.js +13 -0
  27. package/dist/gcp-names.js.map +1 -1
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/infra-contract.d.ts +1 -1
  33. package/dist/infra-contract.d.ts.map +1 -1
  34. package/dist/infra-contract.js +22 -1
  35. package/dist/infra-contract.js.map +1 -1
  36. package/dist/install-job.d.ts +11 -3
  37. package/dist/install-job.d.ts.map +1 -1
  38. package/dist/install-job.js +22 -12
  39. package/dist/install-job.js.map +1 -1
  40. package/dist/lifecycle.d.ts +13 -1
  41. package/dist/lifecycle.d.ts.map +1 -1
  42. package/dist/lifecycle.js +115 -58
  43. package/dist/lifecycle.js.map +1 -1
  44. package/dist/operator-access.d.ts.map +1 -1
  45. package/dist/operator-access.js +13 -3
  46. package/dist/operator-access.js.map +1 -1
  47. package/dist/release-contract.d.ts +3 -2
  48. package/dist/release-contract.d.ts.map +1 -1
  49. package/dist/release-contract.js +5 -2
  50. package/dist/release-contract.js.map +1 -1
  51. package/dist/release-manifest.d.ts +2 -0
  52. package/dist/release-manifest.d.ts.map +1 -1
  53. package/dist/release-manifest.js.map +1 -1
  54. package/dist/remote-mcp-edge.d.ts +114 -0
  55. package/dist/remote-mcp-edge.d.ts.map +1 -0
  56. package/dist/remote-mcp-edge.js +116 -0
  57. package/dist/remote-mcp-edge.js.map +1 -0
  58. package/dist/remote-mcp.d.ts +117 -0
  59. package/dist/remote-mcp.d.ts.map +1 -0
  60. package/dist/remote-mcp.js +379 -0
  61. package/dist/remote-mcp.js.map +1 -0
  62. package/dist/runtime-secrets.d.ts +12 -0
  63. package/dist/runtime-secrets.d.ts.map +1 -1
  64. package/dist/runtime-secrets.js +32 -4
  65. package/dist/runtime-secrets.js.map +1 -1
  66. package/dist/seal-key-rotation.d.ts +38 -0
  67. package/dist/seal-key-rotation.d.ts.map +1 -0
  68. package/dist/seal-key-rotation.js +206 -0
  69. package/dist/seal-key-rotation.js.map +1 -0
  70. package/dist/stack-contract.d.ts +167 -3
  71. package/dist/stack-contract.d.ts.map +1 -1
  72. package/dist/stack-contract.js +171 -3
  73. package/dist/stack-contract.js.map +1 -1
  74. package/dist/stack-converge.d.ts +38 -1
  75. package/dist/stack-converge.d.ts.map +1 -1
  76. package/dist/stack-converge.js +74 -4
  77. package/dist/stack-converge.js.map +1 -1
  78. package/dist/state-lock-lease.d.ts +142 -0
  79. package/dist/state-lock-lease.d.ts.map +1 -0
  80. package/dist/state-lock-lease.js +347 -0
  81. package/dist/state-lock-lease.js.map +1 -0
  82. package/dist/state-store.d.ts +66 -9
  83. package/dist/state-store.d.ts.map +1 -1
  84. package/dist/state-store.js +249 -30
  85. package/dist/state-store.js.map +1 -1
  86. package/dist/upgrade/app-roll.d.ts +36 -7
  87. package/dist/upgrade/app-roll.d.ts.map +1 -1
  88. package/dist/upgrade/app-roll.js +53 -19
  89. package/dist/upgrade/app-roll.js.map +1 -1
  90. package/dist/upgrade/converge.d.ts.map +1 -1
  91. package/dist/upgrade/converge.js +111 -9
  92. package/dist/upgrade/converge.js.map +1 -1
  93. package/dist/upgrade/gcp-lookup.d.ts +27 -10
  94. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  95. package/dist/upgrade/gcp-lookup.js +46 -7
  96. package/dist/upgrade/gcp-lookup.js.map +1 -1
  97. package/dist/upgrade/record-rolled.d.ts +40 -0
  98. package/dist/upgrade/record-rolled.d.ts.map +1 -0
  99. package/dist/upgrade/record-rolled.js +46 -0
  100. package/dist/upgrade/record-rolled.js.map +1 -0
  101. package/dist/upgrade/upgrade.d.ts +45 -1
  102. package/dist/upgrade/upgrade.d.ts.map +1 -1
  103. package/dist/upgrade/upgrade.js +60 -17
  104. package/dist/upgrade/upgrade.js.map +1 -1
  105. package/dist/upgrader-assess.d.ts +15 -1
  106. package/dist/upgrader-assess.d.ts.map +1 -1
  107. package/dist/upgrader-assess.js +36 -6
  108. package/dist/upgrader-assess.js.map +1 -1
  109. package/dist/upgrader-gcp.d.ts +15 -0
  110. package/dist/upgrader-gcp.d.ts.map +1 -1
  111. package/dist/upgrader-gcp.js +42 -7
  112. package/dist/upgrader-gcp.js.map +1 -1
  113. package/dist/upgrader-iam-preflight.d.ts +123 -0
  114. package/dist/upgrader-iam-preflight.d.ts.map +1 -0
  115. package/dist/upgrader-iam-preflight.js +414 -0
  116. package/dist/upgrader-iam-preflight.js.map +1 -0
  117. package/dist/upgrader-job.d.ts +33 -9
  118. package/dist/upgrader-job.d.ts.map +1 -1
  119. package/dist/upgrader-job.js +169 -27
  120. package/dist/upgrader-job.js.map +1 -1
  121. package/dist/upgrader-records.d.ts +58 -0
  122. package/dist/upgrader-records.d.ts.map +1 -0
  123. package/dist/upgrader-records.js +87 -0
  124. package/dist/upgrader-records.js.map +1 -0
  125. package/dist/upgrader-reporter.d.ts +3 -2
  126. package/dist/upgrader-reporter.d.ts.map +1 -1
  127. package/dist/upgrader-reporter.js +10 -2
  128. package/dist/upgrader-reporter.js.map +1 -1
  129. package/dist/upgrader-setup.d.ts +25 -3
  130. package/dist/upgrader-setup.d.ts.map +1 -1
  131. package/dist/upgrader-setup.js +123 -11
  132. package/dist/upgrader-setup.js.map +1 -1
  133. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  134. package/dist/wizards/gcp-cost.js +11 -3
  135. package/dist/wizards/gcp-cost.js.map +1 -1
  136. package/dist/wizards/gcp.d.ts.map +1 -1
  137. package/dist/wizards/gcp.js +99 -4
  138. package/dist/wizards/gcp.js.map +1 -1
  139. package/package.json +2 -2
  140. package/src/agent/run.test.ts +14 -1
  141. package/src/cli-routing.ts +1 -1
  142. package/src/cli.ts +4 -1
  143. package/src/compose.ts +5 -0
  144. package/src/converge-checkpoint.ts +75 -0
  145. package/src/converge-entrypoint.test.ts +213 -0
  146. package/src/converge-job-gcp.test.ts +222 -0
  147. package/src/converge-job-gcp.ts +443 -0
  148. package/src/converge-job-setup.test.ts +152 -0
  149. package/src/converge-job-setup.ts +268 -0
  150. package/src/converge-job.test.ts +486 -0
  151. package/src/converge-job.ts +542 -0
  152. package/src/gateway-env-isolation.test.ts +8 -1
  153. package/src/gcp-names.ts +15 -0
  154. package/src/gcp-url-map.test.ts +9 -1
  155. package/src/index.ts +8 -0
  156. package/src/infra-contract.test.ts +7 -4
  157. package/src/infra-contract.ts +22 -1
  158. package/src/install-job.test.ts +3 -1
  159. package/src/install-job.ts +31 -14
  160. package/src/lifecycle.ts +137 -71
  161. package/src/operator-access.test.ts +14 -0
  162. package/src/operator-access.ts +13 -3
  163. package/src/release-contract.ts +5 -2
  164. package/src/release-manifest.test.ts +42 -0
  165. package/src/release-manifest.ts +2 -0
  166. package/src/remote-mcp-edge.ts +137 -0
  167. package/src/remote-mcp.test.ts +517 -0
  168. package/src/remote-mcp.ts +460 -0
  169. package/src/runtime-secrets.test.ts +31 -6
  170. package/src/runtime-secrets.ts +41 -3
  171. package/src/seal-key-rotation.test.ts +131 -0
  172. package/src/seal-key-rotation.ts +258 -0
  173. package/src/stack-contract-parity.test.ts +20 -3
  174. package/src/stack-contract.test.ts +30 -2
  175. package/src/stack-contract.ts +291 -3
  176. package/src/stack-converge-secrets.test.ts +3 -0
  177. package/src/stack-converge.test.ts +21 -5
  178. package/src/stack-converge.ts +101 -7
  179. package/src/state-attach.test.ts +7 -1
  180. package/src/state-lock-lease.test.ts +302 -0
  181. package/src/state-lock-lease.ts +423 -0
  182. package/src/state-lock.test.ts +568 -0
  183. package/src/state-store.ts +347 -27
  184. package/src/upgrade/app-roll.ts +96 -21
  185. package/src/upgrade/converge.ts +130 -8
  186. package/src/upgrade/gcp-lookup.test.ts +80 -0
  187. package/src/upgrade/gcp-lookup.ts +65 -8
  188. package/src/upgrade/record-rolled.test.ts +105 -0
  189. package/src/upgrade/record-rolled.ts +91 -0
  190. package/src/upgrade/upgrade.test.ts +203 -3
  191. package/src/upgrade/upgrade.ts +120 -24
  192. package/src/upgrader-assess.test.ts +92 -3
  193. package/src/upgrader-assess.ts +58 -9
  194. package/src/upgrader-gcp.ts +47 -8
  195. package/src/upgrader-iam-preflight.test.ts +393 -0
  196. package/src/upgrader-iam-preflight.ts +510 -0
  197. package/src/upgrader-job.test.ts +586 -15
  198. package/src/upgrader-job.ts +237 -53
  199. package/src/upgrader-records.ts +151 -0
  200. package/src/upgrader-reporter.test.ts +6 -0
  201. package/src/upgrader-reporter.ts +7 -2
  202. package/src/upgrader-setup.test.ts +3 -3
  203. package/src/upgrader-setup.ts +128 -11
  204. package/src/wizards/gcp-cost.ts +12 -3
  205. package/src/wizards/gcp.ts +125 -4
  206. package/stacks/aws/index.ts +10 -0
  207. package/stacks/azure/index.ts +9 -0
  208. package/stacks/gcp/index.ts +46 -7
  209. package/stacks/gcp-firebase/index.ts +37 -6
@@ -0,0 +1,152 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The converge job's setup (converge-job-setup.ts, #819) against a fake
5
+ * gcloud. The load-bearing assertions are the trust boundary: the admin's
6
+ * run SA may start the job without overrides and read its runs — on this
7
+ * job only, nothing else — and the job runs the ops image by digest as the
8
+ * install's existing provisioner, with no env and no input but the
9
+ * database.
10
+ */
11
+
12
+ import { describe, expect, it } from "bun:test";
13
+ import {
14
+ CONVERGE_ADMIN_ROLES,
15
+ type ConvergeSetupTarget,
16
+ convergeJobDeployArgs,
17
+ ensureConvergeJob,
18
+ } from "./converge-job-setup.js";
19
+ import type { GcloudResult } from "./gcloud.js";
20
+ import { INSTALL_JOB_ROLES } from "./install-job.js";
21
+
22
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
23
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
24
+
25
+ const PROVISIONER = "caelo-provisioner@acme.iam.gserviceaccount.com";
26
+ const ADMIN_SA = "serviceAccount:caelo-production-run-sa@acme.iam.gserviceaccount.com";
27
+ const OPS = `europe-west1-docker.pkg.dev/caelo-website/caelo-cms-images/ops@sha256:${"c".repeat(64)}`;
28
+
29
+ const TARGET: ConvergeSetupTarget = {
30
+ projectId: "acme",
31
+ region: "europe-west1",
32
+ env: "production",
33
+ opsImageRef: OPS,
34
+ network: "caelo-production-vpc",
35
+ subnet: "caelo-production-subnet",
36
+ };
37
+
38
+ function fakeGcloud(overrides: [RegExp, GcloudResult][] = []) {
39
+ const calls: string[][] = [];
40
+ const rules: [RegExp, GcloudResult][] = [
41
+ ...overrides,
42
+ [/^iam service-accounts describe/, ok(PROVISIONER)],
43
+ [/^sql instances list/, ok("caelo-production-pg-abc\n")],
44
+ [/^sql instances describe/, ok('{"settings":{"databaseFlags":[]}}')],
45
+ [/^sql users list/, ok("")],
46
+ ];
47
+ const run = async (args: string[]) => {
48
+ calls.push(args);
49
+ const line = args.join(" ");
50
+ return rules.find(([re]) => re.test(line))?.[1] ?? ok("");
51
+ };
52
+ return { run, calls };
53
+ }
54
+
55
+ const flag = (args: string[], name: string) =>
56
+ args.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
57
+
58
+ function grants(calls: string[][]) {
59
+ return calls
60
+ .filter((c) => c.includes("add-iam-policy-binding"))
61
+ .map((c) => ({
62
+ where: c.slice(0, c.indexOf("add-iam-policy-binding") + 2).join(" "),
63
+ member: flag(c, "member"),
64
+ role: flag(c, "role") as string,
65
+ }));
66
+ }
67
+
68
+ describe("ensureConvergeJob", () => {
69
+ it("the admin may start the job without overrides and read it — on this job, nothing else", async () => {
70
+ const { run, calls } = fakeGcloud();
71
+ const r = await ensureConvergeJob(TARGET, { run, sleep: async () => {} });
72
+ expect(r.ok).toBe(true);
73
+ const admin = grants(calls).filter((g) => g.member === ADMIN_SA);
74
+ expect(admin.map((g) => `${g.where} → ${g.role}`).sort()).toEqual([
75
+ "run jobs add-iam-policy-binding caelo-production-converge → roles/run.jobsExecutor",
76
+ "run jobs add-iam-policy-binding caelo-production-converge → roles/run.viewer",
77
+ ]);
78
+ expect([...CONVERGE_ADMIN_ROLES]).toEqual(["roles/run.jobsExecutor", "roles/run.viewer"]);
79
+ // jobsExecutor has run.jobs.run but not run.jobs.runWithOverrides; no
80
+ // role that carries it (…WithOverrides, run.developer/admin) appears.
81
+ expect(calls.flat().some((a) => /WithOverrides|run\.developer|run\.admin/.test(a))).toBe(false);
82
+ });
83
+
84
+ it("runs the ops image by digest as the install's provisioner, with no env, no secrets and one fixed subcommand", async () => {
85
+ const { run, calls } = fakeGcloud();
86
+ await ensureConvergeJob(TARGET, { run, sleep: async () => {} });
87
+ const deploy = calls.find((c) => c.slice(0, 3).join(" ") === "run jobs deploy") ?? [];
88
+ expect(deploy).toEqual(convergeJobDeployArgs(TARGET));
89
+ expect(deploy[3]).toBe("caelo-production-converge");
90
+ expect(flag(deploy, "image")).toBe(OPS);
91
+ expect(flag(deploy, "service-account")).toBe(PROVISIONER);
92
+ // #883 review: the strict entry point that refuses other arguments and
93
+ // scrubs the environment (ops/caelo-converge-job), not caelo-ops.
94
+ expect(flag(deploy, "command")).toBe("caelo-converge-job");
95
+ expect(flag(deploy, "args")).toBe("converge-job");
96
+ expect(flag(deploy, "max-retries")).toBe("0");
97
+ // No env or secrets are set, and any an earlier deploy set are cleared.
98
+ expect(deploy.filter((a) => /env-vars|secrets|env-file/.test(a))).toEqual([
99
+ "--clear-env-vars",
100
+ "--clear-secrets",
101
+ ]);
102
+ });
103
+
104
+ it("gives the provisioner the install-job roles and a platform_updater database login only", async () => {
105
+ const { run, calls } = fakeGcloud();
106
+ await ensureConvergeJob(TARGET, { run, sleep: async () => {} });
107
+ const mine = grants(calls).filter((g) => g.member === `serviceAccount:${PROVISIONER}`);
108
+ expect(mine.map((g) => g.role).sort()).toEqual([...INSTALL_JOB_ROLES].sort());
109
+ expect(mine.every((g) => g.where === "projects add-iam-policy-binding acme")).toBe(true);
110
+ const assign = calls.find((c) => c.slice(0, 3).join(" ") === "sql users assign-roles");
111
+ expect(assign?.[3]).toBe("caelo-provisioner@acme.iam");
112
+ expect(flag(assign ?? [], "database-roles")).toBe("platform_updater");
113
+ });
114
+
115
+ it("never creates the provisioner: an install without one keeps converging from a terminal", async () => {
116
+ const { run, calls } = fakeGcloud([[/^iam service-accounts describe/, fail("NOT_FOUND")]]);
117
+ const r = await ensureConvergeJob(TARGET, { run });
118
+ expect(r.ok ? "" : r.error).toContain("has no caelo-provisioner@acme.iam.gserviceaccount.com");
119
+ expect(calls.some((c) => c.join(" ").startsWith("iam service-accounts create"))).toBe(false);
120
+ expect(grants(calls)).toEqual([]);
121
+ });
122
+
123
+ it("refuses an image not pinned by digest, before touching anything", async () => {
124
+ const { run, calls } = fakeGcloud();
125
+ const r = await ensureConvergeJob(
126
+ { ...TARGET, opsImageRef: "ghcr.io/caelo-cms/ops:latest" },
127
+ { run },
128
+ );
129
+ expect(r.ok).toBe(false);
130
+ expect(calls).toEqual([]);
131
+ });
132
+
133
+ it("needs the install's state in its own ops bucket (the job attaches from it)", async () => {
134
+ const { run, calls } = fakeGcloud([
135
+ [/^storage buckets describe/, fail("404 bucket not found")],
136
+ ]);
137
+ const r = await ensureConvergeJob(TARGET, { run });
138
+ expect(r.ok ? "" : r.error).toContain("cms-provision state migrate");
139
+ expect(grants(calls)).toEqual([]);
140
+ });
141
+
142
+ it("stops at the first failed grant and says which", async () => {
143
+ const { run, calls } = fakeGcloud([
144
+ [/add-iam-policy-binding acme .*cloudscheduler/, fail("PERMISSION_DENIED")],
145
+ ]);
146
+ const r = await ensureConvergeJob(TARGET, { run, sleep: async () => {} });
147
+ expect(r.ok ? "" : r.error).toContain(
148
+ "grant the provisioner roles/cloudscheduler.admin: PERMISSION_DENIED",
149
+ );
150
+ expect(calls.some((c) => c.slice(0, 3).join(" ") === "run jobs deploy")).toBe(false);
151
+ });
152
+ });
@@ -0,0 +1,268 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Set up (and converge) the converge job on a Google Cloud install (`gcp`,
5
+ * `gcp-firebase`), #819. Run by the wizard (laptop, Cloud Shell or the
6
+ * install job) after migrations and by every converge — `cms-provision
7
+ * upgrade` and the converge job itself — so the job always runs the ops
8
+ * image of the release the install last converged to.
9
+ *
10
+ * What it is. A release that raises the infrastructure contract
11
+ * (infra-contract.ts) needs rights the upgrader job deliberately lacks:
12
+ * IAM bindings, service accounts, secrets. Until #819 that meant the Owner
13
+ * ran the upgrade from a terminal with their own Google account. The
14
+ * converge job runs the very same upgrade (upgrade/upgrade.ts: backup,
15
+ * converge, roll, health check, rollback, records) from inside the
16
+ * install's project, as the install's existing `caelo-provisioner` service
17
+ * account — the account the install job already runs `pulumi up` as and
18
+ * that already holds every right a converge needs (gcloud.ts
19
+ * PROVISIONER_ROLES). No new privileged identity is created.
20
+ *
21
+ * Trust boundary — the same shape as the upgrader job (upgrader-setup.ts)
22
+ * and the operator-access job. The admin's runtime service account gets
23
+ * exactly two roles, both on this one job:
24
+ *
25
+ * - `roles/run.jobsExecutor`: `run.jobs.run` WITHOUT
26
+ * `run.jobs.runWithOverrides` — it may start the job, never change its
27
+ * image, command, args or env (cloud.google.com/run/docs/reference/iam/roles);
28
+ * - `roles/run.viewer`: read its executions (the page's "Continue" for a
29
+ * run that died, and the dead-run check).
30
+ *
31
+ * The job takes no input but the database: its only input is the request
32
+ * row's target version. It fetches that release's manifest from the FIXED
33
+ * registry, verifies its signature with cosign, refuses a downgrade or a
34
+ * release below `minFromVersion`, and then runs only that release's signed
35
+ * ops image, pinned by the digest the manifest names (converge-job.ts). So a
36
+ * compromised admin can at most make the install converge to a newer
37
+ * signed Caelo release — what the Owner's click does anyway.
38
+ *
39
+ * What the provisioner gets here, on top of what it holds since install:
40
+ *
41
+ * - {@link INSTALL_JOB_ROLES} (the operator-access step of a converge
42
+ * creates a custom role and a Cloud Scheduler job, and the job's lines
43
+ * must reach Cloud Logging) — what an install-job install already
44
+ * grants; not an escalation, the provisioner holds projectIamAdmin;
45
+ * - a Cloud SQL IAM database login that is a member of `platform_updater`
46
+ * only (migration 0267): it reads its request and writes the timeline,
47
+ * nothing else in the database.
48
+ */
49
+
50
+ import { ensureCloudSqlIamUser } from "./cloudsql-iam-user.js";
51
+ import { gcloud as defaultGcloud, type GcloudResult } from "./gcloud.js";
52
+ import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
53
+ import { convergeJobName, runServiceAccountEmail } from "./gcp-names.js";
54
+ import { INSTALL_JOB_ROLES } from "./install-job.js";
55
+ import { provisionerServiceAccountEmail } from "./provisioner-identity.js";
56
+ import { opsBucketName } from "./state-backend.js";
57
+ import { UPGRADER_DB_ROLE } from "./upgrader-setup.js";
58
+
59
+ /**
60
+ * The job's task timeout in minutes, and the lease on the state lock it
61
+ * holds (state-lock-lease.ts): after it, Cloud Run has killed the run and a
62
+ * new execution resumes from the checkpoint. A converge plus a roll takes
63
+ * 10–20 minutes; the live 0.10.51 run needed 7 before its roll.
64
+ */
65
+ export const CONVERGE_TASK_TIMEOUT_MINUTES = 60;
66
+
67
+ /**
68
+ * Exactly what the admin's runtime service account may do on the converge
69
+ * job — start it (no overrides) and read its runs. The setup grants these
70
+ * two and nothing else; the tests assert it.
71
+ */
72
+ export const CONVERGE_ADMIN_ROLES = ["roles/run.jobsExecutor", "roles/run.viewer"] as const;
73
+
74
+ /**
75
+ * The job's entry point and its one argument. `caelo-converge-job`
76
+ * (packages/provisioning/ops/caelo-converge-job, installed by
77
+ * Dockerfile.ops) refuses every other argument and starts the job with a
78
+ * scrubbed environment, so even an execution override could not turn the
79
+ * provisioner's job into `destroy` or point gcloud/Pulumi elsewhere
80
+ * (#883 review). Nobody holds run.jobs.runWithOverrides on it anyway.
81
+ */
82
+ export const CONVERGE_JOB_ENTRYPOINT = "caelo-converge-job";
83
+ export const CONVERGE_JOB_COMMAND = "converge-job";
84
+
85
+ /** Resources: the converge runs gcloud + Pulumi's Node language host for the stack pin. */
86
+ const JOB_CPU = "2";
87
+ const JOB_MEMORY = "4Gi";
88
+
89
+ export interface ConvergeSetupTarget {
90
+ readonly projectId: string;
91
+ readonly region: string;
92
+ /** Pulumi stack name (`production`). */
93
+ readonly env: string;
94
+ /** The caelo-ops image to run, digest-pinned (`…/ops@sha256:…`). */
95
+ readonly opsImageRef: string;
96
+ /** VPC the admin uses to reach Cloud SQL's private IP (the job's queue does too). */
97
+ readonly network: string;
98
+ readonly subnet: string;
99
+ }
100
+
101
+ export type ConvergeSetupResult =
102
+ | { readonly ok: true; readonly done: string[] }
103
+ | {
104
+ readonly ok: false;
105
+ readonly done: string[];
106
+ readonly error: string;
107
+ /**
108
+ * `no-provisioner`: this install has no caelo-provisioner, which the
109
+ * job would run as. Not a failure of the converge (#883 review): such
110
+ * an install keeps installing cloud-setup releases from a terminal,
111
+ * and its admin, without CAELO_CONVERGE_JOB, says so.
112
+ */
113
+ readonly reason?: "no-provisioner";
114
+ };
115
+
116
+ /**
117
+ * The `gcloud run jobs deploy` argv of the converge job: the ops image by
118
+ * digest, the provisioner as its identity, no env and no secrets, the one
119
+ * fixed subcommand. Exported for the tests that pin the boundary.
120
+ */
121
+ export function convergeJobDeployArgs(target: ConvergeSetupTarget): string[] {
122
+ return [
123
+ "run",
124
+ "jobs",
125
+ "deploy",
126
+ convergeJobName(target.env),
127
+ `--image=${target.opsImageRef}`,
128
+ `--region=${target.region}`,
129
+ `--project=${target.projectId}`,
130
+ `--service-account=${provisionerServiceAccountEmail(target.projectId)}`,
131
+ `--network=${target.network}`,
132
+ `--subnet=${target.subnet}`,
133
+ "--vpc-egress=private-ranges-only",
134
+ `--command=${CONVERGE_JOB_ENTRYPOINT}`,
135
+ `--args=${CONVERGE_JOB_COMMAND}`,
136
+ // No env and no secrets on the job, whatever an earlier deploy set.
137
+ "--clear-env-vars",
138
+ "--clear-secrets",
139
+ "--tasks=1",
140
+ // A retry would start a second converge inside the same execution; a
141
+ // killed run is resumed by starting the job again (checkpointed).
142
+ "--max-retries=0",
143
+ `--task-timeout=${CONVERGE_TASK_TIMEOUT_MINUTES}m`,
144
+ `--cpu=${JOB_CPU}`,
145
+ `--memory=${JOB_MEMORY}`,
146
+ "--labels=caelo-role=converge",
147
+ "--quiet",
148
+ ];
149
+ }
150
+
151
+ /** Ensure the converge job and everything it needs. Never throws for a gcloud failure. */
152
+ export async function ensureConvergeJob(
153
+ target: ConvergeSetupTarget,
154
+ deps: { run?: GcloudRunner; sleep?: Sleep } = {},
155
+ ): Promise<ConvergeSetupResult> {
156
+ const run = deps.run ?? defaultGcloud;
157
+ const sleep = deps.sleep ?? realSleep;
158
+ const retry = (args: string[]) => runWithRetry(run, sleep, args);
159
+ const { projectId, region, env } = target;
160
+ const project = `--project=${projectId}`;
161
+ const provisioner = provisionerServiceAccountEmail(projectId);
162
+ const member = `serviceAccount:${provisioner}`;
163
+ const adminMember = `serviceAccount:${runServiceAccountEmail(projectId, env)}`;
164
+ const job = convergeJobName(env);
165
+ const bucket = opsBucketName(projectId);
166
+ const done: string[] = [];
167
+ const fail = (step: string, r: GcloudResult): ConvergeSetupResult => ({
168
+ ok: false,
169
+ done,
170
+ error: `${step}: ${r.stderr.trim()}`,
171
+ });
172
+
173
+ if (!/@sha256:[0-9a-f]{64}$/.test(target.opsImageRef)) {
174
+ return {
175
+ ok: false,
176
+ done,
177
+ error: `the converge job's image must be pinned by digest, got ${target.opsImageRef}`,
178
+ };
179
+ }
180
+
181
+ // The job attaches the install from its ops bucket and keeps its
182
+ // checkpoints there; an install whose state is on a laptop has neither.
183
+ const bucketCheck = await run([
184
+ "storage",
185
+ "buckets",
186
+ "describe",
187
+ `gs://${bucket}`,
188
+ project,
189
+ "--format=value(name)",
190
+ ]);
191
+ if (!bucketCheck.ok) {
192
+ return {
193
+ ok: false,
194
+ done,
195
+ error: `the ops bucket gs://${bucket} is not there (${bucketCheck.stderr.trim()}). Move the install's state into its own project first: cms-provision state migrate`,
196
+ };
197
+ }
198
+
199
+ // The job's identity is the install's provisioner — never created here:
200
+ // an install without one (installed before it existed) keeps converging
201
+ // from a terminal.
202
+ const describe = await run([
203
+ "iam",
204
+ "service-accounts",
205
+ "describe",
206
+ provisioner,
207
+ project,
208
+ "--format=value(email)",
209
+ ]);
210
+ if (!describe.ok) {
211
+ return {
212
+ ok: false,
213
+ done,
214
+ reason: "no-provisioner",
215
+ error: `this install has no ${provisioner} service account (${describe.stderr.trim()}), which the converge job runs as. Updates that change the cloud setup keep running from a terminal (Settings → Updates shows the command).`,
216
+ };
217
+ }
218
+
219
+ // 1. What a converge run as the provisioner needs beyond the install roles.
220
+ for (const role of INSTALL_JOB_ROLES) {
221
+ const r = await retry([
222
+ "projects",
223
+ "add-iam-policy-binding",
224
+ projectId,
225
+ `--member=${member}`,
226
+ `--role=${role}`,
227
+ "--condition=None",
228
+ "--quiet",
229
+ "--format=none",
230
+ ]);
231
+ if (!r.ok) return fail(`grant the provisioner ${role}`, r);
232
+ done.push(`provisioner: ${role} on the project`);
233
+ }
234
+
235
+ // 2. Its database login: the request row and the timeline, nothing else.
236
+ const sqlUser = await ensureCloudSqlIamUser(run, {
237
+ projectId,
238
+ env,
239
+ serviceAccountEmail: provisioner,
240
+ databaseRole: UPGRADER_DB_ROLE,
241
+ });
242
+ done.push(...sqlUser.done);
243
+ if (!sqlUser.ok) return { ok: false, done, error: sqlUser.error };
244
+
245
+ // 3. The job itself.
246
+ const deploy = await run(convergeJobDeployArgs(target));
247
+ if (!deploy.ok) return fail(`deploy job ${job}`, deploy);
248
+ done.push(`job ${job} (${target.opsImageRef.split("@")[1]?.slice(0, 19)}...)`);
249
+
250
+ // 4. The admin may start it (no overrides) and read its runs — nothing else.
251
+ for (const role of CONVERGE_ADMIN_ROLES) {
252
+ const r = await retry([
253
+ "run",
254
+ "jobs",
255
+ "add-iam-policy-binding",
256
+ job,
257
+ `--region=${region}`,
258
+ project,
259
+ `--member=${adminMember}`,
260
+ `--role=${role}`,
261
+ "--quiet",
262
+ "--format=none",
263
+ ]);
264
+ if (!r.ok) return fail(`grant the admin ${role} on ${job}`, r);
265
+ done.push(`admin: ${role} on ${job}`);
266
+ }
267
+ return { ok: true, done };
268
+ }