@caelo-cms/provisioning 0.10.53 → 0.10.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/dist/agent-db.js +272 -128
  2. package/dist/audit-job-setup.d.ts +69 -0
  3. package/dist/audit-job-setup.d.ts.map +1 -0
  4. package/dist/audit-job-setup.js +317 -0
  5. package/dist/audit-job-setup.js.map +1 -0
  6. package/dist/caddy.d.ts +9 -0
  7. package/dist/caddy.d.ts.map +1 -1
  8. package/dist/caddy.js +38 -0
  9. package/dist/caddy.js.map +1 -1
  10. package/dist/cli-routing.js +1 -1
  11. package/dist/cli-routing.js.map +1 -1
  12. package/dist/cli.js +36 -9
  13. package/dist/cli.js.map +1 -1
  14. package/dist/compose.js +5 -0
  15. package/dist/compose.js.map +1 -1
  16. package/dist/converge-checkpoint.d.ts +66 -0
  17. package/dist/converge-checkpoint.d.ts.map +1 -0
  18. package/dist/converge-checkpoint.js +61 -0
  19. package/dist/converge-checkpoint.js.map +1 -0
  20. package/dist/converge-job-gcp.d.ts +46 -0
  21. package/dist/converge-job-gcp.d.ts.map +1 -0
  22. package/dist/converge-job-gcp.js +368 -0
  23. package/dist/converge-job-gcp.js.map +1 -0
  24. package/dist/converge-job-setup.d.ts +62 -0
  25. package/dist/converge-job-setup.d.ts.map +1 -0
  26. package/dist/converge-job-setup.js +228 -0
  27. package/dist/converge-job-setup.js.map +1 -0
  28. package/dist/converge-job.d.ts +141 -0
  29. package/dist/converge-job.d.ts.map +1 -0
  30. package/dist/converge-job.js +325 -0
  31. package/dist/converge-job.js.map +1 -0
  32. package/dist/gcp-names.d.ts +25 -1
  33. package/dist/gcp-names.d.ts.map +1 -1
  34. package/dist/gcp-names.js +36 -0
  35. package/dist/gcp-names.js.map +1 -1
  36. package/dist/index.d.ts +2 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +2 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/infra-contract.d.ts +1 -1
  41. package/dist/infra-contract.d.ts.map +1 -1
  42. package/dist/infra-contract.js +28 -1
  43. package/dist/infra-contract.js.map +1 -1
  44. package/dist/install-job.d.ts +11 -3
  45. package/dist/install-job.d.ts.map +1 -1
  46. package/dist/install-job.js +22 -12
  47. package/dist/install-job.js.map +1 -1
  48. package/dist/lifecycle.d.ts +13 -1
  49. package/dist/lifecycle.d.ts.map +1 -1
  50. package/dist/lifecycle.js +115 -58
  51. package/dist/lifecycle.js.map +1 -1
  52. package/dist/operator-access.d.ts.map +1 -1
  53. package/dist/operator-access.js +13 -3
  54. package/dist/operator-access.js.map +1 -1
  55. package/dist/private-file.d.ts +26 -0
  56. package/dist/private-file.d.ts.map +1 -0
  57. package/dist/private-file.js +49 -0
  58. package/dist/private-file.js.map +1 -0
  59. package/dist/release-contract.d.ts +3 -2
  60. package/dist/release-contract.d.ts.map +1 -1
  61. package/dist/release-contract.js +8 -2
  62. package/dist/release-contract.js.map +1 -1
  63. package/dist/release-manifest.d.ts +2 -0
  64. package/dist/release-manifest.d.ts.map +1 -1
  65. package/dist/release-manifest.js.map +1 -1
  66. package/dist/remote-mcp-edge.d.ts +114 -0
  67. package/dist/remote-mcp-edge.d.ts.map +1 -0
  68. package/dist/remote-mcp-edge.js +116 -0
  69. package/dist/remote-mcp-edge.js.map +1 -0
  70. package/dist/remote-mcp.d.ts +117 -0
  71. package/dist/remote-mcp.d.ts.map +1 -0
  72. package/dist/remote-mcp.js +379 -0
  73. package/dist/remote-mcp.js.map +1 -0
  74. package/dist/runtime-secrets.d.ts +12 -0
  75. package/dist/runtime-secrets.d.ts.map +1 -1
  76. package/dist/runtime-secrets.js +32 -4
  77. package/dist/runtime-secrets.js.map +1 -1
  78. package/dist/seal-key-rotation.d.ts +38 -0
  79. package/dist/seal-key-rotation.d.ts.map +1 -0
  80. package/dist/seal-key-rotation.js +206 -0
  81. package/dist/seal-key-rotation.js.map +1 -0
  82. package/dist/stack-contract.d.ts +175 -3
  83. package/dist/stack-contract.d.ts.map +1 -1
  84. package/dist/stack-contract.js +183 -3
  85. package/dist/stack-contract.js.map +1 -1
  86. package/dist/stack-converge.d.ts +38 -1
  87. package/dist/stack-converge.d.ts.map +1 -1
  88. package/dist/stack-converge.js +74 -4
  89. package/dist/stack-converge.js.map +1 -1
  90. package/dist/state-lock-lease.d.ts +142 -0
  91. package/dist/state-lock-lease.d.ts.map +1 -0
  92. package/dist/state-lock-lease.js +347 -0
  93. package/dist/state-lock-lease.js.map +1 -0
  94. package/dist/state-store.d.ts +66 -9
  95. package/dist/state-store.d.ts.map +1 -1
  96. package/dist/state-store.js +249 -30
  97. package/dist/state-store.js.map +1 -1
  98. package/dist/tar-entries.d.ts.map +1 -1
  99. package/dist/tar-entries.js +84 -9
  100. package/dist/tar-entries.js.map +1 -1
  101. package/dist/upgrade/app-roll.d.ts +36 -7
  102. package/dist/upgrade/app-roll.d.ts.map +1 -1
  103. package/dist/upgrade/app-roll.js +88 -19
  104. package/dist/upgrade/app-roll.js.map +1 -1
  105. package/dist/upgrade/audit-job-roll.d.ts +38 -0
  106. package/dist/upgrade/audit-job-roll.d.ts.map +1 -0
  107. package/dist/upgrade/audit-job-roll.js +43 -0
  108. package/dist/upgrade/audit-job-roll.js.map +1 -0
  109. package/dist/upgrade/converge.d.ts.map +1 -1
  110. package/dist/upgrade/converge.js +141 -8
  111. package/dist/upgrade/converge.js.map +1 -1
  112. package/dist/upgrade/gcp-lookup.d.ts +27 -10
  113. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  114. package/dist/upgrade/gcp-lookup.js +46 -7
  115. package/dist/upgrade/gcp-lookup.js.map +1 -1
  116. package/dist/upgrade/record-rolled.d.ts +40 -0
  117. package/dist/upgrade/record-rolled.d.ts.map +1 -0
  118. package/dist/upgrade/record-rolled.js +46 -0
  119. package/dist/upgrade/record-rolled.js.map +1 -0
  120. package/dist/upgrade/upgrade.d.ts +45 -1
  121. package/dist/upgrade/upgrade.d.ts.map +1 -1
  122. package/dist/upgrade/upgrade.js +60 -17
  123. package/dist/upgrade/upgrade.js.map +1 -1
  124. package/dist/upgrader-assess.d.ts +15 -1
  125. package/dist/upgrader-assess.d.ts.map +1 -1
  126. package/dist/upgrader-assess.js +36 -6
  127. package/dist/upgrader-assess.js.map +1 -1
  128. package/dist/upgrader-gcp.d.ts +15 -0
  129. package/dist/upgrader-gcp.d.ts.map +1 -1
  130. package/dist/upgrader-gcp.js +42 -7
  131. package/dist/upgrader-gcp.js.map +1 -1
  132. package/dist/upgrader-iam-preflight.d.ts +123 -0
  133. package/dist/upgrader-iam-preflight.d.ts.map +1 -0
  134. package/dist/upgrader-iam-preflight.js +414 -0
  135. package/dist/upgrader-iam-preflight.js.map +1 -0
  136. package/dist/upgrader-job.d.ts +33 -9
  137. package/dist/upgrader-job.d.ts.map +1 -1
  138. package/dist/upgrader-job.js +169 -27
  139. package/dist/upgrader-job.js.map +1 -1
  140. package/dist/upgrader-records.d.ts +58 -0
  141. package/dist/upgrader-records.d.ts.map +1 -0
  142. package/dist/upgrader-records.js +87 -0
  143. package/dist/upgrader-records.js.map +1 -0
  144. package/dist/upgrader-reporter.d.ts +3 -2
  145. package/dist/upgrader-reporter.d.ts.map +1 -1
  146. package/dist/upgrader-reporter.js +10 -2
  147. package/dist/upgrader-reporter.js.map +1 -1
  148. package/dist/upgrader-setup.d.ts +25 -3
  149. package/dist/upgrader-setup.d.ts.map +1 -1
  150. package/dist/upgrader-setup.js +132 -14
  151. package/dist/upgrader-setup.js.map +1 -1
  152. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  153. package/dist/wizards/gcp-cost.js +14 -6
  154. package/dist/wizards/gcp-cost.js.map +1 -1
  155. package/dist/wizards/gcp-pulumi.d.ts +6 -0
  156. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  157. package/dist/wizards/gcp-pulumi.js +7 -0
  158. package/dist/wizards/gcp-pulumi.js.map +1 -1
  159. package/dist/wizards/gcp.d.ts.map +1 -1
  160. package/dist/wizards/gcp.js +130 -4
  161. package/dist/wizards/gcp.js.map +1 -1
  162. package/package.json +4 -4
  163. package/src/agent/run.test.ts +15 -1
  164. package/src/audit-job-setup.test.ts +234 -0
  165. package/src/audit-job-setup.ts +360 -0
  166. package/src/caddy-redirects.docker.test.ts +19 -6
  167. package/src/caddy.test.ts +30 -0
  168. package/src/caddy.ts +43 -0
  169. package/src/cli-init-validation.test.ts +44 -0
  170. package/src/cli-routing.ts +1 -1
  171. package/src/cli.ts +40 -8
  172. package/src/compose.ts +5 -0
  173. package/src/converge-checkpoint.ts +75 -0
  174. package/src/converge-entrypoint.test.ts +213 -0
  175. package/src/converge-job-gcp.test.ts +222 -0
  176. package/src/converge-job-gcp.ts +443 -0
  177. package/src/converge-job-setup.test.ts +152 -0
  178. package/src/converge-job-setup.ts +268 -0
  179. package/src/converge-job.test.ts +486 -0
  180. package/src/converge-job.ts +542 -0
  181. package/src/gateway-env-isolation.test.ts +8 -1
  182. package/src/gcp-names.ts +43 -1
  183. package/src/gcp-url-map.test.ts +9 -1
  184. package/src/index.ts +8 -0
  185. package/src/infra-contract.test.ts +7 -4
  186. package/src/infra-contract.ts +28 -1
  187. package/src/install-job.test.ts +3 -1
  188. package/src/install-job.ts +31 -14
  189. package/src/lifecycle.ts +137 -71
  190. package/src/operator-access.test.ts +14 -0
  191. package/src/operator-access.ts +13 -3
  192. package/src/private-file.test.ts +80 -0
  193. package/src/private-file.ts +50 -0
  194. package/src/release-contract.ts +8 -2
  195. package/src/release-manifest.test.ts +42 -0
  196. package/src/release-manifest.ts +2 -0
  197. package/src/remote-mcp-edge.ts +137 -0
  198. package/src/remote-mcp.test.ts +517 -0
  199. package/src/remote-mcp.ts +460 -0
  200. package/src/runtime-secrets.test.ts +31 -6
  201. package/src/runtime-secrets.ts +41 -3
  202. package/src/seal-key-rotation.test.ts +131 -0
  203. package/src/seal-key-rotation.ts +258 -0
  204. package/src/stack-contract-parity.test.ts +20 -3
  205. package/src/stack-contract.test.ts +34 -2
  206. package/src/stack-contract.ts +305 -3
  207. package/src/stack-converge-secrets.test.ts +3 -0
  208. package/src/stack-converge.test.ts +21 -5
  209. package/src/stack-converge.ts +101 -7
  210. package/src/state-attach.test.ts +7 -1
  211. package/src/state-lock-lease.test.ts +302 -0
  212. package/src/state-lock-lease.ts +423 -0
  213. package/src/state-lock.test.ts +568 -0
  214. package/src/state-store.ts +347 -27
  215. package/src/tar-entries.test.ts +149 -7
  216. package/src/tar-entries.ts +90 -9
  217. package/src/upgrade/app-roll.ts +135 -21
  218. package/src/upgrade/audit-job-roll.ts +68 -0
  219. package/src/upgrade/converge.ts +170 -7
  220. package/src/upgrade/gcp-lookup.test.ts +80 -0
  221. package/src/upgrade/gcp-lookup.ts +65 -8
  222. package/src/upgrade/record-rolled.test.ts +105 -0
  223. package/src/upgrade/record-rolled.ts +91 -0
  224. package/src/upgrade/upgrade.test.ts +279 -5
  225. package/src/upgrade/upgrade.ts +120 -24
  226. package/src/upgrader-assess.test.ts +93 -3
  227. package/src/upgrader-assess.ts +58 -9
  228. package/src/upgrader-gcp.ts +47 -8
  229. package/src/upgrader-iam-preflight.test.ts +393 -0
  230. package/src/upgrader-iam-preflight.ts +510 -0
  231. package/src/upgrader-job.test.ts +587 -15
  232. package/src/upgrader-job.ts +237 -53
  233. package/src/upgrader-records.ts +151 -0
  234. package/src/upgrader-reporter.test.ts +6 -0
  235. package/src/upgrader-reporter.ts +7 -2
  236. package/src/upgrader-setup.test.ts +4 -3
  237. package/src/upgrader-setup.ts +137 -13
  238. package/src/wizards/gcp-cost.ts +15 -6
  239. package/src/wizards/gcp-pulumi.ts +7 -0
  240. package/src/wizards/gcp.ts +162 -4
  241. package/stacks/aws/index.ts +18 -0
  242. package/stacks/azure/index.ts +18 -0
  243. package/stacks/gcp/index.ts +59 -7
  244. package/stacks/gcp-firebase/index.ts +50 -6
