@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,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
+ });
@@ -0,0 +1,443 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The real side effects of the converge job on Google Cloud (#819):
5
+ * gcloud as the job's own identity (the install's caelo-provisioner), the
6
+ * install's ops bucket for install.json, the state lock and the checkpoint,
7
+ * the fixed release registry, cosign, and — for stage 2 — the terminal's
8
+ * upgrade itself (upgrade/upgrade.ts) wired the way lifecycle.ts wires it,
9
+ * minus the operator-identity step: this run IS the provisioner.
10
+ * converge-job.ts takes them as {@link ConvergeJobDeps}; the database queue
11
+ * and the job log come from `@caelo-cms/platform-updater`.
12
+ *
13
+ * Nothing here reads the process env or argv (CLOUD_RUN_EXECUTION, read by
14
+ * the entry point, only names this run): what the job may install and from
15
+ * where is compiled in (release-manifest.ts's registry + signer), and where
16
+ * it runs comes from the metadata server.
17
+ */
18
+
19
+ import { mkdtempSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+ import { CAELO_VERSION } from "@caelo-cms/shared";
23
+ import { readConvergeCheckpoint, writeConvergeCheckpoint } from "./converge-checkpoint.js";
24
+ import type { ConvergeJobDeps, ConvergeQueue, ConvergeUpgradeOutcome } from "./converge-job.js";
25
+ import { CONVERGE_TASK_TIMEOUT_MINUTES } from "./converge-job-setup.js";
26
+ import { gcloud } from "./gcloud.js";
27
+ import { type GcloudRunner, realSleep } from "./gcloud-retry.js";
28
+ import {
29
+ convergeJobName,
30
+ GCP_STACK_ENV,
31
+ gcpNamePrefix,
32
+ operatorAccessJobName,
33
+ } from "./gcp-names.js";
34
+ import { INFRA_CONTRACT_VERSION } from "./infra-contract.js";
35
+ import { describeExecution } from "./install-job.js";
36
+ import {
37
+ type InstallMetadata,
38
+ installRoot,
39
+ installStagingHost,
40
+ readMetadata,
41
+ recordInfraContractVersion,
42
+ } from "./install-state.js";
43
+ import { runMigrationsViaCloudRunJob } from "./migration-runner.js";
44
+ import { installIapAllowlist } from "./operator-access.js";
45
+ import { fetchVerifiedReleaseManifest } from "./release-manifest.js";
46
+ import type { HttpFetch } from "./runtime-secrets.js";
47
+ import { publicSiteUrl, STAGING_HOST_ENV } from "./stack-contract.js";
48
+ import { attachInstall } from "./state-attach.js";
49
+ import { hasLocalPulumiState, opsBucketName } from "./state-backend.js";
50
+ import { acquireBucketStateLock, GcsStateStore, realSyncGcloud } from "./state-store.js";
51
+ import { resolveGcpResourceName } from "./upgrade/gcp-lookup.js";
52
+ import { releaseImageRef, verifyCosignAll } from "./upgrade/release-images.js";
53
+ import { upgradeGcpInstallOutcome } from "./upgrade/upgrade.js";
54
+ import { healthProbeFor, type UpgraderPlacement } from "./upgrader-gcp.js";
55
+ import type { JobLog } from "./upgrader-reporter.js";
56
+
57
+ const DIGEST = /@(sha256:[0-9a-f]{64})/;
58
+
59
+ /**
60
+ * The image digest an execution of the converge job runs, from its Cloud
61
+ * Run description (the execution's task template names the image it was
62
+ * started with). Null when it cannot be read or names no digest.
63
+ */
64
+ export function executionImageDigest(describeJson: string): string | null {
65
+ let parsed: unknown;
66
+ try {
67
+ parsed = JSON.parse(describeJson);
68
+ } catch {
69
+ return null;
70
+ }
71
+ // gcloud prints the v1 resource (spec.template.spec.containers); the v2
72
+ // shape (template.containers) is accepted too. The first container is it.
73
+ const p = parsed as {
74
+ spec?: { template?: { spec?: { containers?: { image?: string }[] } } };
75
+ template?: { containers?: { image?: string }[] };
76
+ };
77
+ const image =
78
+ p.spec?.template?.spec?.containers?.[0]?.image ?? p.template?.containers?.[0]?.image ?? "";
79
+ return DIGEST.exec(image)?.[1] ?? null;
80
+ }
81
+
82
+ /** The operator-access job's env var listing the install's fixed IAP members. */
83
+ const STATIC_MEMBERS_ENV = "CAELO_OPERATOR_ACCESS_STATIC_MEMBERS";
84
+
85
+ /** The admin env var naming the public site (stack-contract.ts adminEnvContract). */
86
+ const SITE_URL_ENV = "CAELO_SITE_URL";
87
+
88
+ /**
89
+ * Why install.json's Owner email does not match the IAP members the
90
+ * deployed operator-access job admits (operator-access.ts deploys them as
91
+ * `installIapAllowlist(ownerEmail)`), or null when it does. Read from
92
+ * `gcloud run jobs describe --format=json` (v1 or v2 shape).
93
+ */
94
+ export function deployedOwnerMismatch(describeJson: string, ownerEmail: string): string | null {
95
+ let parsed: unknown;
96
+ try {
97
+ parsed = JSON.parse(describeJson);
98
+ } catch {
99
+ return "the operator-access job's description is not JSON";
100
+ }
101
+ const p = parsed as {
102
+ spec?: {
103
+ template?: {
104
+ spec?: {
105
+ template?: { spec?: { containers?: { env?: { name?: string; value?: string }[] }[] } };
106
+ };
107
+ };
108
+ };
109
+ template?: { template?: { containers?: { env?: { name?: string; value?: string }[] }[] } };
110
+ };
111
+ const env =
112
+ p.spec?.template?.spec?.template?.spec?.containers?.[0]?.env ??
113
+ p.template?.template?.containers?.[0]?.env ??
114
+ [];
115
+ const deployed = env.find((e) => e.name === STATIC_MEMBERS_ENV)?.value;
116
+ if (deployed === undefined) return `the operator-access job sets no ${STATIC_MEMBERS_ENV}`;
117
+ const want = installIapAllowlist(ownerEmail).join(",");
118
+ const have = deployed
119
+ .split(",")
120
+ .map((m) => m.trim().toLowerCase())
121
+ .filter(Boolean)
122
+ .sort()
123
+ .join(",");
124
+ if (have !== [...installIapAllowlist(ownerEmail)].sort().join(",")) {
125
+ return `the admin admits ${deployed || "nobody"}, the records would make it ${want}`;
126
+ }
127
+ return null;
128
+ }
129
+
130
+ /**
131
+ * Why install.json's site does not match what the admin serves today, or
132
+ * null when it does (#883 review, round 2). install.json is writable by the
133
+ * upgrader's account, and the converge hands its domain, staging host and
134
+ * region to code running as the provisioner: a tampered record would have
135
+ * the provisioner point the site, its certificate and its IAP gate at
136
+ * another domain. So the job converges only onto the site the deployed
137
+ * admin already serves (`CAELO_SITE_URL`, `CAELO_STAGING_HOST`, from
138
+ * `gcloud run services describe --format=json`) in the region this job
139
+ * runs in. A change of domain is a terminal upgrade by the Owner.
140
+ *
141
+ * @param adminJson the admin service's description (v1 or v2 shape)
142
+ * @param region the region this job runs in (the metadata server's)
143
+ */
144
+ export function deployedSiteMismatch(
145
+ adminJson: string,
146
+ meta: Pick<InstallMetadata, "domain" | "stagingDomain" | "region">,
147
+ region: string,
148
+ ): string | null {
149
+ if (meta.region !== region) {
150
+ return `the records name region ${meta.region ?? "none"}, the converge job runs in ${region}`;
151
+ }
152
+ let parsed: unknown;
153
+ try {
154
+ parsed = JSON.parse(adminJson);
155
+ } catch {
156
+ return "the admin's description is not JSON";
157
+ }
158
+ const p = parsed as {
159
+ spec?: {
160
+ template?: { spec?: { containers?: { env?: { name?: string; value?: string }[] }[] } };
161
+ };
162
+ template?: { containers?: { env?: { name?: string; value?: string }[] }[] };
163
+ };
164
+ const env =
165
+ p.spec?.template?.spec?.containers?.[0]?.env ?? p.template?.containers?.[0]?.env ?? [];
166
+ const value = (name: string) => env.find((e) => e.name === name)?.value;
167
+ const site = value(SITE_URL_ENV);
168
+ if (site === undefined) return `the admin sets no ${SITE_URL_ENV}`;
169
+ if (site !== publicSiteUrl(meta.domain)) {
170
+ return `the admin serves ${site}, the records would make it ${publicSiteUrl(meta.domain)}`;
171
+ }
172
+ // An admin from before #650 has no staging host yet; this converge adds
173
+ // it, but only the default one derived from the domain.
174
+ const staging = value(STAGING_HOST_ENV);
175
+ const want = installStagingHost(meta);
176
+ const defaultHost = installStagingHost({ domain: meta.domain });
177
+ if (staging === undefined ? want !== defaultHost : staging !== want) {
178
+ return `the admin's staging host is ${staging ?? "unset"}, the records would make it ${want}`;
179
+ }
180
+ return null;
181
+ }
182
+
183
+ /**
184
+ * Why `CLOUD_RUN_EXECUTION` cannot name a run of the converge job, or null
185
+ * when it can. The name keys the state lock's holder and the hand-over
186
+ * between the job's two stages, so a value that names another job's run
187
+ * (or is not a Cloud Run name at all) stops the job before it acts
188
+ * (#883 review). Cloud Run sets it and rejects user values for it; this is
189
+ * the second line.
190
+ */
191
+ export function convergeExecutionNameError(execution: string | null): string | null {
192
+ if (execution === null) return null;
193
+ const prefix = `${convergeJobName(GCP_STACK_ENV)}-`;
194
+ if (!/^[a-z0-9-]{1,63}$/.test(execution) || !execution.startsWith(prefix)) {
195
+ return `CLOUD_RUN_EXECUTION is "${execution.slice(0, 80)}", not a run of ${convergeJobName(GCP_STACK_ENV)}`;
196
+ }
197
+ return null;
198
+ }
199
+
200
+ /** The new execution's name from `gcloud run jobs execute --async`. */
201
+ export function startedExecutionName(stdout: string, stderr: string): string | null {
202
+ const name = stdout.trim().split(/\s+/)[0] || /Execution \[([^\]]+)\]/.exec(stderr)?.[1] || "";
203
+ return /^[a-z0-9-]+$/.test(name) ? name : null;
204
+ }
205
+
206
+ /** Every dependency of {@link runConvergeJob} except the database and the log. */
207
+ export function gcpConvergeDeps(
208
+ placement: UpgraderPlacement,
209
+ queue: ConvergeQueue,
210
+ log: JobLog,
211
+ ): ConvergeJobDeps {
212
+ const run: GcloudRunner = gcloud;
213
+ const http = fetch as unknown as HttpFetch;
214
+ const { projectId, region } = placement;
215
+ const bucket = opsBucketName(projectId);
216
+ const job = convergeJobName(GCP_STACK_ENV);
217
+ const location = [`--region=${region}`, `--project=${projectId}`];
218
+ // Peeks only (uncached); the upgrade writes through install-state.ts.
219
+ const store = new GcsStateStore(bucket, mkdtempSync(join(tmpdir(), "caelo-converge-")));
220
+
221
+ const upgrade: ConvergeJobDeps["upgrade"] = async ({
222
+ manifest: m,
223
+ reporter,
224
+ resume,
225
+ onCheckpoint,
226
+ }) => {
227
+ // The container starts empty: mirror the install from its bucket, as
228
+ // the install job does, so install-state.ts reads and writes it there.
229
+ const attached = attachInstall(projectId, {
230
+ run: realSyncGcloud,
231
+ rootFor: installRoot,
232
+ hasLocalState: hasLocalPulumiState,
233
+ });
234
+ if (!attached.ok) {
235
+ reporter.error(`Could not read the install's records: ${attached.error}`);
236
+ return { ok: false, rolledBack: false, unrecorded: false };
237
+ }
238
+ const installId = attached.installId;
239
+ // Lazily: the CLI's lifecycle module (clack, the wizards' Pulumi
240
+ // helpers) is only needed by stage 2, never by the package's importers.
241
+ const { recordRolledRelease } = await import("./lifecycle.js");
242
+ const meta = readMetadata(installId) as InstallMetadata;
243
+ if ((meta.provider !== "gcp" && meta.provider !== "gcp-firebase") || !meta.region) {
244
+ reporter.error(`install.json of ${installId} is not a Google Cloud install with a region.`);
245
+ return { ok: false, rolledBack: false, unrecorded: false };
246
+ }
247
+ // #883 review — install.json is writable by the upgrader's account
248
+ // (install/ in the ops bucket), and this job runs as the provisioner:
249
+ // the Owner email it hands the converge decides who passes the admin's
250
+ // IAP gate. Converge only when it matches what is deployed today; a
251
+ // change of Owner is a terminal upgrade by the Owner.
252
+ const access = await run([
253
+ "run",
254
+ "jobs",
255
+ "describe",
256
+ operatorAccessJobName(GCP_STACK_ENV),
257
+ ...location,
258
+ "--format=json",
259
+ ]);
260
+ const ownerCheck = access.ok
261
+ ? deployedOwnerMismatch(access.stdout, meta.ownerEmail)
262
+ : `the deployed operator-access job could not be read (${access.stderr.trim()})`;
263
+ if (ownerCheck) {
264
+ reporter.error(
265
+ `Nothing was changed: the install's records name ${meta.ownerEmail} as Owner, which this update could not confirm against the admin's current access settings (${ownerCheck}). Run the update once from Cloud Shell or the computer you installed from.`,
266
+ );
267
+ return { ok: false, rolledBack: false, unrecorded: false };
268
+ }
269
+ const admin = await resolveGcpResourceName(
270
+ run,
271
+ "run-service",
272
+ `${gcpNamePrefix(GCP_STACK_ENV)}-admin`,
273
+ projectId,
274
+ region,
275
+ );
276
+ const described = admin
277
+ ? await run(["run", "services", "describe", admin, ...location, "--format=json"])
278
+ : null;
279
+ const siteCheck = !admin
280
+ ? "the admin service could not be found"
281
+ : described?.ok
282
+ ? deployedSiteMismatch(described.stdout, meta, region)
283
+ : `the admin's description could not be read (${described?.stderr.trim() ?? ""})`;
284
+ if (siteCheck) {
285
+ reporter.error(
286
+ `Nothing was changed: the install's records name ${meta.domain} in ${meta.region}, which this update could not confirm against what the admin serves today (${siteCheck}). Run the update once from Cloud Shell or the computer you installed from.`,
287
+ );
288
+ return { ok: false, rolledBack: false, unrecorded: false };
289
+ }
290
+ let recorded = true;
291
+ let converged = false;
292
+ const outcome = await upgradeGcpInstallOutcome(
293
+ {
294
+ provider: meta.provider,
295
+ projectId,
296
+ region: meta.region,
297
+ env: GCP_STACK_ENV,
298
+ domain: meta.domain,
299
+ stagingHost: installStagingHost(meta),
300
+ ownerEmail: meta.ownerEmail,
301
+ targetTag: m.version,
302
+ // This code is the target release's (stage 2), so its contract is it.
303
+ infraContractVersion: INFRA_CONTRACT_VERSION,
304
+ skipVerify: false,
305
+ skipHealthCheck: false,
306
+ migrationsContract: m.migrations.contract,
307
+ },
308
+ {
309
+ run,
310
+ http,
311
+ sleep: realSleep,
312
+ reporter,
313
+ // The signed manifest's digests — never a registry tag lookup.
314
+ resolveImages: async () => ({
315
+ ok: true,
316
+ images: {
317
+ admin: { digest: m.digests.admin, imageRef: releaseImageRef("admin", m.digests.admin) },
318
+ gateway: {
319
+ digest: m.digests.gateway,
320
+ imageRef: releaseImageRef("gateway", m.digests.gateway),
321
+ },
322
+ },
323
+ }),
324
+ verifyImages: verifyCosignAll,
325
+ migrate: (imageRef) =>
326
+ runMigrationsViaCloudRunJob(
327
+ { projectId, region: meta.region as string, imageOverride: imageRef },
328
+ { run },
329
+ ),
330
+ healthProbe: (plan) => healthProbeFor(run, http, plan),
331
+ recordDigests: async (digests) => {
332
+ recorded = await recordRolledRelease(
333
+ installId,
334
+ meta.provider as "gcp" | "gcp-firebase",
335
+ digests,
336
+ m.version,
337
+ );
338
+ },
339
+ resolveOpsImage: async () => releaseImageRef("ops", m.digests.ops),
340
+ recordInfraContractVersion: (version) => {
341
+ recordInfraContractVersion(installId, version);
342
+ converged = true;
343
+ },
344
+ ...(resume ? { resume } : {}),
345
+ onCheckpoint,
346
+ },
347
+ );
348
+ // rolled_back only when the rollback really put the previous release
349
+ // back (app-roll.ts reports it structurally), never from log text.
350
+ const result: ConvergeUpgradeOutcome =
351
+ outcome.ok && recorded
352
+ ? { ok: true, converged }
353
+ : {
354
+ ok: false,
355
+ rolledBack: !outcome.ok && outcome.rolledBack,
356
+ unrecorded: outcome.ok && !recorded,
357
+ };
358
+ return result;
359
+ };
360
+
361
+ return {
362
+ queue,
363
+ projectId,
364
+ execution: placement.execution,
365
+ ownVersion: CAELO_VERSION,
366
+ // The default deps: the fixed registry and signer, never overridden.
367
+ fetchManifest: (version) => fetchVerifiedReleaseManifest(version),
368
+ readInstall: () => {
369
+ const text = store.peek("install.json");
370
+ return text === null ? null : (JSON.parse(text) as InstallMetadata);
371
+ },
372
+ verifyImage: (slug, digest) => verifyCosignAll([{ slug, digest }]),
373
+ ownImageDigest: async () => {
374
+ if (!placement.execution) return null;
375
+ const r = await run([
376
+ "run",
377
+ "jobs",
378
+ "executions",
379
+ "describe",
380
+ placement.execution,
381
+ ...location,
382
+ "--format=json",
383
+ ]);
384
+ return r.ok ? executionImageDigest(r.stdout) : null;
385
+ },
386
+ pointJobAt: async (digest) => {
387
+ // Only the image changes: the identity, command and args stay what
388
+ // converge-job-setup.ts deployed.
389
+ const update = await run([
390
+ "run",
391
+ "jobs",
392
+ "update",
393
+ job,
394
+ `--image=${releaseImageRef("ops", digest)}`,
395
+ ...location,
396
+ "--quiet",
397
+ ]);
398
+ return update.ok
399
+ ? { ok: true }
400
+ : { ok: false, error: `update ${job}: ${update.stderr.trim()}` };
401
+ },
402
+ startJob: async () => {
403
+ const exec = await run([
404
+ "run",
405
+ "jobs",
406
+ "execute",
407
+ job,
408
+ ...location,
409
+ "--async",
410
+ "--format=value(metadata.name)",
411
+ ]);
412
+ if (!exec.ok) return { ok: false, error: `start ${job}: ${exec.stderr.trim()}` };
413
+ const execution = startedExecutionName(exec.stdout, exec.stderr);
414
+ if (!execution) {
415
+ return {
416
+ ok: false,
417
+ error: `${job} started but gcloud did not name the execution (${exec.stderr.trim()})`,
418
+ };
419
+ }
420
+ return { ok: true, execution };
421
+ },
422
+ executionState: (execution) => describeExecution(run, { projectId, region, execution }),
423
+ // #815 — the lock names this execution; a later run breaks it once
424
+ // Cloud Run says this one has ended (killed at its task timeout).
425
+ lockState: (onBreak) =>
426
+ acquireBucketStateLock(bucket, "caelo converge job", realSyncGcloud, {
427
+ onBreak,
428
+ ...(placement.execution
429
+ ? {
430
+ holder: {
431
+ execution: { projectId, region, name: placement.execution },
432
+ maxRuntimeMs: CONVERGE_TASK_TIMEOUT_MINUTES * 60_000,
433
+ },
434
+ }
435
+ : {}),
436
+ }),
437
+ readCheckpoint: () => readConvergeCheckpoint(bucket, realSyncGcloud),
438
+ writeCheckpoint: (checkpoint) => writeConvergeCheckpoint(bucket, checkpoint, realSyncGcloud),
439
+ upgrade,
440
+ now: () => new Date(),
441
+ log,
442
+ };
443
+ }