@caelo-cms/provisioning 0.10.68 → 0.10.69

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 (51) hide show
  1. package/dist/admin-domain.d.ts +28 -0
  2. package/dist/admin-domain.d.ts.map +1 -1
  3. package/dist/admin-domain.js +65 -0
  4. package/dist/admin-domain.js.map +1 -1
  5. package/dist/cli.js +18 -0
  6. package/dist/cli.js.map +1 -1
  7. package/dist/default-firewall.d.ts +1 -1
  8. package/dist/default-firewall.d.ts.map +1 -1
  9. package/dist/default-firewall.js +2 -1
  10. package/dist/default-firewall.js.map +1 -1
  11. package/dist/gcloud-retry.d.ts +21 -0
  12. package/dist/gcloud-retry.d.ts.map +1 -1
  13. package/dist/gcloud-retry.js +34 -0
  14. package/dist/gcloud-retry.js.map +1 -1
  15. package/dist/ops-backend.d.ts.map +1 -1
  16. package/dist/ops-backend.js +13 -3
  17. package/dist/ops-backend.js.map +1 -1
  18. package/dist/stack-contract.d.ts +9 -2
  19. package/dist/stack-contract.d.ts.map +1 -1
  20. package/dist/stack-contract.js +4 -2
  21. package/dist/stack-contract.js.map +1 -1
  22. package/dist/stack-converge.d.ts +14 -0
  23. package/dist/stack-converge.d.ts.map +1 -1
  24. package/dist/stack-converge.js +83 -8
  25. package/dist/stack-converge.js.map +1 -1
  26. package/dist/upgrade/app-roll.d.ts +9 -1
  27. package/dist/upgrade/app-roll.d.ts.map +1 -1
  28. package/dist/upgrade/app-roll.js +14 -2
  29. package/dist/upgrade/app-roll.js.map +1 -1
  30. package/dist/upgrade/upgrade.d.ts.map +1 -1
  31. package/dist/upgrade/upgrade.js +1 -1
  32. package/dist/upgrade/upgrade.js.map +1 -1
  33. package/dist/upgrader-job.d.ts.map +1 -1
  34. package/dist/upgrader-job.js +4 -1
  35. package/dist/upgrader-job.js.map +1 -1
  36. package/package.json +2 -2
  37. package/src/admin-domain.test.ts +87 -0
  38. package/src/admin-domain.ts +71 -0
  39. package/src/cli.ts +22 -0
  40. package/src/default-firewall.ts +5 -3
  41. package/src/gcloud-retry.ts +37 -0
  42. package/src/ops-backend.test.ts +56 -0
  43. package/src/ops-backend.ts +21 -3
  44. package/src/remote-mcp.test.ts +2 -0
  45. package/src/stack-contract.ts +10 -2
  46. package/src/stack-converge-secrets.test.ts +137 -1
  47. package/src/stack-converge.ts +88 -7
  48. package/src/upgrade/app-roll.ts +29 -0
  49. package/src/upgrade/upgrade.test.ts +1 -1
  50. package/src/upgrade/upgrade.ts +1 -0
  51. package/src/upgrader-job.ts +3 -0
@@ -301,6 +301,62 @@ describe("ensureOpsBackend — new install", () => {
301
301
  });
302
302
  });
303
303
 
