@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,486 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The converge job (converge-job.ts, #819) against fakes: an in-memory
5
+ * request queue, a fake signed manifest, a fake checkpoint store and a fake
6
+ * upgrade. Checks the two stages (stage 1 only verifies and hands over to
7
+ * the release's own signed installer; stage 2 converges under the state
8
+ * lock), that a tampered manifest stops the job before any change, that a
9
+ * killed run is resumed from its checkpoint, and that two runs never
10
+ * converge at once.
11
+ */
12
+
13
+ import { describe, expect, it } from "bun:test";
14
+ import type { StoredUpdatePolicy } from "@caelo-cms/shared";
15
+ import type { ConvergeCheckpoint } from "./converge-checkpoint.js";
16
+ import {
17
+ type ConvergeClaim,
18
+ type ConvergeJobDeps,
19
+ type ConvergeQueue,
20
+ type ConvergeUpgradeOutcome,
21
+ runConvergeJob,
22
+ } from "./converge-job.js";
23
+ import type { ExecutionState } from "./install-job.js";
24
+ import type { InstallMetadata } from "./install-state.js";
25
+ import type { ReleaseManifest, ReleaseManifestResult } from "./release-manifest.js";
26
+ import { StateLockHeldError } from "./state-store.js";
27
+ import type { UpgradeCheckpoint } from "./upgrade/upgrade.js";
28
+ import type { UpdateEnd, UpdateFacts } from "./upgrader-job.js";
29
+
30
+ const D = (c: string) => `sha256:${c.repeat(64)}`;
31
+ const REQUEST_ID = "11111111-2222-4333-8444-555555555555";
32
+
33
+ const MANIFEST: ReleaseManifest = {
34
+ manifestVersion: 1,
35
+ version: "0.11.0",
36
+ channel: "stable",
37
+ digests: { admin: D("a"), gateway: D("b"), ops: D("c") },
38
+ minFromVersion: "0.10.0",
39
+ // Raises the contract: what the converge job exists for.
40
+ infraContractVersion: 8,
41
+ migrations: { contract: false, lastContractVersion: null, head: "0303_x" },
42
+ security: false,
43
+ advisories: [],
44
+ };
45
+
46
+ const POLICY: StoredUpdatePolicy = {
47
+ mode: "notify",
48
+ autoSecurity: false,
49
+ autoPatch: false,
50
+ autoMinor: false,
51
+ channel: "stable",
52
+ windowStart: null,
53
+ windowEnd: null,
54
+ windowTimezone: "UTC",
55
+ soakHours: 24,
56
+ securitySoakHours: 2,
57
+ paused: false,
58
+ pausedReason: null,
59
+ pausedAt: null,
60
+ pausedUntil: null,
61
+ pausedByOwner: false,
62
+ updatedAt: "2026-10-19T00:00:00Z",
63
+ };
64
+
65
+ const META: InstallMetadata = {
66
+ installId: "gcp-firebase-acme",
67
+ provider: "gcp-firebase",
68
+ projectId: "acme",
69
+ domain: "acme.com",
70
+ ownerEmail: "owner@acme.com",
71
+ region: "europe-west1",
72
+ createdAt: "2026-01-01T00:00:00Z",
73
+ imageDigests: { admin: D("1"), gateway: D("2") },
74
+ releaseVersion: "0.10.53",
75
+ infraContractVersion: 7,
76
+ };
77
+
78
+ const BACKUP = { projectId: "acme", instance: "caelo-production-pg-x", backupId: "1760000000" };
79
+ const PRIOR = {
80
+ admin: "caelo-production-admin-old-00001",
81
+ gateway: "caelo-production-gw-old-00001",
82
+ };
83
+
84
+ /** In-memory queue: one request, recorded calls. */
85
+ function fakeQueue(claim: ConvergeClaim | null) {
86
+ const events: { phase: string; level: string; message: string }[] = [];
87
+ const ends: UpdateEnd[] = [];
88
+ const facts: UpdateFacts[] = [];
89
+ const handovers: { from: string | null; to: string | null }[] = [];
90
+ let handoverAnswer = true;
91
+ const queue: ConvergeQueue = {
92
+ claimConverge: async () => claim,
93
+ handover: async (_id, from, to) => {
94
+ handovers.push({ from, to });
95
+ return handoverAnswer;
96
+ },
97
+ event: async (_id, phase, level, message) => {
98
+ events.push({ phase, level, message });
99
+ },
100
+ record: async (_id, f) => {
101
+ facts.push(f);
102
+ },
103
+ finish: async (_id, end) => {
104
+ ends.push(end);
105
+ return true;
106
+ },
107
+ };
108
+ return {
109
+ queue,
110
+ events,
111
+ ends,
112
+ facts,
113
+ handovers,
114
+ refuseHandover: () => {
115
+ handoverAnswer = false;
116
+ },
117
+ };
118
+ }
119
+
120
+ const claimOf = (over: Partial<ConvergeClaim> = {}, trigger: "manual" | "auto" = "manual") => ({
121
+ request: { id: REQUEST_ID, targetVersion: "0.11.0", trigger, autoBasis: null, policy: POLICY },
122
+ resume: false,
123
+ execution: "caelo-production-converge-run1",
124
+ ...over,
125
+ });
126
+
127
+ interface Harness {
128
+ readonly deps: ConvergeJobDeps;
129
+ readonly calls: string[];
130
+ readonly q: ReturnType<typeof fakeQueue>;
131
+ readonly checkpoints: ConvergeCheckpoint[];
132
+ readonly upgrades: { resume: UpgradeCheckpoint | undefined }[];
133
+ }
134
+
135
+ function harness(
136
+ opts: {
137
+ claim?: ConvergeClaim | null;
138
+ ownVersion?: string;
139
+ manifest?: ReleaseManifestResult;
140
+ install?: InstallMetadata | null;
141
+ ownDigest?: string | null;
142
+ checkpoint?: ConvergeCheckpoint | null;
143
+ executions?: Record<string, ExecutionState>;
144
+ lock?: () => () => void;
145
+ outcome?: ConvergeUpgradeOutcome;
146
+ imageSigned?: boolean;
147
+ } = {},
148
+ ): Harness {
149
+ const calls: string[] = [];
150
+ const q = fakeQueue(opts.claim === undefined ? claimOf() : opts.claim);
151
+ const checkpoints: ConvergeCheckpoint[] = [];
152
+ const upgrades: { resume: UpgradeCheckpoint | undefined }[] = [];
153
+ let stored = opts.checkpoint ?? null;
154
+ const deps: ConvergeJobDeps = {
155
+ queue: q.queue,
156
+ projectId: "acme",
157
+ execution: "caelo-production-converge-run1",
158
+ ownVersion: opts.ownVersion ?? "0.11.0",
159
+ fetchManifest: async (v) => {
160
+ calls.push(`manifest ${v}`);
161
+ return opts.manifest ?? { ok: true, manifest: MANIFEST };
162
+ },
163
+ readInstall: () => (opts.install === undefined ? META : opts.install),
164
+ verifyImage: async (slug, digest) => {
165
+ calls.push(`verify ${slug} ${digest}`);
166
+ return opts.imageSigned ?? true;
167
+ },
168
+ ownImageDigest: async () => (opts.ownDigest === undefined ? D("c") : opts.ownDigest),
169
+ pointJobAt: async (digest) => {
170
+ calls.push(`point ${digest}`);
171
+ return { ok: true };
172
+ },
173
+ startJob: async () => {
174
+ calls.push("start");
175
+ return { ok: true, execution: "caelo-production-converge-run2" };
176
+ },
177
+ executionState: async (name) => opts.executions?.[name] ?? "missing",
178
+ lockState: () => {
179
+ calls.push("lock");
180
+ if (opts.lock) return opts.lock();
181
+ return () => calls.push("unlock");
182
+ },
183
+ readCheckpoint: () => stored,
184
+ writeCheckpoint: (c) => {
185
+ calls.push(`checkpoint ${c.state}`);
186
+ checkpoints.push(c);
187
+ stored = c;
188
+ },
189
+ upgrade: async ({ resume, onCheckpoint }) => {
190
+ calls.push("upgrade");
191
+ upgrades.push({ resume });
192
+ if (!resume?.priorRevisions) onCheckpoint({ priorRevisions: PRIOR });
193
+ if (!resume?.backup) onCheckpoint({ backup: BACKUP });
194
+ return opts.outcome ?? { ok: true, converged: true };
195
+ },
196
+ now: () => new Date("2026-10-19T12:00:00Z"),
197
+ log: () => {},
198
+ };
199
+ return { deps, calls, q, checkpoints, upgrades };
200
+ }
201
+
202
+ const checkpointOf = (over: Partial<ConvergeCheckpoint> = {}): ConvergeCheckpoint => ({
203
+ statusVersion: 1,
204
+ installId: META.installId,
205
+ execution: "caelo-production-converge-run0",
206
+ state: "running",
207
+ phase: "converge",
208
+ caeloVersion: "0.11.0",
209
+ startedAt: "2026-10-19T11:00:00Z",
210
+ updatedAt: "2026-10-19T11:10:00Z",
211
+ targetVersion: "0.11.0",
212
+ fromVersion: "0.10.53",
213
+ requestId: REQUEST_ID,
214
+ backup: BACKUP,
215
+ priorRevisions: PRIOR,
216
+ ...over,
217
+ });
218
+
219
+ describe("runConvergeJob — stage 1 (the installed release's installer)", () => {
220
+ it("verifies the manifest and the target's ops image, then hands over by digest and changes nothing else", async () => {
221
+ const h = harness({ ownVersion: "0.10.53" });
222
+ const r = await runConvergeJob(h.deps);
223
+ expect(r.exitCode).toBe(0);
224
+ expect(r.status).toBeNull();
225
+ expect(h.calls).toEqual([
226
+ "manifest 0.11.0",
227
+ `verify ops ${D("c")}`,
228
+ "lock",
229
+ `point ${D("c")}`,
230
+ "unlock",
231
+ "start",
232
+ ]);
233
+ // The request is released for the new run (before it exists), not ended.
234
+ expect(h.q.ends).toEqual([]);
235
+ expect(h.q.handovers).toEqual([{ from: "caelo-production-converge-run1", to: null }]);
236
+ expect(h.upgrades).toEqual([]);
237
+ expect(h.q.facts[0]?.opsDigest).toBe(D("c"));
238
+ });
239
+
240
+ it("an unsigned ops image stops the hand-over", async () => {
241
+ const h = harness({ ownVersion: "0.10.53", imageSigned: false });
242
+ const r = await runConvergeJob(h.deps);
243
+ expect(r.status).toBe("refused");
244
+ expect(h.calls.some((c) => /^(point|start)/.test(c))).toBe(false);
245
+ });
246
+ });
247
+
248
+ describe("runConvergeJob — what stops the job before any change", () => {
249
+ const nothingChanged = (h: Harness) => {
250
+ expect(h.calls.some((c) => /^(lock|point|start|upgrade|checkpoint)/.test(c))).toBe(false);
251
+ };
252
+
253
+ it("a tampered or unsigned manifest", async () => {
254
+ for (const stage of ["0.10.53", "0.11.0"]) {
255
+ const h = harness({
256
+ ownVersion: stage,
257
+ manifest: { ok: false, error: { code: "signature", message: "no valid signature" } },
258
+ });
259
+ const r = await runConvergeJob(h.deps);
260
+ expect(r.status).toBe("refused");
261
+ expect(h.q.ends[0]?.codes).toEqual(["signature"]);
262
+ nothingChanged(h);
263
+ }
264
+ });
265
+
266
+ it("a downgrade or the installed release", async () => {
267
+ const h = harness({ install: { ...META, releaseVersion: "0.12.0" } });
268
+ const r = await runConvergeJob(h.deps);
269
+ expect(r.status).toBe("refused");
270
+ expect(h.q.ends[0]?.codes).toEqual(["not-newer"]);
271
+ nothingChanged(h);
272
+ });
273
+
274
+ it("an automatic request: the policy has no switch for cloud-setup changes", async () => {
275
+ const h = harness({ claim: claimOf({}, "auto") });
276
+ const r = await runConvergeJob(h.deps);
277
+ expect(r.status).toBe("refused");
278
+ expect(h.q.ends[0]?.codes).toEqual(["policy"]);
279
+ nothingChanged(h);
280
+ });
281
+
282
+ it("an install whose records are not in its own project", async () => {
283
+ const h = harness({ install: null });
284
+ expect((await runConvergeJob(h.deps)).status).toBe("needs_cloud_shell");
285
+ nothingChanged(h);
286
+ });
287
+
288
+ it("stage 2 whose own image is not the one the manifest signs", async () => {
289
+ const h = harness({ ownDigest: D("9") });
290
+ const r = await runConvergeJob(h.deps);
291
+ expect(r.status).toBe("refused");
292
+ expect(h.q.ends[0]?.codes).toEqual(["ops-image"]);
293
+ nothingChanged(h);
294
+ });
295
+
296
+ it("another live install operation holding the state lock", async () => {
297
+ const h = harness({
298
+ lock: () => {
299
+ throw new StateLockHeldError("held by cms-provision upgrade");
300
+ },
301
+ });
302
+ const r = await runConvergeJob(h.deps);
303
+ expect(r.status).toBe("failed");
304
+ expect(h.q.ends[0]?.codes).toEqual(["state-locked"]);
305
+ expect(h.q.ends[0]?.reason).toStartWith("Nothing was changed");
306
+ expect(h.upgrades).toEqual([]);
307
+ });
308
+ });
309
+
310
+ describe("runConvergeJob — stage 2 (the target release's own installer)", () => {
311
+ it("checkpoints before the first change, converges under the lock, records the end", async () => {
312
+ const h = harness();
313
+ const r = await runConvergeJob(h.deps);
314
+ expect(r).toEqual({ exitCode: 0, requestId: REQUEST_ID, status: "succeeded" });
315
+ const first = h.calls.indexOf("checkpoint running");
316
+ expect(h.calls.indexOf("lock")).toBeLessThan(first);
317
+ expect(first).toBeLessThan(h.calls.indexOf("upgrade"));
318
+ expect(h.calls.at(-1)).toBe("unlock");
319
+ expect(h.upgrades).toEqual([{ resume: undefined }]);
320
+ // The backup and the rollback targets reached the checkpoint as they came.
321
+ const last = h.checkpoints.at(-1);
322
+ expect(last?.state).toBe("succeeded");
323
+ expect(last?.backup).toEqual(BACKUP);
324
+ expect(last?.priorRevisions).toEqual(PRIOR);
325
+ expect(h.q.ends[0]?.status).toBe("succeeded");
326
+ });
327
+
328
+ it("#883 review: install.json changed between the decision and the lock stops before any change", async () => {
329
+ // Judged against one record, but the upgrader job (or a terminal
330
+ // upgrade) wrote another before this run held the lock.
331
+ const h = harness();
332
+ let reads = 0;
333
+ const r = await runConvergeJob({
334
+ ...h.deps,
335
+ readInstall: () => {
336
+ reads += 1;
337
+ return reads === 1 ? META : { ...META, releaseVersion: "0.10.54" };
338
+ },
339
+ });
340
+ expect(r.status).toBe("failed");
341
+ expect(h.q.ends[0]?.codes).toEqual(["install-state-changed"]);
342
+ expect(h.q.ends[0]?.reason).toStartWith("Nothing was changed:");
343
+ expect(h.upgrades).toEqual([]);
344
+ expect(h.checkpoints).toEqual([]);
345
+ expect(h.calls.at(-1)).toBe("unlock");
346
+
347
+ // A re-read that fails is loud too, never a converge on the old view.
348
+ const failing = harness();
349
+ let n = 0;
350
+ const r2 = await runConvergeJob({
351
+ ...failing.deps,
352
+ readInstall: () => {
353
+ n += 1;
354
+ if (n > 1) throw new Error("storage.objects.get timed out");
355
+ return META;
356
+ },
357
+ });
358
+ expect(r2.status).toBe("failed");
359
+ expect(failing.q.ends[0]?.codes).toEqual(["install-state-read"]);
360
+ expect(failing.upgrades).toEqual([]);
361
+ });
362
+
363
+ it("a release that failed its checks ends rolled back and keeps the checkpoint for a retry", async () => {
364
+ const h = harness({ outcome: { ok: false, rolledBack: true, unrecorded: false } });
365
+ const r = await runConvergeJob(h.deps);
366
+ expect(r.status).toBe("rolled_back");
367
+ expect(r.exitCode).toBe(1);
368
+ expect(h.checkpoints.at(-1)?.state).toBe("failed");
369
+ expect(h.checkpoints.at(-1)?.backup).toEqual(BACKUP);
370
+ });
371
+
372
+ it("a roll install.json could not record fails the run (no fallbacks)", async () => {
373
+ const h = harness({ outcome: { ok: false, rolledBack: false, unrecorded: true } });
374
+ const r = await runConvergeJob(h.deps);
375
+ expect(r.status).toBe("failed");
376
+ expect(h.q.ends[0]?.codes).toEqual(["record"]);
377
+ });
378
+ });
379
+
380
+ describe("runConvergeJob — resume after a killed run", () => {
381
+ it("a run that died is adopted once Cloud Run says it ended, and resumes from the checkpoint", async () => {
382
+ const h = harness({
383
+ claim: claimOf({ resume: true, execution: "caelo-production-converge-run0" }),
384
+ // The killed run had already recorded the new release.
385
+ install: { ...META, releaseVersion: "0.11.0" },
386
+ checkpoint: checkpointOf(),
387
+ executions: { "caelo-production-converge-run0": "failed" },
388
+ });
389
+ const r = await runConvergeJob(h.deps);
390
+ expect(r.status).toBe("succeeded");
391
+ expect(h.q.handovers).toEqual([
392
+ { from: "caelo-production-converge-run0", to: "caelo-production-converge-run1" },
393
+ ]);
394
+ // Judged from where the update started, not from the half-written records.
395
+ expect(h.q.ends[0]?.codes).toEqual([]);
396
+ // No second backup, the original rollback targets.
397
+ expect(h.upgrades).toEqual([{ resume: { backup: BACKUP, priorRevisions: PRIOR } }]);
398
+ expect(h.q.events.some((e) => e.message.startsWith("Continuing the update"))).toBe(true);
399
+ });
400
+
401
+ it("never runs while the previous execution is still alive", async () => {
402
+ const h = harness({
403
+ claim: claimOf({ resume: true, execution: "caelo-production-converge-run0" }),
404
+ checkpoint: checkpointOf(),
405
+ executions: { "caelo-production-converge-run0": "running" },
406
+ });
407
+ const r = await runConvergeJob(h.deps);
408
+ expect(r).toEqual({ exitCode: 0, requestId: REQUEST_ID, status: null });
409
+ expect(h.q.handovers).toEqual([]);
410
+ expect(h.q.ends).toEqual([]);
411
+ expect(h.q.events).toEqual([]);
412
+ expect(h.calls).toEqual([]);
413
+ });
414
+
415
+ it("leaves the request alone when another run adopted it first", async () => {
416
+ const h = harness({
417
+ claim: claimOf({ resume: true, execution: "caelo-production-converge-run0" }),
418
+ executions: { "caelo-production-converge-run0": "failed" },
419
+ });
420
+ h.q.refuseHandover();
421
+ const r = await runConvergeJob(h.deps);
422
+ expect(r.status).toBeNull();
423
+ expect(h.q.ends).toEqual([]);
424
+ expect(h.calls).toEqual([]);
425
+ });
426
+
427
+ it("the stage-2 run adopts the request stage 1 released, without waiting for stage 1 to exit", async () => {
428
+ const h = harness({
429
+ claim: claimOf({ resume: true, execution: null }),
430
+ executions: { "caelo-production-converge-run0": "running" },
431
+ });
432
+ const r = await runConvergeJob(h.deps);
433
+ expect(r.status).toBe("succeeded");
434
+ expect(h.q.handovers).toEqual([{ from: null, to: "caelo-production-converge-run1" }]);
435
+ });
436
+
437
+ it("a stage-1 hand-over whose new run cannot start ends the request with nothing changed", async () => {
438
+ const h = harness({ ownVersion: "0.10.53" });
439
+ const deps = {
440
+ ...h.deps,
441
+ startJob: async () => ({ ok: false as const, error: "quota" }),
442
+ };
443
+ const r = await runConvergeJob(deps);
444
+ expect(r.status).toBe("failed");
445
+ expect(h.q.ends[0]?.codes).toEqual(["handover"]);
446
+ expect(h.q.ends[0]?.reason).toStartWith("Nothing was changed");
447
+ });
448
+
449
+ it("a finished converge whose end was never recorded is finished, not repeated", async () => {
450
+ const h = harness({
451
+ claim: claimOf({ resume: true, execution: "caelo-production-converge-run0" }),
452
+ install: { ...META, releaseVersion: "0.11.0", infraContractVersion: 8 },
453
+ checkpoint: checkpointOf({ state: "succeeded" }),
454
+ executions: { "caelo-production-converge-run0": "succeeded" },
455
+ });
456
+ const r = await runConvergeJob(h.deps);
457
+ expect(r.status).toBe("succeeded");
458
+ expect(h.upgrades).toEqual([]);
459
+ expect(h.calls.includes("lock")).toBe(false);
460
+ });
461
+
462
+ it("a checkpoint of another release starts fresh (new backup, new rollback targets)", async () => {
463
+ const h = harness({ checkpoint: checkpointOf({ targetVersion: "0.10.99", state: "failed" }) });
464
+ await runConvergeJob(h.deps);
465
+ expect(h.upgrades).toEqual([{ resume: undefined }]);
466
+ });
467
+
468
+ it("an earlier stopped try at the same release reuses its backup and the takeover note says changes started", async () => {
469
+ const h = harness({
470
+ checkpoint: checkpointOf({ state: "failed" }),
471
+ lock: () => {
472
+ throw new StateLockHeldError("held");
473
+ },
474
+ });
475
+ const r = await runConvergeJob(h.deps);
476
+ expect(r.status).toBe("failed");
477
+ expect(h.q.ends[0]?.reason).toStartWith("This update already started changing the site");
478
+ });
479
+ });
480
+
481
+ describe("runConvergeJob — nothing to do", () => {
482
+ it("exits cleanly without a converge request", async () => {
483
+ const h = harness({ claim: null });
484
+ expect(await runConvergeJob(h.deps)).toEqual({ exitCode: 0, requestId: null, status: null });
485
+ });
486
+ });