@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
@@ -7,17 +7,20 @@
7
7
  * CLI-generated runtime secrets, the stack's IAM/CDN/firewall invariants,
8
8
  * MCP access through IAP, public_role's Cloud SQL role memberships (#633),
9
9
  * the gateway's and public_role's database logins, the operator-access sync
10
- * job, the quality-audit job (#875), and dropping bindings nothing reads any
11
- * more.
10
+ * job, the quality-audit job (#875), the remote-MCP load-balancer rules and
11
+ * identity env (#806), and dropping bindings nothing reads any more.
12
12
  *
13
- * It needs the rights of whoever provisioned the install, so only
14
- * `cms-provision upgrade` (the operator's own identity) runs it — never the
15
- * in-cloud upgrader job, which only app-rolls and refuses while
16
- * {@link needsConverge} says the install is behind.
13
+ * It needs the rights of whoever provisioned the install: `cms-provision
14
+ * upgrade` runs it as the operator's own identity, and the converge job
15
+ * (#819, converge-job.ts) runs the same upgrade as the install's
16
+ * `caelo-provisioner` account. Never the in-cloud upgrader job, which only
17
+ * app-rolls and refuses while {@link needsConverge} says the install is
18
+ * behind.
17
19
  *
18
20
  * The keyless-provisioner step (#696, provisioner-identity.ts) is converge
19
21
  * too; it needs the operator's local install dir, so the CLI injects it as
20
- * {@link ConvergeDeps.provisionerIdentity}.
22
+ * {@link ConvergeDeps.provisionerIdentity} (the converge job, which IS the
23
+ * provisioner, has nothing to move and passes none).
21
24
  *
22
25
  * Some steps must sit between app-roll steps (a role migration 0248 creates
23
26
  * gets its password after migrations, before the gateway rolls onto it), so
@@ -26,6 +29,7 @@
26
29
  */
27
30
 
28
31
  import { ensureAuditJob, resolveAdminImage } from "../audit-job-setup.js";
32
+ import { ensureConvergeJob } from "../converge-job-setup.js";
29
33
  import { findSqlInstance, planPublicRoleSwitch } from "../gateway-credentials.js";
30
34
  import type { GcloudRunner, Sleep } from "../gcloud-retry.js";
31
35
  import { upgraderServiceAccountEmail } from "../gcp-names.js";
@@ -33,6 +37,7 @@ import type { InstallMetadata } from "../install-state.js";
33
37
  import { ensureMcpIapAccess, type IapResource } from "../mcp-iap.js";
34
38
  import { ensureOperatorAccessSync, resolveOperatorAccessTarget } from "../operator-access.js";
35
39
  import { revokePublicRoleMemberships } from "../public-role-isolation.js";
40
+ import { ensureRemoteMcpEdge, resolveRemoteMcpIdentity } from "../remote-mcp.js";
36
41
  import {
37
42
  ensureGatewayServiceAccount,
38
43
  ensureGeneratedSecrets,
@@ -44,8 +49,11 @@ import { selfCommand } from "../self-command.js";
44
49
  import {
45
50
  AUDIT_JOB_ENV_VAR,
46
51
  AUDIT_RESULTS_BUCKET_ENV_VAR,
52
+ type CloudRunEnvVar,
53
+ CONVERGE_JOB_ENV_VAR,
47
54
  MCP_ENV_VAR,
48
55
  OPERATOR_ACCESS_JOB_ENV_VAR,
56
+ remoteMcpIdentityEnv,
49
57
  UPGRADER_JOB_ENV_VAR,
50
58
  } from "../stack-contract.js";
51
59
  import {
@@ -308,6 +316,65 @@ export function startConverge(
308
316
  leaveUntouched.push(AUDIT_JOB_ENV_VAR, AUDIT_RESULTS_BUCKET_ENV_VAR);
309
317
  }
310
318
 
319
+ async function remoteMcpEdge(): Promise<void> {
320
+ // #806 — on `gcp` the load balancer routes the remote-MCP discovery
321
+ // paths to the gateway and Cloud Armor lets agent traffic through on
322
+ // /api/mcp/* (remote-mcp-edge.ts). Only remote MCP depends on it, so a
323
+ // failure warns.
324
+ if (install.provider !== "gcp") return;
325
+ const s = r.step("Ensuring the load balancer's remote-MCP routes and Cloud Armor rule...");
326
+ const edge = await ensureRemoteMcpEdge(
327
+ {
328
+ provider: install.provider,
329
+ projectId: install.projectId,
330
+ env: install.env,
331
+ domain: install.domain,
332
+ },
333
+ { ...runDeps, ...(deps.http ? { http: deps.http } : {}) },
334
+ );
335
+ if (edge.ok) {
336
+ for (const line of edge.done) r.info(` ${line}`);
337
+ s.done("Remote-MCP load-balancer rules ready");
338
+ return;
339
+ }
340
+ s.warned(`Remote-MCP load-balancer rules not set up: ${edge.error}`);
341
+ r.warn(
342
+ "The upgrade continues; remote MCP (Settings → MCP → Remote) cannot be reached through the site's domain until this succeeds. Re-run upgrade once the cause is fixed.",
343
+ );
344
+ incomplete = true;
345
+ }
346
+
347
+ async function remoteMcpIdentity(): Promise<Record<RollSlug, CloudRunEnvVar[]> | null> {
348
+ // #806 — CAELO_ADMIN_URL / CAELO_MCP_PUBLIC_URL / CAELO_IAP_AUDIENCE
349
+ // name identities the env contract cannot (stack-contract.ts
350
+ // RemoteMcpIdentity). Resolved here, read-only, and set by the rolls
351
+ // (AppRollHooks.afterMigrations `extraEnv`), so they ride the new
352
+ // revisions. A failure leaves the vars as the services have them and
353
+ // warns: only remote MCP depends on them.
354
+ const s = r.step("Resolving the remote-MCP URLs and IAP audience...");
355
+ const resolved = await resolveRemoteMcpIdentity(
356
+ {
357
+ provider: install.provider,
358
+ projectId: install.projectId,
359
+ region: install.region,
360
+ env: install.env,
361
+ domain: install.domain,
362
+ services,
363
+ },
364
+ runDeps,
365
+ );
366
+ if (resolved.ok) {
367
+ s.done(`Remote MCP can be set up under Settings → MCP (${resolved.identity.mcpPublicUrl})`);
368
+ return remoteMcpIdentityEnv(resolved.identity);
369
+ }
370
+ s.warned(`Remote-MCP env not resolved: ${resolved.error}`);
371
+ r.warn(
372
+ "The upgrade continues; Settings → MCP reports remote MCP as not prepared on this install until this succeeds. Re-run upgrade once the cause is fixed.",
373
+ );
374
+ incomplete = true;
375
+ return null;
376
+ }
377
+
311
378
  /** The admin's IAP resource: the service on gcp-firebase, the LB backend on gcp. */
312
379
  async function adminIapResource(): Promise<IapResource | null> {
313
380
  if (install.provider === "gcp-firebase") {
@@ -394,6 +461,54 @@ export function startConverge(
394
461
  leaveUntouched.push(UPGRADER_JOB_ENV_VAR);
395
462
  }
396
463
 
464
+ async function convergeJob(): Promise<void> {
465
+ // #819 — the job "Install update" starts for the next release that
466
+ // changes the cloud setup, on this release's ops image. After
467
+ // migrations: its database login gets the role migration 0267 creates.
468
+ // A failure only costs in-Caelo converges (the terminal path stays), so
469
+ // it warns.
470
+ const s = r.step("Ensuring the converge job (cloud-setup updates from Settings → Updates)...");
471
+ const resolved = deps.opsImageRef
472
+ ? await resolveUpgraderTarget(
473
+ {
474
+ projectId: install.projectId,
475
+ region: install.region,
476
+ env: install.env,
477
+ opsImageRef: deps.opsImageRef,
478
+ },
479
+ { run },
480
+ )
481
+ : { ok: false as const, error: "the release's caelo-ops image could not be resolved" };
482
+ const cj = resolved.ok
483
+ ? await ensureConvergeJob(resolved.target, runDeps)
484
+ : { ok: false as const, done: [], error: resolved.error };
485
+ for (const step of cj.done) r.info(` ${step}`);
486
+ if (cj.ok) {
487
+ s.done("Converge job ready (updates that change the cloud setup install from the admin)");
488
+ return;
489
+ }
490
+ if (cj.reason === "no-provisioner") {
491
+ // #883 review — optional for contract 9: an install without a
492
+ // provisioner (installed before it existed) has nothing the job could
493
+ // run as, and creating one is a privileged identity no update should
494
+ // mint. It stays fully converged; its admin keeps no
495
+ // CAELO_CONVERGE_JOB and so routes cloud-setup releases to the
496
+ // terminal, saying so on Settings → Updates.
497
+ s.done("No converge job: this install has no caelo-provisioner to run it as");
498
+ r.info(
499
+ "Updates that change the cloud setup keep installing from Cloud Shell or the computer you installed from; Settings → Updates says so when one comes.",
500
+ );
501
+ leaveUntouched.push(CONVERGE_JOB_ENV_VAR);
502
+ return;
503
+ }
504
+ s.warned(`Converge job not set up: ${cj.error}`);
505
+ r.warn(
506
+ "The upgrade continues; releases that change the cloud setup keep installing from a terminal until this succeeds.",
507
+ );
508
+ incomplete = true;
509
+ leaveUntouched.push(CONVERGE_JOB_ENV_VAR);
510
+ }
511
+
397
512
  async function operatorAccess(): Promise<void> {
398
513
  // The job that keeps IAP in step with the user list (operator-access.ts).
399
514
  // After migrations: its database user gets the role migration 0239
@@ -447,6 +562,7 @@ export function startConverge(
447
562
  await mcpAccess();
448
563
  await urlMapRedirectsAccess();
449
564
  await auditJob();
565
+ await remoteMcpEdge();
450
566
  return true;
451
567
  },
452
568
 
@@ -454,8 +570,14 @@ export function startConverge(
454
570
  async afterMigrations() {
455
571
  if (!(await databaseLogins())) return { ok: false };
456
572
  await upgraderJob();
573
+ await convergeJob();
457
574
  await operatorAccess();
458
- return { ok: true, leaveUntouched: [...leaveUntouched] };
575
+ const extraEnv = await remoteMcpIdentity();
576
+ return {
577
+ ok: true,
578
+ leaveUntouched: [...leaveUntouched],
579
+ ...(extraEnv ? { extraEnv } : {}),
580
+ };
459
581
  },
460
582
  async beforeRoll(slug) {
461
583
  if (slug !== "gateway" || !publicRole) return { ok: true };
@@ -0,0 +1,80 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * findServingImage against a fake gcloud: the digest a revision was deployed
5
+ * with is the release's index digest, Cloud Run's `status.imageDigest` the
6
+ * platform manifest inside it (#860). Both must come back, so a recorded
7
+ * index digest matches what serves.
8
+ */
9
+
10
+ import { describe, expect, it } from "bun:test";
11
+ import type { GcloudResult } from "../gcloud.js";
12
+ import type { GcloudRunner } from "../gcloud-retry.js";
13
+ import { findServingImage, servingImageDigests } from "./gcp-lookup.js";
14
+
15
+ const INDEX = `sha256:${"a".repeat(64)}`;
16
+ const PLATFORM = `sha256:${"b".repeat(64)}`;
17
+ const REPO = "europe-west1-docker.pkg.dev/caelo-website/caelo-cms-images/admin";
18
+
19
+ const ok = (stdout: string): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
20
+
21
+ function gcloud(revision: GcloudResult): GcloudRunner {
22
+ return async (args) => {
23
+ if (args.includes("services")) return ok("caelo-production-admin-x-00042-abc\n");
24
+ return revision;
25
+ };
26
+ }
27
+
28
+ const revision = (image: string, imageDigest?: string) =>
29
+ ok(JSON.stringify({ spec: { containers: [{ image }] }, status: { imageDigest } }));
30
+
31
+ describe("findServingImage", () => {
32
+ it("returns the deployed index digest and the platform digest Cloud Run resolved", async () => {
33
+ const image = await findServingImage(
34
+ gcloud(revision(`${REPO}@${INDEX}`, `${REPO}@${PLATFORM}`)),
35
+ "acme",
36
+ "europe-west1",
37
+ "caelo-production-admin-x",
38
+ );
39
+ expect(image).toEqual({ deployedDigest: INDEX, resolvedDigest: PLATFORM });
40
+ expect(servingImageDigests(image ?? { deployedDigest: null, resolvedDigest: null })).toEqual([
41
+ INDEX,
42
+ PLATFORM,
43
+ ]);
44
+ });
45
+
46
+ it("a revision deployed by tag is known by its resolved digest only", async () => {
47
+ const image = await findServingImage(
48
+ gcloud(revision(`${REPO}:0.10.52`, `${REPO}@${PLATFORM}`)),
49
+ "acme",
50
+ "europe-west1",
51
+ "svc",
52
+ );
53
+ expect(image).toEqual({ deployedDigest: null, resolvedDigest: PLATFORM });
54
+ });
55
+
56
+ it("one digest when both name the same image", () => {
57
+ expect(servingImageDigests({ deployedDigest: INDEX, resolvedDigest: INDEX })).toEqual([INDEX]);
58
+ });
59
+
60
+ it("null when no digest can be read or the output is not JSON", async () => {
61
+ expect(
62
+ await findServingImage(gcloud(revision(`${REPO}:latest`)), "acme", "europe-west1", "svc"),
63
+ ).toBeNull();
64
+ expect(
65
+ await findServingImage(gcloud(ok("not json")), "acme", "europe-west1", "svc"),
66
+ ).toBeNull();
67
+ // JSON of an unexpected shape reads as unknown instead of throwing.
68
+ for (const odd of ["null", "[]", '{"spec":{"containers":{"image":1}}}', '{"status":"x"}']) {
69
+ expect(await findServingImage(gcloud(ok(odd)), "acme", "europe-west1", "svc")).toBeNull();
70
+ }
71
+ expect(
72
+ await findServingImage(
73
+ gcloud({ ok: false, stdout: "", stderr: "denied", exitCode: 1 }),
74
+ "acme",
75
+ "europe-west1",
76
+ "svc",
77
+ ),
78
+ ).toBeNull();
79
+ });
80
+ });
@@ -7,6 +7,7 @@
7
7
  * by a different identity in the in-cloud upgrader job (epic #696).
8
8
  */
9
9
 
10
+ import { z } from "zod";
10
11
  import type { GcloudRunner } from "../gcloud-retry.js";
11
12
 
12
13
  /**
@@ -102,16 +103,57 @@ export async function findCurrentRevision(
102
103
  }
103
104
 
104
105
  /**
105
- * The image digest the revision serving traffic runs (not the service
106
- * template's: after a rollback the template names the failed image while
107
- * traffic stays on the previous revision).
106
+ * The image the revision serving traffic runs, by every digest it is known
107
+ * by (not the service template's: after a rollback the template names the
108
+ * failed image while traffic stays on the previous revision).
109
+ *
110
+ * Why two digests (#860): Caelo's release images are OCI image *indexes*
111
+ * (buildx adds a provenance/SBOM attestation manifest even to a
112
+ * single-platform build), and the signed release manifest, install.json
113
+ * and every deploy pin the index digest. Cloud Run's `status.imageDigest`
114
+ * names what it resolved and pulled, the linux/amd64 *platform manifest*
115
+ * inside that index, so comparing it with a recorded index digest never
116
+ * matches. The digest the revision was deployed with
117
+ * (`spec.containers[0].image`, `…@sha256:<index>`) is the form everything
118
+ * else records; it is content-addressed, so the revision necessarily runs
119
+ * that index's image.
108
120
  */
109
- export async function findServingImageDigest(
121
+ export interface ServingImage {
122
+ /** The digest the revision was deployed with; null when deployed by tag. */
123
+ readonly deployedDigest: string | null;
124
+ /** The digest Cloud Run resolved and runs (`status.imageDigest`); null when not reported. */
125
+ readonly resolvedDigest: string | null;
126
+ }
127
+
128
+ /** Every digest `image` is known by, deployed form first. */
129
+ export function servingImageDigests(image: ServingImage): string[] {
130
+ const out: string[] = [];
131
+ for (const d of [image.deployedDigest, image.resolvedDigest]) {
132
+ if (d && !out.includes(d)) out.push(d);
133
+ }
134
+ return out;
135
+ }
136
+
137
+ const PINNED_DIGEST = /@(sha256:[0-9a-f]{64})$/;
138
+
139
+ /** The fields of `gcloud run revisions describe --format=json` the lookup reads. */
140
+ const RevisionJsonSchema = z.object({
141
+ spec: z
142
+ .object({ containers: z.array(z.object({ image: z.string().optional() })).optional() })
143
+ .optional(),
144
+ status: z.object({ imageDigest: z.string().optional() }).optional(),
145
+ });
146
+
147
+ /**
148
+ * The serving revision's {@link ServingImage}, or null when the revision or
149
+ * any digest of it cannot be read.
150
+ */
151
+ export async function findServingImage(
110
152
  run: GcloudRunner,
111
153
  projectId: string,
112
154
  region: string,
113
155
  serviceName: string,
114
- ): Promise<string | null> {
156
+ ): Promise<ServingImage | null> {
115
157
  const revision = await findCurrentRevision(run, projectId, region, serviceName);
116
158
  if (!revision) return null;
117
159
  const r = await run([
@@ -123,11 +165,26 @@ export async function findServingImageDigest(
123
165
  region,
124
166
  "--project",
125
167
  projectId,
126
- "--format=value(status.imageDigest)",
168
+ "--format=json",
127
169
  ]);
128
170
  if (!r.ok) return null;
129
- const m = /@(sha256:[0-9a-f]{64})\s*$/.exec(r.stdout.trim());
130
- return m?.[1] ?? null;
171
+ let json: unknown;
172
+ try {
173
+ json = JSON.parse(r.stdout);
174
+ } catch {
175
+ return null;
176
+ }
177
+ // gcloud output is a boundary (CLAUDE.md §4): an unexpected shape (`null`,
178
+ // an array, a non-string image) reads as "unknown" instead of throwing.
179
+ const parsed = RevisionJsonSchema.safeParse(json);
180
+ if (!parsed.success) return null;
181
+ const digestOf = (ref: string | undefined): string | null =>
182
+ ref === undefined ? null : (PINNED_DIGEST.exec(ref.trim())?.[1] ?? null);
183
+ const image: ServingImage = {
184
+ deployedDigest: digestOf(parsed.data.spec?.containers?.[0]?.image),
185
+ resolvedDigest: digestOf(parsed.data.status?.imageDigest),
186
+ };
187
+ return servingImageDigests(image).length > 0 ? image : null;
131
188
  }
132
189
 
133
190
  /** The service's own `run.app` URL. */
@@ -0,0 +1,105 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * recordRolledDigests (#860): a CLI upgrade whose records could not be
5
+ * written used to exit 0 with a warning, leaving install.json naming the
6
+ * previous release. It now reports failure (the command exits non-zero)
7
+ * with the exact fix, and never skips one record because another failed.
8
+ */
9
+
10
+ import { describe, expect, it } from "bun:test";
11
+ import type { ImageDigests } from "../install-state.js";
12
+ import { type RecordRolledDeps, recordRolledDigests } from "./record-rolled.js";
13
+
14
+ const DIGESTS: ImageDigests = {
15
+ admin: `sha256:${"a".repeat(64)}`,
16
+ gateway: `sha256:${"b".repeat(64)}`,
17
+ };
18
+
19
+ function deps(overrides: Partial<RecordRolledDeps> = {}) {
20
+ const lines: { level: string; message: string }[] = [];
21
+ const calls: string[] = [];
22
+ const d: RecordRolledDeps = {
23
+ recordInstall: (_digests, version) => {
24
+ calls.push(`install ${version}`);
25
+ },
26
+ pinStack: async () => {
27
+ calls.push("stack");
28
+ return { removedOverrides: [] };
29
+ },
30
+ stackRecovery: () => " pulumi config set …",
31
+ installJsonLocation: "gs://acme-caelo-ops/install/install.json",
32
+ log: {
33
+ info: (message) => lines.push({ level: "info", message }),
34
+ warn: (message) => lines.push({ level: "warn", message }),
35
+ error: (message) => lines.push({ level: "error", message }),
36
+ },
37
+ ...overrides,
38
+ };
39
+ return { d, lines, calls };
40
+ }
41
+
42
+ describe("recordRolledDigests", () => {
43
+ it("records install.json and the stack config", async () => {
44
+ const { d, calls, lines } = deps();
45
+ expect(await recordRolledDigests(DIGESTS, "0.10.53", d)).toBe(true);
46
+ expect(calls).toEqual(["install 0.10.53", "stack"]);
47
+ expect(lines.filter((l) => l.level === "error")).toEqual([]);
48
+ });
49
+
50
+ it("regression #860: a failed install.json write fails the upgrade, with the exact fix", async () => {
51
+ const { d, calls, lines } = deps({
52
+ recordInstall: () => {
53
+ throw new Error("Could not write gs://acme-caelo-ops/install/install.json: 403");
54
+ },
55
+ });
56
+ expect(await recordRolledDigests(DIGESTS, "0.10.53", d)).toBe(false);
57
+ const error = lines.find((l) => l.level === "error")?.message ?? "";
58
+ expect(error).toContain("gs://acme-caelo-ops/install/install.json");
59
+ expect(error).toContain(DIGESTS.admin);
60
+ expect(error).toContain('"releaseVersion": "0.10.53"');
61
+ // The stack config is still pinned.
62
+ expect(calls).toEqual(["stack"]);
63
+ });
64
+
65
+ it("regression #865: with no known version, the manual fix removes the stale releaseVersion", async () => {
66
+ const { d, lines } = deps({
67
+ recordInstall: () => {
68
+ throw new Error("403");
69
+ },
70
+ });
71
+ expect(await recordRolledDigests(DIGESTS, null, d)).toBe(false);
72
+ const error = lines.find((l) => l.level === "error")?.message ?? "";
73
+ expect(error).toContain(DIGESTS.admin);
74
+ expect(error).not.toContain('"releaseVersion":');
75
+ expect(error).toContain('delete the "releaseVersion" field');
76
+ });
77
+
78
+ it("with a known version, the manual fix sets it and says nothing about deleting it", async () => {
79
+ const { d, lines } = deps({
80
+ recordInstall: () => {
81
+ throw new Error("403");
82
+ },
83
+ });
84
+ await recordRolledDigests(DIGESTS, "0.10.53", d);
85
+ const error = lines.find((l) => l.level === "error")?.message ?? "";
86
+ expect(error).not.toContain("delete the");
87
+ });
88
+
89
+ it("a failed stack-config pin fails the upgrade too, with the commands to run", async () => {
90
+ const { d, lines } = deps({
91
+ pinStack: async () => {
92
+ throw new Error("passphrase missing");
93
+ },
94
+ });
95
+ expect(await recordRolledDigests(DIGESTS, "0.10.53", d)).toBe(false);
96
+ expect(lines.find((l) => l.level === "error")?.message).toContain("pulumi config set");
97
+ });
98
+
99
+ it("an unknown release version is recorded as none, with a warning, not a failure", async () => {
100
+ const { d, calls, lines } = deps();
101
+ expect(await recordRolledDigests(DIGESTS, null, d)).toBe(true);
102
+ expect(calls).toEqual(["install null", "stack"]);
103
+ expect(lines.some((l) => l.level === "warn")).toBe(true);
104
+ });
105
+ });
@@ -0,0 +1,91 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * After a CLI upgrade rolled: record the digests that now serve, so nothing
5
+ * re-deploys an older release — install.json (what a wizard re-run deploys
6
+ * and what the in-cloud upgrader judges the next update by) and the Pulumi
7
+ * stack config (what a plain `pulumi up` deploys).
8
+ *
9
+ * The roll already succeeded, so a failed write never undoes it, and one
10
+ * failing never skips the other. But it is a failed upgrade, not a warning
11
+ * (#860, CLAUDE.md §2 no fallbacks): a run that exits 0 while install.json
12
+ * still names the previous release leaves every later update judging
13
+ * against a stale record. So each failure prints the exact manual fix and
14
+ * the result makes the command exit non-zero.
15
+ */
16
+
17
+ import type { ImageDigests } from "../install-state.js";
18
+
19
+ /** Where the records go; the real ones are wired in lifecycle.ts. */
20
+ export interface RecordRolledDeps {
21
+ /** Write install.json (recordImageDigests). Throws when it cannot. */
22
+ readonly recordInstall: (digests: ImageDigests, releaseVersion: string | null) => void;
23
+ /** Pin the digests in the Pulumi stack config. Throws when it cannot. */
24
+ readonly pinStack: (
25
+ digests: ImageDigests,
26
+ ) => Promise<{ readonly removedOverrides: readonly string[] }>;
27
+ /** The commands that pin `digests` in the stack by hand. */
28
+ readonly stackRecovery: (digests: ImageDigests) => string;
29
+ /** Where install.json lives, for the manual fix (`gs://…/install.json` or a path). */
30
+ readonly installJsonLocation: string;
31
+ readonly log: {
32
+ readonly info: (message: string) => void;
33
+ readonly warn: (message: string) => void;
34
+ readonly error: (message: string) => void;
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Record the rolled `digests` (and `releaseVersion`, when known).
40
+ *
41
+ * @returns whether every record was written; false means the upgrade must
42
+ * exit non-zero — the fix for each failure has been printed.
43
+ */
44
+ export async function recordRolledDigests(
45
+ digests: ImageDigests,
46
+ releaseVersion: string | null,
47
+ deps: RecordRolledDeps,
48
+ ): Promise<boolean> {
49
+ const { log } = deps;
50
+ const reason = (e: unknown) => (e instanceof Error ? e.message.split("\n")[0] : String(e));
51
+ if (!releaseVersion) {
52
+ log.warn(
53
+ "Could not tell which Caelo release these images are (no version tag points at them), so install.json records none; in-product updates stay unavailable until an upgrade to a released version.",
54
+ );
55
+ }
56
+ let ok = true;
57
+ try {
58
+ deps.recordInstall(digests, releaseVersion);
59
+ } catch (e) {
60
+ ok = false;
61
+ const fields = {
62
+ imageDigests: digests,
63
+ ...(releaseVersion ? { releaseVersion } : {}),
64
+ };
65
+ // With no known version the fix must REMOVE the recorded one: left in
66
+ // place, the previous release's version would sit next to the new
67
+ // digests, and the upgrader (which matches records by digest only)
68
+ // would judge the next update's downgrade / minFromVersion against it.
69
+ const versionFix = releaseVersion
70
+ ? ""
71
+ : '\nAlso delete the "releaseVersion" field from that file: it names the previous release, not the one that serves now.';
72
+ log.error(
73
+ `The new release serves, but its digests could not be recorded in ${deps.installJsonLocation} (${reason(e)}). Set these fields there before anything else, or a re-run of the installer deploys another release and in-product updates refuse to run:\n${JSON.stringify(fields, null, 2)}${versionFix}`,
74
+ );
75
+ }
76
+ try {
77
+ const { removedOverrides } = await deps.pinStack(digests);
78
+ log.info("Pinned the rolled image digests in the Pulumi stack config.");
79
+ if (removedOverrides.length > 0) {
80
+ log.warn(
81
+ `Removed ${removedOverrides.join(", ")} from the stack config: those image overrides win over the digest pins, so a later \`pulumi up\` would have rolled back to them.`,
82
+ );
83
+ }
84
+ } catch (e) {
85
+ ok = false;
86
+ log.error(
87
+ `Could not pin the rolled digests in the Pulumi stack config (${reason(e)}). Before any manual \`pulumi up\`, run:\n${deps.stackRecovery(digests)}`,
88
+ );
89
+ }
90
+ return ok;
91
+ }