304
+ describe("ensureOpsBackend — a just-enabled KMS API (#1060)", () => {
305
+ const DISABLED =
306
+ "ERROR: (gcloud.kms.keyrings.describe) PERMISSION_DENIED: Cloud Key Management Service (KMS) API has not been used in project 42 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/cloudkms.googleapis.com/overview?project=42 then retry. (reason: SERVICE_DISABLED)";
307
+
308
+ /** KMS calls answer SERVICE_DISABLED for the first `failures` calls after enabling. */
309
+ function propagating(failures: number) {
310
+ const g = fakeGcp();
311
+ let left = failures;
312
+ const sleeps: number[] = [];
313
+ const run = async (args: string[]) => {
314
+ if (args[0] === "kms" && left > 0) {
315
+ left--;
316
+ g.calls.push(args.join(" "));
317
+ return fail(DISABLED);
318
+ }
319
+ return g.run(args);
320
+ };
321
+ const sleep = async (ms: number) => {
322
+ sleeps.push(ms);
323
+ };
324
+ return { g, run, sleep, sleeps };
325
+ }
326
+
327
+ it("waits for the enablement to propagate, then creates the key ring and key", async () => {
328
+ const p = propagating(2);
329
+ const r = await ensureOpsBackend(TARGET, { run: p.run, sleep: p.sleep });
330
+ expect(r.ok).toBe(true);
331
+ expect(p.g.state.ring && p.g.state.key).toBe(true);
332
+ expect(p.sleeps.length).toBe(2);
333
+ expect(p.g.calls.filter((c) => c.startsWith("kms keyrings describe")).length).toBe(3);
334
+ });
335
+
336
+ it("fails loudly with the next step when the API never comes up", async () => {
337
+ const p = propagating(Number.POSITIVE_INFINITY);
338
+ const r = await ensureOpsBackend(TARGET, { run: p.run, sleep: p.sleep });
339
+ expect(r.ok).toBe(false);
340
+ if (r.ok) return;
341
+ expect(r.error).toContain("Reading key ring caelo failed");
342
+ expect(r.error).toContain("still reported as disabled");
343
+ expect(r.error).toContain("gcloud services list --enabled --project=acme");
344
+ expect(r.error).toContain("re-run the same command");
345
+ // Waited a few minutes in total, then stopped — no endless loop.
346
+ const waited = p.sleeps.reduce((a, b) => a + b, 0);
347
+ expect(waited).toBeGreaterThanOrEqual(3 * 60_000);
348
+ expect(waited).toBeLessThanOrEqual(5 * 60_000);
349
+ expect(p.g.state.ring).toBe(false);
350
+ });
351
+
352
+ it("does not wait in a dry run", async () => {
353
+ const p = propagating(Number.POSITIVE_INFINITY);
354
+ const r = await ensureOpsBackend(TARGET, { run: p.run, sleep: p.sleep, dryRun: true });
355
+ expect(r.ok).toBe(true);
356
+ expect(p.sleeps).toEqual([]);
357
+ });
358
+ });
359
+
304
360
  describe("cost table (CLAUDE.md §11.C)", () => {
305
361
  it("lists the state bucket + KMS key at under $0.10/mo without float noise in the total", () => {
306
362
  const est = estimateGcpCost({
@@ -31,7 +31,15 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
31
31
  import { tmpdir } from "node:os";
32
32
  import { join } from "node:path";
33
33
  import { gcloud as defaultGcloud } from "./gcloud.js";
34
- import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
34
+ import {
35
+ type GcloudRunner,
36
+ realSleep,
37
+ retryWhileServiceEnabling,
38
+ runWithRetry,
39
+ SERVICE_ENABLE_DELAYS_MS,
40
+ SERVICE_NOT_ENABLED,
41
+ type Sleep,
42
+ } from "./gcloud-retry.js";
35
43
  import { operatorMember } from "./provisioner-identity.js";
36
44
  import {
37
45
  gcsBackendRecord,
@@ -144,9 +152,16 @@ export async function ensureOpsBackend(
144
152
  target: OpsBackendTarget,
145
153
  deps: { run?: GcloudRunner; sleep?: Sleep; dryRun?: boolean } = {},
146
154
  ): Promise<OpsBackendResult> {
147
- const run = deps.run ?? defaultGcloud;
148
155
  const sleep = deps.sleep ?? realSleep;
149
156
  const dryRun = deps.dryRun ?? false;
157
+ // `services enable` returns before the enablement has propagated: for a
158
+ // few minutes the first KMS (or storage) call still answers
159
+ // SERVICE_DISABLED (#1060). Every call waits that out — on a re-run whose
160
+ // APIs are long on it costs nothing. A dry run only reads and treats a
161
+ // disabled API as "not there yet", so it does not wait.
162
+ const run = dryRun
163
+ ? (deps.run ?? defaultGcloud)
164
+ : retryWhileServiceEnabling(deps.run ?? defaultGcloud, sleep);
150
165
  const { projectId, region } = target;
151
166
  const project = `--project=${projectId}`;
152
167
  const bucket = opsBucketName(projectId);
@@ -157,9 +172,12 @@ export async function ensureOpsBackend(
157
172
  ];
158
173
  const actions: string[] = [];
159
174
  const act = (what: string) => actions.push(dryRun ? `would ${what}` : what);
175
+ const waitedMinutes = Math.round(SERVICE_ENABLE_DELAYS_MS.reduce((a, b) => a + b, 0) / 60_000);
160
176
  const fail = (what: string, r: { stderr: string; exitCode: number }): OpsBackendResult => ({
161
177
  ok: false,
162
- error: `${what} failed: ${reason(r.stderr, r.exitCode)}`,
178
+ error: SERVICE_NOT_ENABLED.test(r.stderr)
179
+ ? `${what} failed: the API is still reported as disabled ${waitedMinutes} minutes after it was enabled (${reason(r.stderr, r.exitCode)}). Check that ${OPS_APIS.join(" and ")} show as enabled in project ${projectId} (gcloud services list --enabled --project=${projectId}), then re-run the same command — it picks up where it stopped.`
180
+ : `${what} failed: ${reason(r.stderr, r.exitCode)}`,
163
181
  });
164
182
 
165
183
  // --- APIs -------------------------------------------------------------
@@ -284,6 +284,8 @@ describe("planSecretFiles / planRequestTimeout (upgrade)", () => {
284
284
  );
285
285
  expect(plan.ok && plan.flags).toEqual([
286
286
  "--remove-secrets=/secrets/mcp-oauth-client/client.json",
287
+ "--remove-volume-mount=/secrets/mcp-oauth-client",
288
+ "--remove-volume=mcp-oauth-client",
287
289
  "--update-secrets=/secrets/operator-oauth-client/client.json=caelo-production-operator-oauth-client:latest",
288
290
  ]);
289
291
  // Converged: nothing left to remove or mount.
@@ -443,6 +443,12 @@ export const SERVICE_SECRET_FILES: Readonly<Record<CloudRunSlug, readonly Secret
443
443
 
444
444
  /** A secret file an older contract mounted; upgrade unmounts it in the roll. */
445
445
  export interface RetiredSecretFile {
446
+ /**
447
+ * The contract name of the secret the file was mounted from (gcp id
448
+ * `caelo-<env>-<secret>`). Names the volume behind the mount — and a
449
+ * volume left behind without one (#954) — so the roll removes it too.
450
+ */
451
+ readonly secret: string;
446
452
  readonly mountDir: string;
447
453
  readonly fileName: string;
448
454
  readonly why: string;
@@ -450,13 +456,15 @@ export interface RetiredSecretFile {
450
456
 
451
457
  /**
452
458
  * Secret files older contracts mounted that nothing reads any more, per
453
- * service. The roll removes each mount (`--remove-secrets=<path>`) in the
454
- * same revision that mounts its replacement.
459
+ * service. The roll removes each mount (`--remove-secrets=<path>`) and the
460
+ * volume of that secret (`--remove-volume=<name>`) in the same revision that
461
+ * mounts its replacement (stack-converge.ts planSecretFiles).
455
462
  */
456
463
  export const RETIRED_SECRET_FILES: Readonly<Record<CloudRunSlug, readonly RetiredSecretFile[]>> = {
457
464
  admin: [],
458
465
  gateway: [
459
466
  {
467
+ secret: "mcp-oauth-client",
460
468
  mountDir: "/secrets/mcp-oauth-client",
461
469
  fileName: "client.json",
462
470
  why: "the Google sign-in client is mounted from operator-oauth-client now (#914)",
@@ -14,13 +14,20 @@
14
14
 
15
15
  import { describe, expect, it } from "bun:test";
16
16
  import { databaseUrlFromEnv } from "@caelo-cms/shared";
17
- import type { CloudRunSlug } from "./stack-contract.js";
17
+ import type { GcloudResult } from "./gcloud.js";
18
18
  import {
19
+ type CloudRunSlug,
20
+ OPERATOR_OAUTH_CLIENT_FILE,
21
+ RETIRED_SECRET_FILES,
22
+ } from "./stack-contract.js";
23
+ import {
24
+ findVolumesWithMissingSecrets,
19
25
  type LiveEnvValue,
20
26
  liveContainerEnv,
21
27
  liveEnvHasInlinePassword,
22
28
  planContractEnv,
23
29
  planEnvUpdate,
30
+ planSecretFiles,
24
31
  serviceRollArgs,
25
32
  } from "./stack-converge.js";
26
33
 
@@ -460,3 +467,132 @@ describe("liveEnvHasInlinePassword", () => {
460
467
  expect(liveEnvHasInlinePassword(services.gateway.env)).toBe(false);
461
468
  });
462
469
  });
470
+
471
+ describe("retiring a secret file removes its volume too (#954)", () => {
472
+ // The shape searchviu's gateway was left in: gcloud names a volume it
473
+ // mints for `--update-secrets` after the secret plus a random suffix.
474
+ const RETIRED_VOLUME = "caelo-production-mcp-oauth-client-mac-won";
475
+ const OPERATOR_VOLUME = "caelo-production-operator-oauth-client-puw-tuq";
476
+ const operatorVolume = {
477
+ name: OPERATOR_VOLUME,
478
+ secret: {
479
+ secretName: "caelo-production-operator-oauth-client",
480
+ items: [{ key: "latest", path: "client.json" }],
481
+ },
482
+ };
483
+ const retiredVolume = {
484
+ name: RETIRED_VOLUME,
485
+ secret: {
486
+ secretName: "projects/42/secrets/caelo-production-mcp-oauth-client",
487
+ items: [{ key: "latest", path: "client.json" }],
488
+ },
489
+ };
490
+ const plan = (live: Parameters<typeof planSecretFiles>[0]) =>
491
+ planSecretFiles(live, [OPERATOR_OAUTH_CLIENT_FILE], "production", RETIRED_SECRET_FILES.gateway);
492
+
493
+ it("a retired mount: removes the file, the mount and the volume behind it in the roll", () => {
494
+ const r = plan({
495
+ executionEnvironment: null,
496
+ volumes: [retiredVolume],
497
+ mounts: [{ name: RETIRED_VOLUME, mountPath: "/secrets/mcp-oauth-client" }],
498
+ });
499
+ expect(r.ok).toBe(true);
500
+ if (!r.ok) return;
501
+ expect(r.flags).toContain("--remove-secrets=/secrets/mcp-oauth-client/client.json");
502
+ expect(r.flags).toContain(`--remove-volume=${RETIRED_VOLUME}`);
503
+ expect(r.flags).toContain("--remove-volume-mount=/secrets/mcp-oauth-client");
504
+ expect(r.changes.some((c) => c.startsWith(`volume ${RETIRED_VOLUME}: removed`))).toBe(true);
505
+ });
506
+
507
+ it("an orphaned retired volume (no mount, the searchviu state): removes it and says so", () => {
508
+ const r = plan({
509
+ executionEnvironment: null,
510
+ volumes: [retiredVolume, operatorVolume],
511
+ mounts: [{ name: OPERATOR_VOLUME, mountPath: "/secrets/operator-oauth-client" }],
512
+ });
513
+ expect(r).toEqual({
514
+ ok: true,
515
+ flags: [`--remove-volume=${RETIRED_VOLUME}`],
516
+ changes: [
517
+ `volume ${RETIRED_VOLUME}: removed (the Google sign-in client is mounted from operator-oauth-client now (#914); nothing mounts it)`,
518
+ ],
519
+ });
520
+ });
521
+
522
+ it("a converged service plans nothing", () => {
523
+ const r = plan({
524
+ executionEnvironment: null,
525
+ volumes: [operatorVolume],
526
+ mounts: [{ name: OPERATOR_VOLUME, mountPath: "/secrets/operator-oauth-client" }],
527
+ });
528
+ expect(r).toEqual({ ok: true, flags: [], changes: [] });
529
+ });
530
+
531
+ it("refuses a volume of the retired secret mounted somewhere else instead of pulling it", () => {
532
+ const r = plan({
533
+ executionEnvironment: null,
534
+ volumes: [retiredVolume, operatorVolume],
535
+ mounts: [
536
+ { name: OPERATOR_VOLUME, mountPath: "/secrets/operator-oauth-client" },
537
+ { name: RETIRED_VOLUME, mountPath: "/elsewhere" },
538
+ ],
539
+ });
540
+ expect(r.ok).toBe(false);
541
+ if (r.ok) return;
542
+ expect(r.error).toContain(`--remove-volume=${RETIRED_VOLUME}`);
543
+ });
544
+
545
+ describe("preflight: a volume pointing at a deleted secret", () => {
546
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
547
+ const missing: GcloudResult = {
548
+ ok: false,
549
+ stdout: "",
550
+ stderr:
551
+ "ERROR: (gcloud.secrets.describe) NOT_FOUND: Secret [projects/42/secrets/x] not found",
552
+ exitCode: 1,
553
+ };
554
+ const where = {
555
+ serviceName: "caelo-production-gateway-532b35d",
556
+ projectId: "acme",
557
+ region: "europe-west1",
558
+ };
559
+ const live = {
560
+ executionEnvironment: null,
561
+ volumes: [retiredVolume, operatorVolume],
562
+ mounts: [{ name: OPERATOR_VOLUME, mountPath: "/secrets/operator-oauth-client" }],
563
+ };
564
+ const gcloud = (gone: string) => {
565
+ const calls: string[] = [];
566
+ const run = async (args: string[]) => {
567
+ calls.push(args.join(" "));
568
+ return args[2] === gone ? missing : ok("name");
569
+ };
570
+ return { run, calls };
571
+ };
572
+
573
+ it("names the volume and the exact fix", async () => {
574
+ const g = gcloud("caelo-production-mcp-oauth-client");
575
+ const problems = await findVolumesWithMissingSecrets(live, where, [], g.run);
576
+ expect(problems).toHaveLength(1);
577
+ expect(problems[0]).toContain(`volume "${RETIRED_VOLUME}" (not mounted)`);
578
+ expect(problems[0]).toContain("caelo-production-mcp-oauth-client, which no longer exists");
579
+ expect(problems[0]).toContain(
580
+ `gcloud run services update caelo-production-gateway-532b35d --region=europe-west1 --project=acme --remove-volume=${RETIRED_VOLUME}`,
581
+ );
582
+ });
583
+
584
+ it("skips a volume the roll removes anyway", async () => {
585
+ const g = gcloud("caelo-production-mcp-oauth-client");
586
+ expect(await findVolumesWithMissingSecrets(live, where, [RETIRED_VOLUME], g.run)).toEqual([]);
587
+ expect(g.calls.some((c) => c.includes("mcp-oauth-client"))).toBe(false);
588
+ });
589
+
590
+ it("a mounted volume's fix removes the mount too", async () => {
591
+ const g = gcloud("caelo-production-operator-oauth-client");
592
+ const [problem] = await findVolumesWithMissingSecrets(live, where, [RETIRED_VOLUME], g.run);
593
+ expect(problem).toContain(
594
+ `--remove-volume-mount=/secrets/operator-oauth-client --remove-volume=${OPERATOR_VOLUME}`,
595
+ );
596
+ });
597
+ });
598
+ });
@@ -1023,14 +1023,51 @@ export function planSecretFiles(
1023
1023
  const changes: string[] = [];
1024
1024
  // #914 — mounts an older contract made and nothing reads any more
1025
1025
  // (RETIRED_SECRET_FILES) go in the same revision that mounts their
1026
- // replacement. `--remove-secrets=<dir>/<file>` drops the file, and the
1027
- // mount with it when it was the volume's only file (gcloud
1028
- // `_PruneManagedVolumeMapping`). Repeated `--remove-secrets` /
1029
- // `--update-secrets` flags merge (gcloud map flags use UpdateAction).
1026
+ // replacement. `--remove-secrets=<dir>/<file>` drops the file and its
1027
+ // mount, but NOT the volume: gcloud's `_PruneManagedVolumeMapping` only
1028
+ // deletes the mount, so the volume stayed behind naming a secret the
1029
+ // upgrade deletes after the roll, and the next revision could not deploy
1030
+ // (#954, searchviu). The plan therefore removes that volume itself, plus
1031
+ // any volume of a retired secret that an earlier roll already orphaned.
1032
+ // Repeated map/list flags merge (gcloud UpdateAction); removing a volume
1033
+ // or mount that is already gone is a no-op in gcloud.
1034
+ const removedVolumes = new Set<string>();
1035
+ const removeVolume = (name: string, why: string) => {
1036
+ if (removedVolumes.has(name)) return;
1037
+ removedVolumes.add(name);
1038
+ flags.push(`--remove-volume=${name}`);
1039
+ changes.push(`volume ${name}: removed (${why})`);
1040
+ };
1030
1041
  for (const old of retired) {
1031
- if (!live.mounts.some((m) => m.mountPath === old.mountDir)) continue;
1032
- flags.push(`--remove-secrets=${old.mountDir}/${old.fileName}`);
1033
- changes.push(`secret file ${old.mountDir}/${old.fileName}: removed (${old.why})`);
1042
+ const retiredId = gcpSecretId(env, old.secret);
1043
+ const mount = live.mounts.find((m) => m.mountPath === old.mountDir);
1044
+ if (mount) {
1045
+ const volume = live.volumes.find((v) => v.name === mount.name);
1046
+ const volumeSecret = bareSecretId(volume?.secret?.secretName);
1047
+ if (volume && volumeSecret !== retiredId) {
1048
+ return {
1049
+ ok: false,
1050
+ error: `the service mounts volume "${mount.name}" (secret ${volumeSecret || "(none)"}) at ${old.mountDir}, where only the retired ${retiredId} was ever mounted. Check what it is; remove it (gcloud run services update <service> --remove-volume-mount=${old.mountDir} --remove-volume=${mount.name}) only if nothing needs it, then re-run upgrade.`,
1051
+ };
1052
+ }
1053
+ flags.push(`--remove-secrets=${old.mountDir}/${old.fileName}`);
1054
+ flags.push(`--remove-volume-mount=${old.mountDir}`);
1055
+ changes.push(`secret file ${old.mountDir}/${old.fileName}: removed (${old.why})`);
1056
+ if (volume) removeVolume(volume.name, old.why);
1057
+ }
1058
+ const mounted = new Set(
1059
+ live.mounts.filter((m) => m.mountPath !== old.mountDir).map((m) => m.name),
1060
+ );
1061
+ for (const v of live.volumes) {
1062
+ if (bareSecretId(v.secret?.secretName) !== retiredId) continue;
1063
+ if (mounted.has(v.name)) {
1064
+ return {
1065
+ ok: false,
1066
+ error: `the service mounts volume "${v.name}" of the retired secret ${retiredId} somewhere other than ${old.mountDir}. Nothing reads that secret any more and upgrade deletes it after the roll; remove the mount and the volume (gcloud run services update <service> --remove-volume-mount=<path> --remove-volume=${v.name}), then re-run upgrade.`,
1067
+ };
1068
+ }
1069
+ removeVolume(v.name, `${old.why}; nothing mounts it`);
1070
+ }
1034
1071
  }
1035
1072
  for (const file of files) {
1036
1073
  const secretId = gcpSecretId(env, file.secret);
@@ -1052,6 +1089,50 @@ export function planSecretFiles(
1052
1089
  return { ok: true, flags, changes };
1053
1090
  }
1054
1091
 
1092
+ /**
1093
+ * #954 — the secret volumes of a service whose secret no longer exists in
1094
+ * Secret Manager. Cloud Run refuses every new revision of such a service,
1095
+ * so the upgrade names each one with the exact fix before it starts a roll
1096
+ * instead of failing mid-roll. Volumes in `removing` (the roll's own
1097
+ * `--remove-volume` flags) are skipped: the roll takes them off anyway.
1098
+ * A secret gcloud cannot read for another reason is reported too — the
1099
+ * roll's outcome would be unknowable.
1100
+ */
1101
+ export async function findVolumesWithMissingSecrets(
1102
+ live: LiveVolumes,
1103
+ where: { readonly serviceName: string; readonly projectId: string; readonly region: string },
1104
+ removing: readonly string[],
1105
+ run: GcloudRunner,
1106
+ ): Promise<string[]> {
1107
+ const problems: string[] = [];
1108
+ for (const v of live.volumes) {
1109
+ const secretId = bareSecretId(v.secret?.secretName);
1110
+ if (!secretId || removing.includes(v.name)) continue;
1111
+ const r = await run([
1112
+ "secrets",
1113
+ "describe",
1114
+ secretId,
1115
+ `--project=${where.projectId}`,
1116
+ "--format=value(name)",
1117
+ ]);
1118
+ if (r.ok) continue;
1119
+ const mounts = live.mounts.filter((m) => m.name === v.name).map((m) => m.mountPath);
1120
+ const fix = [
1121
+ `gcloud run services update ${where.serviceName}`,
1122
+ `--region=${where.region}`,
1123
+ `--project=${where.projectId}`,
1124
+ ...mounts.map((p) => `--remove-volume-mount=${p}`),
1125
+ `--remove-volume=${v.name}`,
1126
+ ].join(" ");
1127
+ problems.push(
1128
+ RESOURCE_MISSING.test(r.stderr)
1129
+ ? `${where.serviceName} has volume "${v.name}"${mounts.length > 0 ? ` (mounted at ${mounts.join(", ")})` : " (not mounted)"} pointing at secret ${secretId}, which no longer exists, so Cloud Run would refuse the next revision. If nothing reads it, remove it: ${fix} — then re-run upgrade.`
1130
+ : `could not check secret ${secretId} behind volume "${v.name}" of ${where.serviceName}: ${r.stderr.trim() || `gcloud exited ${r.exitCode}`}`,
1131
+ );
1132
+ }
1133
+ return problems;
1134
+ }
1135
+
1055
1136
  /** The request timeout of a described service, in seconds (null: Cloud Run's default, 300). */
1056
1137
  export function liveTimeoutSeconds(serviceJson: string): number | null {
1057
1138
  const svc = JSON.parse(serviceJson) as {
@@ -37,6 +37,7 @@ import {
37
37
  import {
38
38
  type DeployedService,
39
39
  type EnvChange,
40
+ findVolumesWithMissingSecrets,
40
41
  type LiveEnvValue,
41
42
  type LiveVolumes,
42
43
  liveContainerEnv,
@@ -147,6 +148,15 @@ export async function planAppRoll(
147
148
  install: AppRollInstall,
148
149
  images: Readonly<Record<RollSlug, ReleaseImage>>,
149
150
  run: GcloudRunner,
151
+ checks: {
152
+ /**
153
+ * #954 — check that every secret volume's secret still exists. The
154
+ * operator / converge identity can read secret metadata; the upgrader
155
+ * job's narrow identity holds no Secret Manager permission at all
156
+ * (upgrader-setup.ts), so it passes false.
157
+ */
158
+ readonly secretVolumes: boolean;
159
+ },
150
160
  ): Promise<PlanResult> {
151
161
  const services: ServicePlan[] = [];
152
162
  for (const slug of ROLL_ORDER) {
@@ -223,6 +233,25 @@ export async function planAppRoll(
223
233
  }
224
234
  secretFiles[s.slug] = files;
225
235
  }
236
+ // #954 — a volume pointing at a deleted secret makes Cloud Run refuse
237
+ // every new revision: name it with its fix now, before any roll starts.
238
+ const orphaned: string[] = [];
239
+ for (const s of checks.secretVolumes ? services : []) {
240
+ const removing = secretFiles[s.slug].flags
241
+ .filter((f) => f.startsWith("--remove-volume="))
242
+ .map((f) => f.slice("--remove-volume=".length));
243
+ orphaned.push(
244
+ ...(await findVolumesWithMissingSecrets(
245
+ s.liveVolumes,
246
+ { serviceName: s.serviceName, projectId: install.projectId, region: install.region },
247
+ removing,
248
+ run,
249
+ )),
250
+ );
251
+ }
252
+ if (orphaned.length > 0) {
253
+ return { ok: false, error: `a service can't take a new revision: ${orphaned.join(" ")}` };
254
+ }
226
255
  const warnings: string[] = [];
227
256
  const rolls = services.map((s): RollPlan => {
228
257
  // #553 — the admin runs the Lighthouse quality audit; raise it to the
@@ -906,7 +906,7 @@ const INSTALL: AppRollInstall = {
906
906
  };
907
907
 
908
908
  async function plannedRoll(run: GcloudRunner): Promise<AppRollPlan> {
909
- const planned = await planAppRoll(INSTALL, IMAGES, run);
909
+ const planned = await planAppRoll(INSTALL, IMAGES, run, { secretVolumes: true });
910
910
  if (!planned.ok) throw new Error(planned.error);
911
911
  return planned.plan;
912
912
  }
@@ -177,6 +177,7 @@ export async function upgradeGcpInstallOutcome(
177
177
  },
178
178
  images.images,
179
179
  run,
180
+ { secretVolumes: true },
180
181
  );
181
182
  if (!planned.ok) {
182
183
  sPre.failed("Pre-flight failed");
@@ -519,6 +519,9 @@ export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRun
519
519
  },
520
520
  images,
521
521
  deps.run,
522
+ // Holds no Secret Manager permission (upgrader-setup.ts); the
523
+ // converge — operator or converge job — runs the #954 volume check.
524
+ { secretVolumes: false },
522
525
  );
523
526
  if (!planned.ok) {
524
527
  return stopWith("failed", ["plan"], `Nothing was changed: ${planned.error}.`);