@@ -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";
@@ -49,6 +86,7 @@ const POLICY: StoredUpdatePolicy = {
49
86
  autoSecurity: false,
50
87
  autoPatch: false,
51
88
  autoMinor: false,
89
+ autoConverge: false,
52
90
  channel: "stable",
53
91
  windowStart: null,
54
92
  windowEnd: null,
@@ -76,6 +114,11 @@ const META: InstallMetadata = {
76
114
  infraContractVersion: 2,
77
115
  };
78
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
+
79
122
  const serviceJson = (image: string, env: unknown[]) =>
80
123
  JSON.stringify({ spec: { template: { spec: { containers: [{ image, env }] } } } });
81
124
  const secretRef = (name: string, secret: string) => ({
@@ -105,7 +148,8 @@ function fakeGcloud(events: string[], overrides: [RegExp, Answer][] = []) {
105
148
  [/describe (\S+) .*status\.url/, (l) => ok(`https://${l.split(" ")[3]}-xyz.a.run.app\n`)],
106
149
  [
107
150
  /^run revisions describe (\S+)/,
108
- (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"))),
109
153
  ],
110
154
  [/^sql instances list/, ok("caelo-production-pg-abc\n")],
111
155
  [/^sql backups list/, ok("1760000000000\n")],
@@ -158,12 +202,20 @@ function setup(
158
202
  opts: {
159
203
  request?: Partial<ClaimedUpdate>;
160
204
  manifest?: ReleaseManifestResult;
205
+ /** Manifests of other versions than the target (the serving release's). */
206
+ manifests?: Record<string, ReleaseManifestResult>;
161
207
  meta?: InstallMetadata | null;
162
208
  gcloud?: [RegExp, Answer][];
163
209
  deep?: HealthOutcome;
164
210
  migrate?: { ok: boolean; error?: string };
165
211
  locked?: boolean;
166
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;
167
219
  } = {},
168
220
  ) {
169
221
  const order: string[] = [];
@@ -184,14 +236,21 @@ function setup(
184
236
  region: "europe-west1",
185
237
  env: "production",
186
238
  execution: "caelo-production-upgrader-x1",
239
+ iam: fakeIam(order, opts.iamWithheld ?? {}, opts.iamError),
187
240
  fetchManifest: async (v) => {
188
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
+ }
189
247
  return opts.manifest ?? { ok: true, manifest: MANIFEST };
190
248
  },
191
- lockState: () => {
249
+ lockState: (onBreak) => {
192
250
  order.push("lock");
193
251
  if (opts.locked) throw new StateLockHeldError("held by caelo installer");
194
252
  if (opts.lockDenied) throw new Error("storage.objects.list denied");
253
+ if (opts.brokeStaleLock) onBreak(opts.brokeStaleLock);
195
254
  return () => order.push("unlock");
196
255
  },
197
256
  readInstall: () => (opts.meta === undefined ? META : opts.meta),
@@ -212,6 +271,23 @@ function setup(
212
271
  return { deps, order, q, written };
213
272
  }
214
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
+
215
291
  const step = (order: string[], re: RegExp) => order.findIndex((l) => re.test(l));
216
292
 
217
293
  describe("runUpgraderJob", () => {
@@ -274,10 +350,12 @@ describe("runUpgraderJob", () => {
274
350
  ]);
275
351
  });
276
352
 
277
- 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 () => {
278
354
  const ok = setup();
279
355
  await runUpgraderJob(ok.deps);
280
- 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/));
281
359
  expect(ok.order.indexOf("unlock")).toBeGreaterThan(
282
360
  step(ok.order, /jobs update caelo-production-upgrader/),
283
361
  );
@@ -286,7 +364,83 @@ describe("runUpgraderJob", () => {
286
364
  const r = await runUpgraderJob(busy.deps);
287
365
  expect(r.status).toBe("failed");
288
366
  expect(busy.q.end()?.codes).toEqual(["state-locked"]);
289
- 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
+ }
290
444
  });
291
445
 
292
446
  it("does not call a lock it cannot take 'busy': an access failure asks for Cloud Shell", async () => {
@@ -297,9 +451,61 @@ describe("runUpgraderJob", () => {
297
451
  expect(end?.codes).toEqual(["state-access"]);
298
452
  expect(end?.reason).not.toContain("another install operation");
299
453
  expect(end?.reason).toContain("storage.objects.list denied");
300
- expect(denied.order.some((l) => l.startsWith("manifest") || l.startsWith("gcloud"))).toBe(
301
- false,
302
- );
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();
303
509
  });
304
510
 
305
511
  it("retries recording the end, and fails the run when it cannot", async () => {
@@ -353,25 +559,391 @@ describe("runUpgraderJob", () => {
353
559
  expect(q.end()?.codes).toEqual(["install-state"]);
354
560
  });
355
561
 
356
- it("refuses when the serving release is not the recorded one (a lost install.json write)", async () => {
357
- 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({
358
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") })],
359
831
  });
360
832
  expect((await runUpgraderJob(deps)).status).toBe("needs_cloud_shell");
361
833
  expect(q.end()?.codes).toEqual(["install-state-drift"]);
362
- expect(order.some((l) => l.startsWith("manifest"))).toBe(false);
834
+ expect(written).toEqual([]);
363
835
  });
364
836
 
365
- it("a failed install.json write still ends succeeded, and the next run catches the drift", async () => {
366
- 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();
367
934
  const r = await runUpgraderJob({
368
935
  ...deps,
369
936
  writeInstall: () => {
370
937
  throw new Error("403 on the ops bucket");
371
938
  },
372
939
  });
373
- expect(r.status).toBe("succeeded");
374
- 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);
375
947
  });
376
948
 
377
949
  it("a failed backup stops before migrations", async () => {