@caelo-cms/provisioning 0.10.55 → 0.10.58

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 (221) hide show
  1. package/dist/agent-db.js +97 -24
  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 +33 -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 +4 -0
  45. package/dist/operator-access.d.ts.map +1 -1
  46. package/dist/operator-access.js +43 -7
  47. package/dist/operator-access.js.map +1 -1
  48. package/dist/release-contract.d.ts +3 -2
  49. package/dist/release-contract.d.ts.map +1 -1
  50. package/dist/release-contract.js +5 -2
  51. package/dist/release-contract.js.map +1 -1
  52. package/dist/release-manifest.d.ts +2 -0
  53. package/dist/release-manifest.d.ts.map +1 -1
  54. package/dist/release-manifest.js.map +1 -1
  55. package/dist/remote-mcp-edge.d.ts +114 -0
  56. package/dist/remote-mcp-edge.d.ts.map +1 -0
  57. package/dist/remote-mcp-edge.js +116 -0
  58. package/dist/remote-mcp-edge.js.map +1 -0
  59. package/dist/remote-mcp.d.ts +139 -0
  60. package/dist/remote-mcp.d.ts.map +1 -0
  61. package/dist/remote-mcp.js +434 -0
  62. package/dist/remote-mcp.js.map +1 -0
  63. package/dist/runtime-secrets.d.ts +12 -0
  64. package/dist/runtime-secrets.d.ts.map +1 -1
  65. package/dist/runtime-secrets.js +32 -4
  66. package/dist/runtime-secrets.js.map +1 -1
  67. package/dist/seal-key-rotation.d.ts +38 -0
  68. package/dist/seal-key-rotation.d.ts.map +1 -0
  69. package/dist/seal-key-rotation.js +206 -0
  70. package/dist/seal-key-rotation.js.map +1 -0
  71. package/dist/stack-contract.d.ts +206 -3
  72. package/dist/stack-contract.d.ts.map +1 -1
  73. package/dist/stack-contract.js +219 -4
  74. package/dist/stack-contract.js.map +1 -1
  75. package/dist/stack-converge.d.ts +53 -1
  76. package/dist/stack-converge.d.ts.map +1 -1
  77. package/dist/stack-converge.js +124 -4
  78. package/dist/stack-converge.js.map +1 -1
  79. package/dist/state-lock-lease.d.ts +142 -0
  80. package/dist/state-lock-lease.d.ts.map +1 -0
  81. package/dist/state-lock-lease.js +347 -0
  82. package/dist/state-lock-lease.js.map +1 -0
  83. package/dist/state-store.d.ts +66 -9
  84. package/dist/state-store.d.ts.map +1 -1
  85. package/dist/state-store.js +249 -30
  86. package/dist/state-store.js.map +1 -1
  87. package/dist/upgrade/app-roll.d.ts +36 -7
  88. package/dist/upgrade/app-roll.d.ts.map +1 -1
  89. package/dist/upgrade/app-roll.js +53 -19
  90. package/dist/upgrade/app-roll.js.map +1 -1
  91. package/dist/upgrade/audit-job-roll.d.ts.map +1 -1
  92. package/dist/upgrade/audit-job-roll.js +3 -1
  93. package/dist/upgrade/audit-job-roll.js.map +1 -1
  94. package/dist/upgrade/converge.d.ts.map +1 -1
  95. package/dist/upgrade/converge.js +125 -12
  96. package/dist/upgrade/converge.js.map +1 -1
  97. package/dist/upgrade/gcp-lookup.d.ts +27 -10
  98. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  99. package/dist/upgrade/gcp-lookup.js +46 -7
  100. package/dist/upgrade/gcp-lookup.js.map +1 -1
  101. package/dist/upgrade/record-rolled.d.ts +40 -0
  102. package/dist/upgrade/record-rolled.d.ts.map +1 -0
  103. package/dist/upgrade/record-rolled.js +46 -0
  104. package/dist/upgrade/record-rolled.js.map +1 -0
  105. package/dist/upgrade/upgrade.d.ts +45 -1
  106. package/dist/upgrade/upgrade.d.ts.map +1 -1
  107. package/dist/upgrade/upgrade.js +60 -17
  108. package/dist/upgrade/upgrade.js.map +1 -1
  109. package/dist/upgrader-assess.d.ts +15 -1
  110. package/dist/upgrader-assess.d.ts.map +1 -1
  111. package/dist/upgrader-assess.js +36 -6
  112. package/dist/upgrader-assess.js.map +1 -1
  113. package/dist/upgrader-gcp.d.ts +15 -0
  114. package/dist/upgrader-gcp.d.ts.map +1 -1
  115. package/dist/upgrader-gcp.js +42 -7
  116. package/dist/upgrader-gcp.js.map +1 -1
  117. package/dist/upgrader-iam-preflight.d.ts +123 -0
  118. package/dist/upgrader-iam-preflight.d.ts.map +1 -0
  119. package/dist/upgrader-iam-preflight.js +414 -0
  120. package/dist/upgrader-iam-preflight.js.map +1 -0
  121. package/dist/upgrader-job.d.ts +33 -9
  122. package/dist/upgrader-job.d.ts.map +1 -1
  123. package/dist/upgrader-job.js +169 -27
  124. package/dist/upgrader-job.js.map +1 -1
  125. package/dist/upgrader-records.d.ts +58 -0
  126. package/dist/upgrader-records.d.ts.map +1 -0
  127. package/dist/upgrader-records.js +87 -0
  128. package/dist/upgrader-records.js.map +1 -0
  129. package/dist/upgrader-reporter.d.ts +3 -2
  130. package/dist/upgrader-reporter.d.ts.map +1 -1
  131. package/dist/upgrader-reporter.js +10 -2
  132. package/dist/upgrader-reporter.js.map +1 -1
  133. package/dist/upgrader-setup.d.ts +25 -3
  134. package/dist/upgrader-setup.d.ts.map +1 -1
  135. package/dist/upgrader-setup.js +123 -11
  136. package/dist/upgrader-setup.js.map +1 -1
  137. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  138. package/dist/wizards/gcp-cost.js +11 -3
  139. package/dist/wizards/gcp-cost.js.map +1 -1
  140. package/dist/wizards/gcp-pulumi.d.ts +6 -0
  141. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  142. package/dist/wizards/gcp-pulumi.js +14 -2
  143. package/dist/wizards/gcp-pulumi.js.map +1 -1
  144. package/dist/wizards/gcp.d.ts.map +1 -1
  145. package/dist/wizards/gcp.js +106 -4
  146. package/dist/wizards/gcp.js.map +1 -1
  147. package/package.json +2 -2
  148. package/src/agent/run.test.ts +14 -1
  149. package/src/audit-job-setup.test.ts +7 -1
  150. package/src/cli-routing.ts +1 -1
  151. package/src/cli.ts +4 -1
  152. package/src/compose.ts +5 -0
  153. package/src/converge-checkpoint.ts +75 -0
  154. package/src/converge-entrypoint.test.ts +213 -0
  155. package/src/converge-job-gcp.test.ts +222 -0
  156. package/src/converge-job-gcp.ts +443 -0
  157. package/src/converge-job-setup.test.ts +152 -0
  158. package/src/converge-job-setup.ts +268 -0
  159. package/src/converge-job.test.ts +486 -0
  160. package/src/converge-job.ts +542 -0
  161. package/src/gateway-env-isolation.test.ts +8 -1
  162. package/src/gcp-names.ts +15 -0
  163. package/src/gcp-url-map.test.ts +9 -1
  164. package/src/index.ts +8 -0
  165. package/src/infra-contract.test.ts +8 -4
  166. package/src/infra-contract.ts +33 -1
  167. package/src/install-job.test.ts +3 -1
  168. package/src/install-job.ts +31 -14
  169. package/src/lifecycle.ts +137 -71
  170. package/src/operator-access.test.ts +24 -1
  171. package/src/operator-access.ts +44 -6
  172. package/src/release-contract.ts +5 -2
  173. package/src/release-manifest.test.ts +42 -0
  174. package/src/release-manifest.ts +2 -0
  175. package/src/remote-mcp-edge.ts +137 -0
  176. package/src/remote-mcp.test.ts +690 -0
  177. package/src/remote-mcp.ts +524 -0
  178. package/src/runtime-secrets.test.ts +31 -6
  179. package/src/runtime-secrets.ts +41 -3
  180. package/src/seal-key-rotation.test.ts +131 -0
  181. package/src/seal-key-rotation.ts +258 -0
  182. package/src/stack-contract-parity.test.ts +20 -3
  183. package/src/stack-contract.test.ts +32 -2
  184. package/src/stack-contract.ts +363 -4
  185. package/src/stack-converge-secrets.test.ts +4 -0
  186. package/src/stack-converge.test.ts +21 -5
  187. package/src/stack-converge.ts +162 -7
  188. package/src/state-attach.test.ts +7 -1
  189. package/src/state-lock-lease.test.ts +302 -0
  190. package/src/state-lock-lease.ts +423 -0
  191. package/src/state-lock.test.ts +568 -0
  192. package/src/state-store.ts +347 -27
  193. package/src/upgrade/app-roll.ts +102 -21
  194. package/src/upgrade/audit-job-roll.ts +3 -1
  195. package/src/upgrade/converge.ts +148 -10
  196. package/src/upgrade/gcp-lookup.test.ts +80 -0
  197. package/src/upgrade/gcp-lookup.ts +65 -8
  198. package/src/upgrade/record-rolled.test.ts +105 -0
  199. package/src/upgrade/record-rolled.ts +91 -0
  200. package/src/upgrade/upgrade.test.ts +203 -3
  201. package/src/upgrade/upgrade.ts +120 -24
  202. package/src/upgrader-assess.test.ts +92 -3
  203. package/src/upgrader-assess.ts +58 -9
  204. package/src/upgrader-gcp.ts +47 -8
  205. package/src/upgrader-iam-preflight.test.ts +393 -0
  206. package/src/upgrader-iam-preflight.ts +510 -0
  207. package/src/upgrader-job.test.ts +586 -15
  208. package/src/upgrader-job.ts +237 -53
  209. package/src/upgrader-records.ts +151 -0
  210. package/src/upgrader-reporter.test.ts +6 -0
  211. package/src/upgrader-reporter.ts +7 -2
  212. package/src/upgrader-setup.test.ts +3 -3
  213. package/src/upgrader-setup.ts +128 -11
  214. package/src/wizards/gcp-cost.ts +12 -3
  215. package/src/wizards/gcp-pulumi.test.ts +15 -0
  216. package/src/wizards/gcp-pulumi.ts +18 -2
  217. package/src/wizards/gcp.ts +134 -4
  218. package/stacks/aws/index.ts +10 -0
  219. package/stacks/azure/index.ts +9 -0
  220. package/stacks/gcp/index.ts +47 -7
  221. package/stacks/gcp-firebase/index.ts +38 -6
