@caelo-cms/provisioning 0.10.67 → 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 (54) 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/agent-db.js +261 -151
  6. package/dist/cli.js +18 -0
  7. package/dist/cli.js.map +1 -1
  8. package/dist/compose.js +1 -1
  9. package/dist/default-firewall.d.ts +1 -1
  10. package/dist/default-firewall.d.ts.map +1 -1
  11. package/dist/default-firewall.js +2 -1
  12. package/dist/default-firewall.js.map +1 -1
  13. package/dist/gcloud-retry.d.ts +21 -0
  14. package/dist/gcloud-retry.d.ts.map +1 -1
  15. package/dist/gcloud-retry.js +34 -0
  16. package/dist/gcloud-retry.js.map +1 -1
  17. package/dist/ops-backend.d.ts.map +1 -1
  18. package/dist/ops-backend.js +13 -3
  19. package/dist/ops-backend.js.map +1 -1
  20. package/dist/stack-contract.d.ts +9 -2
  21. package/dist/stack-contract.d.ts.map +1 -1
  22. package/dist/stack-contract.js +4 -2
  23. package/dist/stack-contract.js.map +1 -1
  24. package/dist/stack-converge.d.ts +14 -0
  25. package/dist/stack-converge.d.ts.map +1 -1
  26. package/dist/stack-converge.js +83 -8
  27. package/dist/stack-converge.js.map +1 -1
  28. package/dist/upgrade/app-roll.d.ts +9 -1
  29. package/dist/upgrade/app-roll.d.ts.map +1 -1
  30. package/dist/upgrade/app-roll.js +14 -2
  31. package/dist/upgrade/app-roll.js.map +1 -1
  32. package/dist/upgrade/upgrade.d.ts.map +1 -1
  33. package/dist/upgrade/upgrade.js +1 -1
  34. package/dist/upgrade/upgrade.js.map +1 -1
  35. package/dist/upgrader-job.d.ts.map +1 -1
  36. package/dist/upgrader-job.js +4 -1
  37. package/dist/upgrader-job.js.map +1 -1
  38. package/package.json +2 -2
  39. package/src/admin-domain.test.ts +87 -0
  40. package/src/admin-domain.ts +71 -0
  41. package/src/cli.ts +22 -0
  42. package/src/compose.ts +1 -1
  43. package/src/default-firewall.ts +5 -3
  44. package/src/gcloud-retry.ts +37 -0
  45. package/src/ops-backend.test.ts +56 -0
  46. package/src/ops-backend.ts +21 -3
  47. package/src/remote-mcp.test.ts +2 -0
  48. package/src/stack-contract.ts +10 -2
  49. package/src/stack-converge-secrets.test.ts +137 -1
  50. package/src/stack-converge.ts +88 -7
  51. package/src/upgrade/app-roll.ts +29 -0
  52. package/src/upgrade/upgrade.test.ts +1 -1
  53. package/src/upgrade/upgrade.ts +1 -0
  54. package/src/upgrader-job.ts +3 -0
@@ -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}.`);