@@ -16,6 +16,7 @@ import type { InstallMetadata } from "./install-state.js";
16
16
  import type { ReleaseManifest, ReleaseManifestResult } from "./release-manifest.js";
17
17
  import { StateLockHeldError } from "./state-store.js";
18
18
  import type { HealthOutcome, HealthProbe } from "./upgrade/health.js";
19
+ import { type IamTester, resourceName } from "./upgrader-iam-preflight.js";
19
20
  import {
20
21
  type ClaimedUpdate,
21
22
  runUpgraderJob,
@@ -28,6 +29,42 @@ import {
28
29
  const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
29
30
  const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
30
31
  const D = (c: string) => `sha256:${c.repeat(64)}`;
32
+ /**
33
+ * The linux/amd64 platform manifest inside release index `D(c)`. Cloud Run
34
+ * reports this one as a revision's `status.imageDigest` while the release
35
+ * manifest and install.json name the index (#860 root cause).
36
+ */
37
+ const P = (c: string) => `sha256:${"f".repeat(63)}${c}`;
38
+
39
+ const MIRROR = "europe-west1-docker.pkg.dev/caelo-website/caelo-cms-images/x";
40
+
41
+ /** `gcloud run revisions describe --format=json` of a revision deployed as `deployed` (null: by tag). */
42
+ const revisionJson = (deployed: string | null, resolved: string) =>
43
+ JSON.stringify({
44
+ spec: { containers: [{ image: deployed ? `${MIRROR}@${deployed}` : `${MIRROR}:0.10.45` }] },
45
+ status: { imageDigest: `${MIRROR}@${resolved}` },
46
+ });
47
+
48
+ /** Serving revisions deployed as `admin` / `gateway` (index digests). */
49
+ const servingAs = (admin: string, gateway: string): [RegExp, Answer] => [
50
+ /^run revisions describe (\S+)/,
51
+ (l) =>
52
+ ok(
53
+ l.includes("-admin-")
54
+ ? revisionJson(admin, `sha256:${"e".repeat(64)}`)
55
+ : revisionJson(gateway, `sha256:${"d".repeat(64)}`),
56
+ ),
57
+ ];
58
+
59
+ /** `gcloud artifacts docker tags list` of the mirror: `tags` maps release version → digest. */
60
+ const releaseTags = (slug: "admin" | "gateway", tags: Record<string, string>): [RegExp, Answer] => [
61
+ new RegExp(`^artifacts docker tags list \\S+/${slug} `),
62
+ ok(
63
+ Object.entries(tags)
64
+ .map(([v, d]) => `projects/p/locations/l/repositories/r/packages/${slug}/tags/${v}\t${d}`)
65
+ .join("\n"),
66
+ ),
67
+ ];
31
68
 
32
69
  const ADMIN = "caelo-production-admin-aaa";
33
70
  const GATEWAY = "caelo-production-gateway-bbb";
@@ -77,6 +114,11 @@ const META: InstallMetadata = {
77
114
  infraContractVersion: 2,
78
115
  };
79
116
 
117
+ const manifestOf = (version: string, admin: string, gateway: string): ReleaseManifestResult => ({
118
+ ok: true,
119
+ manifest: { ...MANIFEST, version, digests: { admin, gateway, ops: D("c") } },
120
+ });
121
+
80
122
  const serviceJson = (image: string, env: unknown[]) =>
81
123
  JSON.stringify({ spec: { template: { spec: { containers: [{ image, env }] } } } });
82
124
  const secretRef = (name: string, secret: string) => ({
@@ -106,7 +148,8 @@ function fakeGcloud(events: string[], overrides: [RegExp, Answer][] = []) {
106
148
  [/describe (\S+) .*status\.url/, (l) => ok(`https://${l.split(" ")[3]}-xyz.a.run.app\n`)],
107
149
  [
108
150
  /^run revisions describe (\S+)/,
109
- (l) => ok(`registry/x@${l.includes("-admin-") ? D("1") : D("2")}\n`),
151
+ (l) =>
152
+ ok(l.includes("-admin-") ? revisionJson(D("1"), P("1")) : revisionJson(D("2"), P("2"))),
110
153
  ],
111
154
  [/^sql instances list/, ok("caelo-production-pg-abc\n")],
112
155
  [/^sql backups list/, ok("1760000000000\n")],
@@ -159,12 +202,20 @@ function setup(
159
202
  opts: {
160
203
  request?: Partial<ClaimedUpdate>;
161
204
  manifest?: ReleaseManifestResult;
205
+ /** Manifests of other versions than the target (the serving release's). */
206
+ manifests?: Record<string, ReleaseManifestResult>;
162
207
  meta?: InstallMetadata | null;
163
208
  gcloud?: [RegExp, Answer][];
164
209
  deep?: HealthOutcome;
165
210
  migrate?: { ok: boolean; error?: string };
166
211
  locked?: boolean;
167
212
  lockDenied?: boolean;
213
+ /** The lock broke a dead run's lock on the way, with this message. */
214
+ brokeStaleLock?: string;
215
+ /** IAM's answer: permissions withheld per resource name. */
216
+ iamWithheld?: Record<string, readonly string[]>;
217
+ /** A resource IAM cannot be asked about. */
218
+ iamError?: string;
168
219
  } = {},
169
220
  ) {
170
221
  const order: string[] = [];
@@ -185,14 +236,21 @@ function setup(
185
236
  region: "europe-west1",
186
237
  env: "production",
187
238
  execution: "caelo-production-upgrader-x1",
239
+ iam: fakeIam(order, opts.iamWithheld ?? {}, opts.iamError),
188
240
  fetchManifest: async (v) => {
189
241
  order.push(`manifest ${v}`);
242
+ const other = opts.manifests?.[v];
243
+ if (other) return other;
244
+ if (v !== MANIFEST.version) {
245
+ return { ok: false, error: { code: "registry", message: `no release manifest for v${v}` } };
246
+ }
190
247
  return opts.manifest ?? { ok: true, manifest: MANIFEST };
191
248
  },
192
- lockState: () => {
249
+ lockState: (onBreak) => {
193
250
  order.push("lock");
194
251
  if (opts.locked) throw new StateLockHeldError("held by caelo installer");
195
252
  if (opts.lockDenied) throw new Error("storage.objects.list denied");
253
+ if (opts.brokeStaleLock) onBreak(opts.brokeStaleLock);
196
254
  return () => order.push("unlock");
197
255
  },
198
256
  readInstall: () => (opts.meta === undefined ? META : opts.meta),
@@ -213,6 +271,23 @@ function setup(
213
271
  return { deps, order, q, written };
214
272
  }
215
273
 
274
+ /** IAM granting everything asked except `withheld` per resource; `error` names a resource it cannot be asked about. */
275
+ function fakeIam(
276
+ order: string[],
277
+ withheld: Record<string, readonly string[]>,
278
+ error?: string,
279
+ ): IamTester {
280
+ return {
281
+ granted: async (resource, permissions) => {
282
+ const name = resourceName(resource);
283
+ order.push(`iam ${name}`);
284
+ if (error && name === error) return { ok: false, error: "HTTP 503 backend unavailable" };
285
+ const no = withheld[name] ?? [];
286
+ return { ok: true, granted: permissions.filter((p) => !no.includes(p)) };
287
+ },
288
+ };
289
+ }
290
+
216
291
  const step = (order: string[], re: RegExp) => order.findIndex((l) => re.test(l));
217
292
 
218
293
  describe("runUpgraderJob", () => {
@@ -275,10 +350,12 @@ describe("runUpgraderJob", () => {
275
350
  ]);
276
351
  });
277
352
 
278
- it("holds the install's state lock for the whole run, and stops when another run holds it", async () => {
353
+ it("#815: decides first, locks right before the backup, holds the lock to the end", async () => {
279
354
  const ok = setup();
280
355
  await runUpgraderJob(ok.deps);
281
- expect(ok.order[0]).toBe("lock");
356
+ const lock = ok.order.indexOf("lock");
357
+ expect(lock).toBeGreaterThan(step(ok.order, /^manifest 0\.11\.0/));
358
+ expect(lock).toBeLessThan(step(ok.order, /^gcloud sql backups create/));
282
359
  expect(ok.order.indexOf("unlock")).toBeGreaterThan(
283
360
  step(ok.order, /jobs update caelo-production-upgrader/),
284
361
  );
@@ -287,7 +364,83 @@ describe("runUpgraderJob", () => {
287
364
  const r = await runUpgraderJob(busy.deps);
288
365
  expect(r.status).toBe("failed");
289
366
  expect(busy.q.end()?.codes).toEqual(["state-locked"]);
290
- expect(busy.order.some((l) => l.startsWith("manifest") || l.startsWith("gcloud"))).toBe(false);
367
+ expect(busy.order.some((l) => /backups create|services update|^migrate/.test(l))).toBe(false);
368
+ expect(busy.order).not.toContain("unlock");
369
+ });
370
+
371
+ it("#818: checks IAM after the decision and before the lock, the backup and the roll", async () => {
372
+ const { deps, order } = setup();
373
+ expect((await runUpgraderJob(deps)).status).toBe("succeeded");
374
+ const firstIam = step(order, /^iam /);
375
+ expect(firstIam).toBeGreaterThan(step(order, /^manifest 0\.11\.0/));
376
+ expect(firstIam).toBeLessThan(order.indexOf("lock"));
377
+ expect(firstIam).toBeLessThan(step(order, /^gcloud sql backups create/));
378
+ // Every resource the run acts on is asked about, each once.
379
+ expect(order.filter((l) => l.startsWith("iam ")).sort()).toEqual(
380
+ [
381
+ "iam gs://acme-caelo-ops",
382
+ "iam gs://acme-caelo-ops/install/",
383
+ "iam projects/acme",
384
+ "iam projects/acme/locations/europe-west1/jobs/caelo-production-upgrader",
385
+ "iam projects/acme/serviceAccounts/caelo-mcp@acme.iam.gserviceaccount.com",
386
+ "iam projects/acme/serviceAccounts/caelo-production-audit@acme.iam.gserviceaccount.com",
387
+ "iam projects/acme/serviceAccounts/caelo-production-gateway-sa@acme.iam.gserviceaccount.com",
388
+ "iam projects/acme/serviceAccounts/caelo-production-run-sa@acme.iam.gserviceaccount.com",
389
+ "iam projects/acme/serviceAccounts/caelo-production-upgrader@acme.iam.gserviceaccount.com",
390
+ "iam projects/caelo-website/locations/europe-west1/repositories/caelo-cms-images",
391
+ ].sort(),
392
+ );
393
+ });
394
+
395
+ it("#818: missing grants end needs-Cloud-Shell with EVERY missing permission named; nothing changed", async () => {
396
+ const { deps, order, q, written } = setup({
397
+ iamWithheld: {
398
+ "projects/acme": ["run.jobs.create", "cloudsql.backupRuns.create"],
399
+ "gs://acme-caelo-ops/install/": ["storage.objects.delete"],
400
+ },
401
+ });
402
+ const r = await runUpgraderJob(deps);
403
+ expect(r).toMatchObject({ exitCode: 0, status: "needs_cloud_shell" });
404
+ const end = q.end();
405
+ expect(end?.codes).toEqual(["iam-preflight"]);
406
+ for (const p of ["run.jobs.create", "cloudsql.backupRuns.create", "storage.objects.delete"]) {
407
+ expect(end?.reason).toContain(p);
408
+ }
409
+ // In plain words, with the one next step.
410
+ expect(end?.reason).toContain("back up the database before anything changes");
411
+ expect(end?.reason).toContain("Run the upgrade once with Cloud Shell");
412
+ expect(end?.reason?.startsWith("Nothing was changed")).toBe(true);
413
+ expect(order).not.toContain("lock");
414
+ expect(written).toEqual([]);
415
+ expect(order.some((l) => /backups create|services update|jobs update|^migrate/.test(l))).toBe(
416
+ false,
417
+ );
418
+ });
419
+
420
+ it("#818: IAM that cannot be asked fails the run before anything changes (never assumed fine)", async () => {
421
+ const { deps, order, q } = setup({ iamError: "projects/acme" });
422
+ const r = await runUpgraderJob(deps);
423
+ expect(r.status).toBe("failed");
424
+ expect(q.end()?.codes).toEqual(["iam-preflight"]);
425
+ expect(q.end()?.reason).toContain("could not check its access");
426
+ expect(order).not.toContain("lock");
427
+ expect(order.some((l) => /backups create|services update|^migrate/.test(l))).toBe(false);
428
+ });
429
+
430
+ it("#818: a stop verdict never asks IAM (it changes nothing that needs it)", async () => {
431
+ const { deps, order } = setup({ meta: { ...META, infraContractVersion: 1 } });
432
+ expect((await runUpgraderJob(deps)).status).toBe("needs_cloud_shell");
433
+ expect(order.some((l) => l.startsWith("iam "))).toBe(false);
434
+ });
435
+
436
+ it("#815: a release that needs Cloud Shell says so even when the lock is held or denied", async () => {
437
+ for (const lock of [{ locked: true }, { lockDenied: true }]) {
438
+ const { deps, order, q } = setup({ meta: { ...META, infraContractVersion: 1 }, ...lock });
439
+ expect((await runUpgraderJob(deps)).status).toBe("needs_cloud_shell");
440
+ expect(q.end()?.codes).toEqual(["infra-contract"]);
441
+ // A verdict that changes nothing never even asks for the lock.
442
+ expect(order).not.toContain("lock");
443
+ }
291
444
  });
292
445
 
293
446
  it("does not call a lock it cannot take 'busy': an access failure asks for Cloud Shell", async () => {
@@ -298,9 +451,61 @@ describe("runUpgraderJob", () => {
298
451
  expect(end?.codes).toEqual(["state-access"]);
299
452
  expect(end?.reason).not.toContain("another install operation");
300
453
  expect(end?.reason).toContain("storage.objects.list denied");
301
- expect(denied.order.some((l) => l.startsWith("manifest") || l.startsWith("gcloud"))).toBe(
302
- false,
303
- );
454
+ expect(denied.order.some((l) => /backups create|services update|^migrate/.test(l))).toBe(false);
455
+ });
456
+
457
+ it("#815: a dead run's lock broken on the way lands on the timeline", async () => {
458
+ const { deps, q } = setup({
459
+ brokeStaleLock: "Removed a stale state lock: caelo upgrader job (run x0) has failed.",
460
+ });
461
+ expect((await runUpgraderJob(deps)).status).toBe("succeeded");
462
+ expect(q.events).toContainEqual({
463
+ phase: "assess",
464
+ level: "info",
465
+ message: "Removed a stale state lock: caelo upgrader job (run x0) has failed.",
466
+ });
467
+ });
468
+
469
+ it("#815: install.json changed by another writer before the lock: stops, writes nothing", async () => {
470
+ const { deps, order, q, written } = setup();
471
+ let reads = 0;
472
+ const r = await runUpgraderJob({
473
+ ...deps,
474
+ readInstall: () => {
475
+ reads += 1;
476
+ return reads === 1 ? META : { ...META, releaseVersion: "0.10.46" };
477
+ },
478
+ });
479
+ expect(r.status).toBe("failed");
480
+ expect(q.end()?.codes).toEqual(["install-state-changed"]);
481
+ expect(written).toEqual([]);
482
+ expect(order.some((l) => /backups create|services update|^migrate/.test(l))).toBe(false);
483
+ expect(order).toContain("unlock");
484
+ });
485
+
486
+ it("adversarial #815: two runs at once — one installs, the other finds the lock held", async () => {
487
+ let holder: string | null = null;
488
+ const runOf = (id: string) => {
489
+ const s = setup({ request: { id } });
490
+ const lockState = () => {
491
+ if (holder) throw new StateLockHeldError(`held by ${holder}`);
492
+ holder = id;
493
+ return () => {
494
+ holder = null;
495
+ };
496
+ };
497
+ return { ...s, deps: { ...s.deps, lockState } };
498
+ };
499
+ const a = runOf("req-a");
500
+ const b = runOf("req-b");
501
+ const [ra, rb] = await Promise.all([runUpgraderJob(a.deps), runUpgraderJob(b.deps)]);
502
+ const statuses = [ra.status, rb.status].sort();
503
+ expect(statuses).toEqual(["failed", "succeeded"]);
504
+ const loser = ra.status === "failed" ? a : b;
505
+ expect(loser.q.end()?.codes).toEqual(["state-locked"]);
506
+ expect(loser.order.some((l) => /backups create|services update|^migrate/.test(l))).toBe(false);
507
+ expect(loser.written).toEqual([]);
508
+ expect(holder).toBeNull();
304
509
  });
305
510
 
306
511
  it("retries recording the end, and fails the run when it cannot", async () => {
@@ -354,25 +559,391 @@ describe("runUpgraderJob", () => {
354
559
  expect(q.end()?.codes).toEqual(["install-state"]);
355
560
  });
356
561
 
357
- it("refuses when the serving release is not the recorded one (a lost install.json write)", async () => {
358
- const { deps, order, q } = setup({
562
+ it("regression #860: Cloud Run's platform digest of the recorded index is no drift", async () => {
563
+ // The fake reports status.imageDigest = P(…) (the platform manifest) and
564
+ // the revision spec = D(…) (the index install.json and the manifest name).
565
+ const { deps, order, q, written } = setup();
566
+ expect((await runUpgraderJob(deps)).status).toBe("succeeded");
567
+ expect(q.events.some((e) => e.message.includes("refreshed its install records"))).toBe(false);
568
+ expect(order.filter((l) => l.startsWith("manifest"))).toEqual(["manifest 0.11.0"]);
569
+ expect(order.some((l) => l.includes("artifacts docker tags list"))).toBe(false);
570
+ expect(written).toHaveLength(1);
571
+ });
572
+
573
+ it("a revision deployed by tag matches by the digest Cloud Run resolved", async () => {
574
+ const { deps, written } = setup({
575
+ meta: { ...META, imageDigests: { admin: P("1"), gateway: D("2") } },
576
+ gcloud: [
577
+ [
578
+ /^run revisions describe (\S+)/,
579
+ (l) =>
580
+ ok(l.includes("-admin-") ? revisionJson(null, P("1")) : revisionJson(D("2"), P("2"))),
581
+ ],
582
+ ],
583
+ });
584
+ expect((await runUpgraderJob(deps)).status).toBe("succeeded");
585
+ expect(written).toHaveLength(1);
586
+ });
587
+
588
+ it("drift to a signed release: refreshes install.json, says so, installs judged against it", async () => {
589
+ // Recorded: 0.10.44 (digests 9/8). Serving: 0.10.45 (digests 1/2).
590
+ const { deps, order, q, written } = setup({
591
+ meta: {
592
+ ...META,
593
+ imageDigests: { admin: D("9"), gateway: D("8") },
594
+ releaseVersion: "0.10.44",
595
+ },
596
+ manifests: {
597
+ "0.10.44": manifestOf("0.10.44", D("9"), D("8")),
598
+ "0.10.45": manifestOf("0.10.45", D("1"), D("2")),
599
+ },
600
+ gcloud: [releaseTags("admin", { "0.10.44": D("9"), "0.10.45": D("1") })],
601
+ });
602
+ const r = await runUpgraderJob(deps);
603
+ expect(r).toMatchObject({ exitCode: 0, status: "succeeded" });
604
+ expect(written[0]).toMatchObject({
605
+ releaseVersion: "0.10.45",
606
+ imageDigests: { admin: D("1"), gateway: D("2") },
607
+ domain: "acme.com",
608
+ infraContractVersion: 2,
609
+ });
610
+ expect(q.events).toContainEqual({
611
+ phase: "assess",
612
+ level: "success",
613
+ message: "Caelo refreshed its install records: v0.10.45 serves.",
614
+ });
615
+ // Judged against the serving release, written once the lock is held (#815).
616
+ expect(step(order, /^manifest 0\.10\.45/)).toBeLessThan(step(order, /^manifest 0\.11\.0/));
617
+ expect(q.events.some((e) => e.message.startsWith("0.10.45 → 0.11.0"))).toBe(true);
618
+ expect(written.at(-1)).toMatchObject({ releaseVersion: "0.11.0" });
619
+ });
620
+
621
+ it("#815: drift + a stop verdict: the records are corrected under the lock, the verdict stands", async () => {
622
+ const drift = {
623
+ meta: { ...META, imageDigests: { admin: D("9"), gateway: D("2") }, infraContractVersion: 1 },
624
+ manifests: { "0.10.45": manifestOf("0.10.45", D("1"), D("2")) },
625
+ };
626
+ const free = setup(drift);
627
+ expect((await runUpgraderJob(free.deps)).status).toBe("needs_cloud_shell");
628
+ expect(free.q.end()?.codes).toEqual(["infra-contract"]);
629
+ expect(free.written).toEqual([
630
+ expect.objectContaining({ imageDigests: { admin: D("1"), gateway: D("2") } }),
631
+ ]);
632
+ expect(free.order.indexOf("lock")).toBeGreaterThan(step(free.order, /^manifest 0\.11\.0/));
633
+ expect(free.order).toContain("unlock");
634
+
635
+ // A lock that cannot be taken (no access) only defers the correction;
636
+ // it never replaces the verdict.
637
+ const denied = setup({ ...drift, lockDenied: true });
638
+ expect((await runUpgraderJob(denied.deps)).status).toBe("needs_cloud_shell");
639
+ expect(denied.q.end()?.codes).toEqual(["infra-contract"]);
640
+ expect(denied.written).toEqual([]);
641
+ expect(
642
+ denied.q.events.some(
643
+ (e) => e.level === "warn" && e.message.includes("could not be corrected this time"),
644
+ ),
645
+ ).toBe(true);
646
+ });
647
+
648
+ it("#815 review: drift + a stop verdict while another run holds the lock ends state-locked", async () => {
649
+ // The serving images were read without the lock; the run holding it may
650
+ // be half-way through its own roll (or be the very Cloud Shell upgrade
651
+ // the verdict would ask for). Its half-finished state is no verdict.
652
+ const busy = setup({
653
+ meta: { ...META, imageDigests: { admin: D("9"), gateway: D("2") }, infraContractVersion: 1 },
654
+ manifests: { "0.10.45": manifestOf("0.10.45", D("1"), D("2")) },
655
+ locked: true,
656
+ });
657
+ expect((await runUpgraderJob(busy.deps)).status).toBe("failed");
658
+ expect(busy.q.end()?.codes).toEqual(["state-locked"]);
659
+ expect(busy.written).toEqual([]);
660
+ });
661
+
662
+ it("#815 review: images no release vouches for, while another run holds the lock, are not drift", async () => {
663
+ // A run mid-roll serves the new admin next to the old gateway for a
664
+ // moment: read without the lock that looks exactly like a mixed install.
665
+ const mixed = {
666
+ gcloud: [
667
+ servingAs(D("9"), D("2")),
668
+ releaseTags("admin", { "0.10.44": D("9") }),
669
+ releaseTags("gateway", { "0.10.45": D("2") }),
670
+ ],
671
+ meta: {
672
+ ...META,
673
+ imageDigests: { admin: D("1"), gateway: D("2") },
674
+ releaseVersion: "0.10.45",
675
+ },
676
+ manifests: {
677
+ "0.10.44": manifestOf("0.10.44", D("9"), D("8")),
678
+ "0.10.45": manifestOf("0.10.45", D("1"), D("2")),
679
+ },
680
+ };
681
+ const busy = setup({ ...mixed, locked: true });
682
+ expect((await runUpgraderJob(busy.deps)).status).toBe("failed");
683
+ expect(busy.q.end()?.codes).toEqual(["state-locked"]);
684
+ expect(busy.written).toEqual([]);
685
+
686
+ // With the lock free (no run is rolling), it is drift.
687
+ const free = setup(mixed);
688
+ expect((await runUpgraderJob(free.deps)).status).toBe("needs_cloud_shell");
689
+ expect(free.q.end()?.codes).toEqual(["install-state-drift"]);
690
+ expect(free.order).toContain("lock");
691
+ expect(free.order).toContain("unlock");
692
+ expect(free.written).toEqual([]);
693
+ });
694
+
695
+ it("#815: drift + a stop verdict: a failed re-read under the lock keeps the verdict", async () => {
696
+ const drift = setup({
697
+ meta: { ...META, imageDigests: { admin: D("9"), gateway: D("2") }, infraContractVersion: 1 },
698
+ manifests: { "0.10.45": manifestOf("0.10.45", D("1"), D("2")) },
699
+ });
700
+ let reads = 0;
701
+ const r = await runUpgraderJob({
702
+ ...drift.deps,
703
+ readInstall: () => {
704
+ reads += 1;
705
+ if (reads > 1) throw new Error("storage.objects.get timed out");
706
+ return {
707
+ ...META,
708
+ imageDigests: { admin: D("9"), gateway: D("2") },
709
+ infraContractVersion: 1,
710
+ };
711
+ },
712
+ });
713
+ expect(r.status).toBe("needs_cloud_shell");
714
+ expect(drift.q.end()?.codes).toEqual(["infra-contract"]);
715
+ expect(drift.written).toEqual([]);
716
+ expect(drift.order).toContain("unlock");
717
+ expect(
718
+ drift.q.events.some(
719
+ (e) => e.level === "warn" && e.message.includes("storage.objects.get timed out"),
720
+ ),
721
+ ).toBe(true);
722
+ });
723
+
724
+ it("#815: a failed re-read under the lock before the backup stops as install-state-read", async () => {
725
+ const { deps, order, q, written } = setup();
726
+ let reads = 0;
727
+ const r = await runUpgraderJob({
728
+ ...deps,
729
+ readInstall: () => {
730
+ reads += 1;
731
+ if (reads > 1) throw new Error("storage.objects.get timed out");
732
+ return META;
733
+ },
734
+ });
735
+ expect(r.status).toBe("failed");
736
+ expect(q.end()?.codes).toEqual(["install-state-read"]);
737
+ expect(written).toEqual([]);
738
+ expect(order.some((l) => /backups create|services update|^migrate/.test(l))).toBe(false);
739
+ expect(order).toContain("unlock");
740
+ });
741
+
742
+ it("drift where the recorded version is the serving one needs no tag lookup", async () => {
743
+ const { deps, order, written } = setup({
359
744
  meta: { ...META, imageDigests: { admin: D("9"), gateway: D("2") } },
745
+ manifests: { "0.10.45": manifestOf("0.10.45", D("1"), D("2")) },
746
+ });
747
+ expect((await runUpgraderJob(deps)).status).toBe("succeeded");
748
+ expect(order.some((l) => l.includes("artifacts docker tags list"))).toBe(false);
749
+ expect(written[0]).toMatchObject({ imageDigests: { admin: D("1"), gateway: D("2") } });
750
+ });
751
+
752
+ it("drift to images of no signed release is refused, says so, and changes nothing", async () => {
753
+ const { deps, order, q, written } = setup({
754
+ gcloud: [servingAs(D("7"), D("6"))],
755
+ manifests: { "0.10.45": manifestOf("0.10.45", D("1"), D("2")) },
756
+ });
757
+ const r = await runUpgraderJob(deps);
758
+ expect(r.status).toBe("needs_cloud_shell");
759
+ const end = q.end();
760
+ expect(end?.codes).toEqual(["install-state-drift"]);
761
+ expect(end?.reason).toContain("do not belong to one signed Caelo release");
762
+ expect(end?.reason).toContain(`admin ${D("7")}`);
763
+ expect(written).toEqual([]);
764
+ expect(order.some((l) => /backups create|services update|^migrate/.test(l))).toBe(false);
765
+ expect(order).not.toContain("manifest 0.11.0");
766
+ });
767
+
768
+ it("regression #865: drift whose release manifest the registry cannot serve fails retryably, not install-state-drift", async () => {
769
+ const { deps, order, q, written } = setup({
770
+ meta: {
771
+ ...META,
772
+ imageDigests: { admin: D("9"), gateway: D("8") },
773
+ releaseVersion: "0.10.44",
774
+ },
775
+ manifests: {
776
+ "0.10.44": manifestOf("0.10.44", D("9"), D("8")),
777
+ "0.10.45": {
778
+ ok: false,
779
+ error: { code: "registry", message: "503 Service Unavailable", transient: true },
780
+ },
781
+ },
782
+ gcloud: [releaseTags("admin", { "0.10.44": D("9"), "0.10.45": D("1") })],
783
+ });
784
+ const r = await runUpgraderJob(deps);
785
+ expect(r.status).toBe("failed");
786
+ const end = q.end();
787
+ expect(end?.codes).toEqual(["registry"]);
788
+ expect(end?.reason).toContain("503 Service Unavailable");
789
+ expect(end?.reason).toContain("Try again later");
790
+ expect(end?.reason).not.toContain("Cloud Shell");
791
+ expect(written).toEqual([]);
792
+ expect(order).not.toContain("manifest 0.11.0");
793
+ });
794
+
795
+ it("regression #865: drift whose candidate manifest does not exist (404) ends in install-state-drift, not a retry", async () => {
796
+ const { deps, q, written } = setup({
797
+ meta: {
798
+ ...META,
799
+ imageDigests: { admin: D("9"), gateway: D("8") },
800
+ releaseVersion: "0.10.44",
801
+ },
802
+ manifests: {
803
+ "0.10.44": manifestOf("0.10.44", D("9"), D("8")),
804
+ "0.10.45": {
805
+ ok: false,
806
+ error: { code: "registry", message: "no release manifest for v0.10.45 (HTTP 404)" },
807
+ },
808
+ },
809
+ gcloud: [releaseTags("admin", { "0.10.44": D("9"), "0.10.45": D("1") })],
810
+ });
811
+ expect((await runUpgraderJob(deps)).status).toBe("needs_cloud_shell");
812
+ const end = q.end();
813
+ expect(end?.codes).toEqual(["install-state-drift"]);
814
+ expect(end?.reason).toContain("HTTP 404");
815
+ expect(end?.reason).not.toContain("Try again later");
816
+ expect(written).toEqual([]);
817
+ });
818
+
819
+ it("drift with a signature-rejected candidate still ends in install-state-drift", async () => {
820
+ const { deps, q, written } = setup({
821
+ meta: {
822
+ ...META,
823
+ imageDigests: { admin: D("9"), gateway: D("8") },
824
+ releaseVersion: "0.10.44",
825
+ },
826
+ manifests: {
827
+ "0.10.44": manifestOf("0.10.44", D("9"), D("8")),
828
+ "0.10.45": { ok: false, error: { code: "signature", message: "not signed" } },
829
+ },
830
+ gcloud: [releaseTags("admin", { "0.10.44": D("9"), "0.10.45": D("1") })],
360
831
  });
361
832
  expect((await runUpgraderJob(deps)).status).toBe("needs_cloud_shell");
362
833
  expect(q.end()?.codes).toEqual(["install-state-drift"]);
363
- expect(order.some((l) => l.startsWith("manifest"))).toBe(false);
834
+ expect(written).toEqual([]);
364
835
  });
365
836
 
366
- it("a failed install.json write still ends succeeded, and the next run catches the drift", async () => {
367
- const { deps, q } = setup();
837
+ it("partial match (the admin of a release, the gateway not) is refused", async () => {
838
+ const { deps, q, written } = setup({
839
+ gcloud: [servingAs(D("1"), D("6"))],
840
+ meta: { ...META, imageDigests: { admin: D("9"), gateway: D("8") } },
841
+ manifests: { "0.10.45": manifestOf("0.10.45", D("1"), D("2")) },
842
+ });
843
+ expect((await runUpgraderJob(deps)).status).toBe("needs_cloud_shell");
844
+ expect(q.end()?.reason).toContain("The admin serves v0.10.45's image, the gateway does not");
845
+ expect(written).toEqual([]);
846
+ });
847
+
848
+ it("admin and gateway of two different signed releases are refused, naming both", async () => {
849
+ // Each image is genuinely signed, but no ONE release names both: a mixed
850
+ // deployment no release vouches for.
851
+ const { deps, q, written } = setup({
852
+ gcloud: [
853
+ servingAs(D("9"), D("2")),
854
+ releaseTags("admin", { "0.10.44": D("9") }),
855
+ releaseTags("gateway", { "0.10.45": D("2") }),
856
+ ],
857
+ meta: {
858
+ ...META,
859
+ imageDigests: { admin: D("1"), gateway: D("2") },
860
+ releaseVersion: "0.10.45",
861
+ },
862
+ manifests: {
863
+ "0.10.44": manifestOf("0.10.44", D("9"), D("8")),
864
+ "0.10.45": manifestOf("0.10.45", D("1"), D("2")),
865
+ },
866
+ });
867
+ expect((await runUpgraderJob(deps)).status).toBe("needs_cloud_shell");
868
+ const end = q.end();
869
+ expect(end?.codes).toEqual(["install-state-drift"]);
870
+ expect(end?.reason).toContain("The gateway serves v0.10.45's image, the admin does not");
871
+ expect(end?.reason).toContain("the admin serves v0.10.44's image, the gateway does not");
872
+ expect(written).toEqual([]);
873
+ });
874
+
875
+ it("an older signed release serving is recorded as what it is, and the target judged from it", async () => {
876
+ // install.json says 0.10.45, but someone put 0.10.44 back: the heal must
877
+ // record the OLDER version, never keep claiming the newer one.
878
+ const { deps, q, written } = setup({
879
+ gcloud: [servingAs(D("9"), D("8")), releaseTags("admin", { "0.10.44": D("9") })],
880
+ manifests: {
881
+ "0.10.44": manifestOf("0.10.44", D("9"), D("8")),
882
+ "0.10.45": manifestOf("0.10.45", D("1"), D("2")),
883
+ },
884
+ });
885
+ expect((await runUpgraderJob(deps)).status).toBe("succeeded");
886
+ expect(written[0]).toMatchObject({
887
+ releaseVersion: "0.10.44",
888
+ imageDigests: { admin: D("9"), gateway: D("8") },
889
+ });
890
+ expect(q.events.some((e) => e.message.startsWith("0.10.44 → 0.11.0"))).toBe(true);
891
+ });
892
+
893
+ it("a release whose manifest does not verify is never recorded", async () => {
894
+ const { deps, q, written } = setup({
895
+ meta: { ...META, imageDigests: { admin: D("9"), gateway: D("8") } },
896
+ manifests: {
897
+ "0.10.45": { ok: false, error: { code: "signature", message: "cert identity mismatch" } },
898
+ },
899
+ });
900
+ expect((await runUpgraderJob(deps)).status).toBe("needs_cloud_shell");
901
+ expect(q.end()?.reason).toContain("could not be verified: v0.10.45 (cert identity mismatch)");
902
+ expect(written).toEqual([]);
903
+ });
904
+
905
+ it("an install.json without digests is refreshed the same way", async () => {
906
+ const { imageDigests: _none, ...bare } = META;
907
+ const { deps, written } = setup({
908
+ meta: bare,
909
+ manifests: { "0.10.45": manifestOf("0.10.45", D("1"), D("2")) },
910
+ });
911
+ expect((await runUpgraderJob(deps)).status).toBe("succeeded");
912
+ expect(written[0]).toMatchObject({ imageDigests: { admin: D("1"), gateway: D("2") } });
913
+ });
914
+
915
+ it("a refresh that cannot be written stops before anything changes", async () => {
916
+ const { deps, order, q } = setup({
917
+ meta: { ...META, imageDigests: { admin: D("9"), gateway: D("2") } },
918
+ manifests: { "0.10.45": manifestOf("0.10.45", D("1"), D("2")) },
919
+ });
920
+ const r = await runUpgraderJob({
921
+ ...deps,
922
+ writeInstall: () => {
923
+ throw new Error("403 on the ops bucket");
924
+ },
925
+ });
926
+ expect(r).toMatchObject({ exitCode: 1, status: "failed" });
927
+ expect(q.end()?.codes).toEqual(["install-state-write"]);
928
+ expect(q.end()?.reason).toContain("403 on the ops bucket");
929
+ expect(order.some((l) => /backups create|services update|^migrate/.test(l))).toBe(false);
930
+ });
931
+
932
+ it("a failed install.json write after the roll fails the run instead of ending succeeded", async () => {
933
+ const { deps, order, q } = setup();
368
934
  const r = await runUpgraderJob({
369
935
  ...deps,
370
936
  writeInstall: () => {
371
937
  throw new Error("403 on the ops bucket");
372
938
  },
373
939
  });
374
- expect(r.status).toBe("succeeded");
375
- expect(q.events.some((e) => e.phase === "record" && e.level === "warn")).toBe(true);
940
+ expect(r).toMatchObject({ exitCode: 1, status: "failed" });
941
+ expect(q.end()?.codes).toEqual(["record"]);
942
+ expect(q.end()?.reason).toContain("0.11.0 serves");
943
+ expect(q.events.some((e) => e.phase === "record" && e.level === "error")).toBe(true);
944
+ // The new release keeps serving: no rollback, no self-update.
945
+ expect(order.some((l) => /--to-revisions/.test(l))).toBe(false);
946
+ expect(order.some((l) => /jobs update caelo-production-upgrader/.test(l))).toBe(false);
376
947
  });
377
948
 
378
949
  it("a failed backup stops before migrations", async () => {