@caelo-cms/provisioning 0.10.34 → 0.10.37

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 (62) hide show
  1. package/dist/adapter.d.ts +7 -1
  2. package/dist/adapter.d.ts.map +1 -1
  3. package/dist/cli.js +81 -12
  4. package/dist/cli.js.map +1 -1
  5. package/dist/compose.d.ts +12 -0
  6. package/dist/compose.d.ts.map +1 -1
  7. package/dist/compose.js +12 -4
  8. package/dist/compose.js.map +1 -1
  9. package/dist/default-firewall.d.ts +45 -0
  10. package/dist/default-firewall.d.ts.map +1 -0
  11. package/dist/default-firewall.js +171 -0
  12. package/dist/default-firewall.js.map +1 -0
  13. package/dist/deployed-release.d.ts +5 -3
  14. package/dist/deployed-release.d.ts.map +1 -1
  15. package/dist/deployed-release.js +6 -4
  16. package/dist/deployed-release.js.map +1 -1
  17. package/dist/gateway-credentials.d.ts +42 -0
  18. package/dist/gateway-credentials.d.ts.map +1 -0
  19. package/dist/gateway-credentials.js +95 -0
  20. package/dist/gateway-credentials.js.map +1 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/install-state.d.ts +27 -0
  25. package/dist/install-state.d.ts.map +1 -1
  26. package/dist/install-state.js +67 -0
  27. package/dist/install-state.js.map +1 -1
  28. package/dist/lifecycle.d.ts +6 -0
  29. package/dist/lifecycle.d.ts.map +1 -1
  30. package/dist/lifecycle.js +225 -56
  31. package/dist/lifecycle.js.map +1 -1
  32. package/dist/runtime-secrets.d.ts +39 -2
  33. package/dist/runtime-secrets.d.ts.map +1 -1
  34. package/dist/runtime-secrets.js +77 -8
  35. package/dist/runtime-secrets.js.map +1 -1
  36. package/dist/self-hosted-roles.d.ts +64 -0
  37. package/dist/self-hosted-roles.d.ts.map +1 -0
  38. package/dist/self-hosted-roles.js +77 -0
  39. package/dist/self-hosted-roles.js.map +1 -0
  40. package/dist/stack-contract.d.ts +68 -3
  41. package/dist/stack-contract.d.ts.map +1 -1
  42. package/dist/stack-contract.js +69 -12
  43. package/dist/stack-contract.js.map +1 -1
  44. package/dist/stack-converge.d.ts +24 -18
  45. package/dist/stack-converge.d.ts.map +1 -1
  46. package/dist/stack-converge.js +47 -2
  47. package/dist/stack-converge.js.map +1 -1
  48. package/dist/wizard.d.ts.map +1 -1
  49. package/dist/wizard.js +4 -2
  50. package/dist/wizard.js.map +1 -1
  51. package/dist/wizards/gcp.d.ts.map +1 -1
  52. package/dist/wizards/gcp.js +64 -2
  53. package/dist/wizards/gcp.js.map +1 -1
  54. package/package.json +3 -3
  55. package/stacks/aws/README.md +1 -1
  56. package/stacks/aws/index.ts +20 -2
  57. package/stacks/azure/README.md +1 -1
  58. package/stacks/azure/index.ts +46 -7
  59. package/stacks/gcp/README.md +1 -1
  60. package/stacks/gcp/index.ts +25 -5
  61. package/stacks/gcp-firebase/index.ts +23 -2
  62. package/stacks/self-hosted/index.ts +26 -12
package/dist/lifecycle.js CHANGED
@@ -7,49 +7,123 @@
7
7
  * into provider tools for any of them.
8
8
  *
9
9
  * Each command:
10
- * - reads the install id from the active install (single install
11
- * per machine for v1; multi-install via `--install-id` flag)
10
+ * - acts on the install named by `--install <install-id or domain>`, or
11
+ * on the only install on this machine; with several and no flag it
12
+ * stops and lists them (selectInstall in install-state.ts)
12
13
  * - dispatches to provider-specific implementations (gcp / aws /
13
14
  * azure / self-hosted)
14
15
  * - emits human-readable progress + a final summary
15
16
  */
16
- import { existsSync, readdirSync } from "node:fs";
17
- import { homedir } from "node:os";
18
17
  import { join, resolve as resolvePath } from "node:path";
19
- import { cancel, confirm, isCancel, log, note, spinner } from "@clack/prompts";
18
+ import { cancel, confirm, isCancel, log, note, select, spinner } from "@clack/prompts";
20
19
  import { bold, cyan, dim, green, red, yellow } from "kleur/colors";
20
+ import { ensureGatewayRolePassword, planPublicRoleSwitch } from "./gateway-credentials.js";
21
21
  import { gcloud } from "./gcloud.js";
22
22
  import { GCP_STACK_ENV, gatewayServiceAccountEmail, runServiceAccountEmail } from "./gcp-names.js";
23
- import { installRoot, readMetadata, readSecret, recordImageDigests, } from "./install-state.js";
23
+ import { installFlag, installRoot, listInstalls, readSecret, recordImageDigests, selectInstall, } from "./install-state.js";
24
24
  import { ensureMcpIapAccess } from "./mcp-iap.js";
25
25
  import { ensureOperatorAccessSync, resolveOperatorAccessTarget } from "./operator-access.js";
26
26
  import { assertRegionUnchanged } from "./regions.js";
27
- import { ensureGatewayServiceAccount, ensureGeneratedSecrets, plainGeneratedSecretSeed, ROTATABLE_SECRETS, readSecretReplication, rotateRuntimeSecret, rotationRefusal, } from "./runtime-secrets.js";
27
+ import { ensureGatewayServiceAccount, ensureGeneratedSecrets, plainGeneratedSecretSeed, ROTATABLE_SECRETS, readSecretReplication, rolesOfSecret, rotateRuntimeSecret, rotationRefusal, } from "./runtime-secrets.js";
28
28
  import { adminMediaVolume, MCP_ENV_VAR, OPERATOR_ACCESS_JOB_ENV_VAR } from "./stack-contract.js";
29
- import { ensureStackInvariants, liveContainerEnv, liveContainerMemory, liveEnvHasInlinePassword, liveVolumes, planAdminMemory, planContractEnv, planMediaVolume, rollService, serviceRollArgs, } from "./stack-converge.js";
30
- /** Find the single install on this machine — or warn if 0/multiple. */
31
- function findActiveInstall() {
32
- const home = homedir();
33
- if (!existsSync(home))
34
- return null;
35
- const candidates = readdirSync(home)
36
- .filter((entry) => entry.startsWith(".caelo-"))
37
- .map((entry) => entry.replace(/^\.caelo-/, ""));
38
- for (const installId of candidates) {
39
- const meta = readMetadata(installId);
40
- if (meta)
41
- return { installId, meta };
42
- }
43
- return null;
29
+ import { ensureStackInvariants, liveContainerEnv, liveContainerMemory, liveEnvHasInlinePassword, liveVolumes, planAdminMemory, planContractEnv, planMediaVolume, removeRetiredIamBindings, rollService, serviceRollArgs, } from "./stack-converge.js";
30
+ /**
31
+ * The installs a command acts on. `--install <id or domain>` names one
32
+ * (`--install all` every one, where the command allows it). Without the flag:
33
+ * the only install on this machine; with several, an interactive terminal
34
+ * asks which one (or all), and a non-interactive run stops with the list.
35
+ * Never guesses — acting on the wrong install upgrades or wipes the wrong
36
+ * site (see selectInstall).
37
+ */
38
+ async function chooseInstalls(opts) {
39
+ const installs = listInstalls();
40
+ let wanted;
41
+ try {
42
+ wanted = installFlag(process.argv);
43
+ }
44
+ catch (e) {
45
+ log.error(red(e instanceof Error ? e.message : String(e)));
46
+ process.exit(2);
47
+ }
48
+ if (wanted === "all") {
49
+ if (!opts.allowAll) {
50
+ log.error(red(`${opts.verb} runs on one install at a time; pass --install <install-id or domain>.`));
51
+ process.exit(2);
52
+ }
53
+ if (installs.length === 0)
54
+ return exitNoInstall("No Caelo install found on this machine.");
55
+ return installs;
56
+ }
57
+ if (wanted === undefined && installs.length > 1 && process.stdin.isTTY) {
58
+ const choice = await select({
59
+ message: `Which install should ${opts.verb} act on?`,
60
+ options: [
61
+ ...installs.map((m) => ({
62
+ value: m.installId,
63
+ label: m.domain,
64
+ hint: `${m.installId}, ${m.provider}`,
65
+ })),
66
+ ...(opts.allowAll
67
+ ? [{ value: "all", label: `All ${installs.length} installs`, hint: "one after another" }]
68
+ : []),
69
+ ],
70
+ });
71
+ if (isCancel(choice)) {
72
+ cancel("Aborted.");
73
+ process.exit(1);
74
+ }
75
+ if (choice === "all")
76
+ return installs;
77
+ wanted = choice;
78
+ }
79
+ const selected = selectInstall(installs, wanted);
80
+ if (!selected.ok)
81
+ return exitNoInstall(selected.message);
82
+ return [selected.meta];
44
83
  }
45
- function requireInstall() {
46
- const found = findActiveInstall();
47
- if (!found) {
48
- log.error(red("No Caelo install found on this machine."));
84
+ function exitNoInstall(message) {
85
+ log.error(red(message));
86
+ if (listInstalls().length === 0) {
49
87
  log.warn(`Run ${bold("bunx @caelo-cms/provisioning")} first to provision an install, OR copy ${dim("~/.caelo-<install-id>/")} from the provisioning machine.`);
50
- process.exit(1);
51
88
  }
52
- return found;
89
+ process.exit(1);
90
+ }
91
+ /** The one install a single-install command (rotate-secret, truncate, destroy) acts on. */
92
+ async function requireInstall(verb) {
93
+ const [meta] = await chooseInstalls({ verb, allowAll: false });
94
+ if (!meta)
95
+ return exitNoInstall("No Caelo install found on this machine.");
96
+ return { installId: meta.installId, meta };
97
+ }
98
+ /**
99
+ * Run `run` for each chosen install in turn, with a header when there are
100
+ * several. `run` reports whether the install succeeded; the first failure
101
+ * stops the batch (the installs after it are left untouched) and sets a
102
+ * non-zero exit code, so scripts see it too.
103
+ *
104
+ * @param check validates the chosen set before anything runs (e.g. a
105
+ * --region that cannot match several installs); returns an error or null
106
+ */
107
+ async function forEachInstall(verb, run, check) {
108
+ const chosen = await chooseInstalls({ verb, allowAll: true });
109
+ const problem = check?.(chosen) ?? null;
110
+ if (problem) {
111
+ log.error(red(problem));
112
+ process.exit(2);
113
+ }
114
+ for (const [i, meta] of chosen.entries()) {
115
+ if (chosen.length > 1) {
116
+ note(`${meta.domain} ${dim(`(${meta.installId})`)}`, `${verb} ${i + 1}/${chosen.length}`);
117
+ }
118
+ if (await run(meta.installId, meta))
119
+ continue;
120
+ process.exitCode = 1;
121
+ const skipped = chosen.slice(i + 1);
122
+ if (skipped.length > 0) {
123
+ log.warn(`${verb} failed for ${meta.domain}; stopped before ${skipped.map((m) => m.domain).join(", ")}.`);
124
+ }
125
+ return;
126
+ }
53
127
  }
54
128
  /**
55
129
  * The install's recorded region (#607); exits with the actionable reason
@@ -101,7 +175,9 @@ async function resolveGcpResourceName(kind, prefix, projectId, region) {
101
175
  // status — health check + monthly cost
102
176
  // =========================================================================
103
177
  export async function statusCommand() {
104
- const { installId, meta } = requireInstall();
178
+ await forEachInstall("status", statusOf);
179
+ }
180
+ async function statusOf(installId, meta) {
105
181
  log.info(`Install: ${bold(installId)} ${dim(`(${meta.provider})`)}`);
106
182
  log.info(`Domain: ${bold(meta.domain)}`);
107
183
  log.info(`Project: ${bold(meta.projectId ?? "<self-hosted>")}`);
@@ -111,6 +187,7 @@ export async function statusCommand() {
111
187
  else {
112
188
  log.warn(`status command for provider ${meta.provider} not yet implemented.`);
113
189
  }
190
+ return true;
114
191
  }
115
192
  /**
116
193
  * P20 — fetch the latest stable Caelo release tag from GitHub.
@@ -315,7 +392,22 @@ async function resolveAdminIapResource(meta, region, adminServiceName) {
315
392
  return name ? { kind: "backend-services", service: name } : null;
316
393
  }
317
394
  export async function upgradeCommand(opts = {}) {
318
- const { installId, meta } = requireInstall();
395
+ if (opts.installId) {
396
+ const selected = selectInstall(listInstalls(), opts.installId);
397
+ if (!selected.ok)
398
+ return exitNoInstall(selected.message);
399
+ if (!(await upgradeInstall(selected.meta.installId, selected.meta, opts)))
400
+ process.exitCode = 1;
401
+ return;
402
+ }
403
+ await forEachInstall("upgrade", (installId, meta) => upgradeInstall(installId, meta, opts),
404
+ // Each install has its own recorded region (#607); one --region can't
405
+ // match several, and finding out halfway would leave a partial batch.
406
+ (chosen) => opts.region && chosen.length > 1
407
+ ? "--region names one install's region; drop it when upgrading several installs."
408
+ : null);
409
+ }
410
+ async function upgradeInstall(installId, meta, opts) {
319
411
  // v0.5.15 — extended to cover gcp-firebase too. Both providers share
320
412
  // the identical admin + gateway shape on Cloud Run (Artifact
321
413
  // Registry image, `caelo-production-<slug>` service naming, the same
@@ -325,10 +417,10 @@ export async function upgradeCommand(opts = {}) {
325
417
  // provider adapters land.
326
418
  if (meta.provider !== "gcp" && meta.provider !== "gcp-firebase") {
327
419
  log.warn(`upgrade for provider ${meta.provider} not yet implemented.`);
328
- return;
420
+ return false;
329
421
  }
330
422
  if (!meta.projectId)
331
- return;
423
+ return false;
332
424
  // #607 — upgrade never moves an install: a `--region` other than the
333
425
  // recorded one is refused before anything rolls.
334
426
  const region = installRegion(meta, opts.region);
@@ -357,7 +449,7 @@ export async function upgradeCommand(opts = {}) {
357
449
  const serviceName = await resolveGcpResourceName("run-service", `caelo-production-${slug}`, meta.projectId, region);
358
450
  if (!serviceName) {
359
451
  sPre.stop(red(`Could not find caelo-production-${slug}* Cloud Run service`));
360
- return;
452
+ return false;
361
453
  }
362
454
  // Resolve the tag via the public Docker Registry V2 API directly,
363
455
  // not `gcloud artifacts docker tags list`. The gcloud path requires
@@ -374,18 +466,18 @@ export async function upgradeCommand(opts = {}) {
374
466
  log.error(`Public registry returned ${digestRes.reason}.\n` +
375
467
  `Verify the tag exists at https://${registryRegion}-docker.pkg.dev/${registryProject}/${registryRepo}/${slug}:${targetTag}\n` +
376
468
  `(latest releases live at https://github.com/caelo-cms/caelo-cms/releases — pass --version vX.Y.Z to pin.)`);
377
- return;
469
+ return false;
378
470
  }
379
471
  const digest = digestRes.digest;
380
472
  const priorRevision = await findCurrentRevision(meta.projectId, region, serviceName);
381
473
  if (!priorRevision) {
382
474
  sPre.stop(red(`Could not capture current revision for ${slug} — refusing to roll`));
383
- return;
475
+ return false;
384
476
  }
385
477
  const serviceJson = await describeServiceJson(meta.projectId, region, serviceName);
386
478
  if (!serviceJson) {
387
479
  sPre.stop(red(`Could not read the ${slug} service's configuration — refusing to roll`));
388
- return;
480
+ return false;
389
481
  }
390
482
  plans.push({
391
483
  slug,
@@ -410,7 +502,7 @@ export async function upgradeCommand(opts = {}) {
410
502
  if (!envPlan.ok) {
411
503
  sPre.stop(red("Pre-flight failed: the install's env contract can't be applied"));
412
504
  log.error(envPlan.error);
413
- return;
505
+ return false;
414
506
  }
415
507
  // The admin's media lives in the media bucket, mounted as a Cloud Storage
416
508
  // volume. Without it every upload is lost on the next revision or
@@ -422,7 +514,7 @@ export async function upgradeCommand(opts = {}) {
422
514
  if (!mediaVolume.ok) {
423
515
  sPre.stop(red("Pre-flight failed: the admin's media volume can't be set up"));
424
516
  log.error(mediaVolume.error);
425
- return;
517
+ return false;
426
518
  }
427
519
  let rolls = plans.map((p) => {
428
520
  // #553 — the admin runs the Lighthouse quality audit; raise it to the
@@ -464,7 +556,7 @@ export async function upgradeCommand(opts = {}) {
464
556
  if (!opts.skipVerify) {
465
557
  const verified = await verifyCosignAll(plans, registryRegion, registryProject, registryRepo);
466
558
  if (!verified)
467
- return;
559
+ return false;
468
560
  }
469
561
  else {
470
562
  log.warn(yellow("--skip-verify set — image signatures NOT verified."));
@@ -496,7 +588,7 @@ export async function upgradeCommand(opts = {}) {
496
588
  for (const o of runtimeFailed)
497
589
  log.error(red(` FAILED: ${o.id}\n ${o.error ?? ""}`));
498
590
  log.warn("No traffic was shifted and no migrations ran. Fix the above and re-run.");
499
- return;
591
+ return false;
500
592
  }
501
593
  sRt.stop(green("Gateway service account + runtime secrets ok"));
502
594
  for (const o of runtime.filter((o) => o.status === "applied"))
@@ -506,10 +598,11 @@ export async function upgradeCommand(opts = {}) {
506
598
  // from an image roll: IAM bindings + CDN policy added to the stacks after
507
599
  // this install was provisioned (stack-contract.ts). Additive + idempotent.
508
600
  // A failure the install can't work without aborts here, before
509
- // migrations or any traffic shift; the rest warn.
601
+ // migrations or any traffic shift; the rest warn. Also deletes GCP's
602
+ // auto-created SSH/RDP-from-anywhere firewall rules (default-firewall.ts).
510
603
  // ────────────────────────────────────────────────────────────────
511
604
  const sInv = spinner();
512
- sInv.start("Ensuring the IAM bindings + CDN settings the stack declares...");
605
+ sInv.start("Ensuring the IAM bindings + CDN settings the stack declares, removing default SSH/RDP firewall rules...");
513
606
  const invariants = await ensureStackInvariants({
514
607
  provider: meta.provider,
515
608
  projectId: meta.projectId,
@@ -536,7 +629,7 @@ export async function upgradeCommand(opts = {}) {
536
629
  }
537
630
  if (invariants.mustAbort) {
538
631
  log.warn("No traffic was shifted and no migrations ran. Fix the bindings above and re-run.");
539
- return;
632
+ return false;
540
633
  }
541
634
  // ────────────────────────────────────────────────────────────────
542
635
  // P21 ship 3 — DB migrations BEFORE traffic shifts. Idempotent
@@ -565,10 +658,38 @@ export async function upgradeCommand(opts = {}) {
565
658
  " gcloud logging read 'resource.type=cloud_run_job AND " +
566
659
  'resource.labels.job_name=~"caelo-migrate-.*"\' ' +
567
660
  `--project=${meta.projectId} --limit=50`);
568
- return;
661
+ return false;
569
662
  }
570
663
  sMig.stop(green("Migrations applied"));
571
664
  // ────────────────────────────────────────────────────────────────
665
+ // #613 — the gateway's own database login. Migration 0248 created
666
+ // gateway_role without a password; give it the gateway-role-password
667
+ // secret's value now, before the gateway rolls onto it. Without it the
668
+ // new gateway could not reach cms_admin, so a failure aborts here.
669
+ // public_role moves onto its own password right before the gateway's
670
+ // roll (gateway-credentials.ts), so the old revision keeps working
671
+ // until then.
672
+ // ────────────────────────────────────────────────────────────────
673
+ const sRole = spinner();
674
+ sRole.start("Ensuring the gateway's database login (gateway_role)...");
675
+ const sqlInstance = await resolveGcpResourceName("sql-instance", "caelo-production-pg", meta.projectId);
676
+ if (!sqlInstance) {
677
+ sRole.stop(red("Could not find the Cloud SQL instance. Aborting upgrade."));
678
+ log.warn("Migrations ran; no traffic was shifted. Re-run upgrade once the instance is found.");
679
+ return false;
680
+ }
681
+ const roleTarget = { projectId: meta.projectId, env: GCP_STACK_ENV, sqlInstance };
682
+ const gatewayRole = await ensureGatewayRolePassword(roleTarget);
683
+ if (gatewayRole.status === "failed") {
684
+ sRole.stop(red("gateway_role could not be given its password. Aborting upgrade."));
685
+ log.error(red(` ${gatewayRole.error ?? ""}`));
686
+ log.warn("Migrations ran (0248 creates gateway_role); no traffic was shifted. Fix the above and re-run upgrade.");
687
+ return false;
688
+ }
689
+ sRole.stop(green("gateway_role ready"));
690
+ const gatewayLive = plans.find((p) => p.slug === "gateway");
691
+ const publicRole = planPublicRoleSwitch(roleTarget, gatewayLive?.liveEnv ?? new Map());
692
+ // ────────────────────────────────────────────────────────────────
572
693
  // Issue #37 — MCP through IAP. Idempotently ensure the MCP service
573
694
  // account + its IAP/token-creator bindings; the admin learns the SA's
574
695
  // email from CAELO_MCP_IAP_SERVICE_ACCOUNT in the env contract. Installs
@@ -648,9 +769,27 @@ export async function upgradeCommand(opts = {}) {
648
769
  // service's run SA: the gateway moves to its own SA here.
649
770
  // ────────────────────────────────────────────────────────────────
650
771
  const rolled = [];
772
+ /** A failed gateway roll goes back to a revision that reads public_role's old password. */
773
+ const restorePublicRole = async (slug) => {
774
+ if (slug !== "gateway")
775
+ return;
776
+ const outcome = await publicRole.afterGatewayRollFailed();
777
+ if (outcome.startsWith("could NOT"))
778
+ log.error(red(` ${outcome}`));
779
+ else
780
+ log.info(` ${outcome}`);
781
+ };
651
782
  for (const plan of rolls) {
652
783
  const s = spinner();
653
784
  s.start(`Rolling ${plan.slug} → ${plan.digest.slice(0, 19)}...`);
785
+ if (plan.slug === "gateway") {
786
+ const moved = await publicRole.beforeGatewayRoll();
787
+ if (moved.status === "failed") {
788
+ s.stop(red(`public_role could not be moved onto its own password: ${moved.error ?? ""}`));
789
+ await rollbackPriorlyRolled(meta.projectId, region, rolled);
790
+ return false;
791
+ }
792
+ }
654
793
  const upd = await rollService(serviceRollArgs({
655
794
  serviceName: plan.serviceName,
656
795
  region,
@@ -665,8 +804,9 @@ export async function upgradeCommand(opts = {}) {
665
804
  }));
666
805
  if (!upd.ok) {
667
806
  s.stop(red(`Failed: ${upd.stderr.trim()}`));
807
+ await restorePublicRole(plan.slug);
668
808
  await rollbackPriorlyRolled(meta.projectId, region, rolled);
669
- return;
809
+ return false;
670
810
  }
671
811
  // Force traffic onto the new revision. `update --image` only auto-
672
812
  // flips when the service has no explicit traffic config — but our
@@ -691,14 +831,16 @@ export async function upgradeCommand(opts = {}) {
691
831
  ]);
692
832
  if (!flip.ok) {
693
833
  s.stop(red(`Traffic flip to latest failed: ${flip.stderr.trim()}`));
834
+ await restorePublicRole(plan.slug);
694
835
  await rollbackPriorlyRolled(meta.projectId, region, rolled);
695
- return;
836
+ return false;
696
837
  }
697
838
  if (!(await findServiceUrl(meta.projectId, region, plan.serviceName))) {
698
839
  s.stop(red(`Could not resolve service URL for ${plan.slug} — rolling back`));
699
840
  await rollbackTraffic(meta.projectId, region, plan.serviceName, plan.priorRevision);
841
+ await restorePublicRole(plan.slug);
700
842
  await rollbackPriorlyRolled(meta.projectId, region, rolled);
701
- return;
843
+ return false;
702
844
  }
703
845
  // Cloud Run's `update --image` (with default --quiet) returns only
704
846
  // after the new revision passes its container-readiness probe AND
@@ -717,6 +859,29 @@ export async function upgradeCommand(opts = {}) {
717
859
  rolled.push(plan);
718
860
  }
719
861
  log.success(`Upgrade to ${bold(targetTag)} complete (admin + gateway revisions Ready).`);
862
+ // #613 — with both services on their new revisions, drop the bindings
863
+ // nothing reads through any more: above all the gateway SA's access to
864
+ // admin_role's password. Loud on failure — until it is gone, a
865
+ // compromised gateway could still read that password.
866
+ const retired = await removeRetiredIamBindings({
867
+ provider: meta.provider,
868
+ projectId: meta.projectId,
869
+ region,
870
+ env: GCP_STACK_ENV,
871
+ services: Object.fromEntries(rolls.map((r) => [r.slug, r.serviceName])),
872
+ });
873
+ for (const o of retired) {
874
+ if (o.status === "removed")
875
+ log.info(` removed: ${o.id} ${dim(`(${o.why})`)}`);
876
+ if (o.status === "failed") {
877
+ log.error(red(` NOT removed: ${o.id} — ${o.why}\n ${o.error ?? ""}\n Re-run upgrade with an account that may change IAM, or remove the binding by hand.`));
878
+ }
879
+ }
880
+ if (publicRole.moves) {
881
+ // Until this upgrade the gateway held admin_role's password; anyone who
882
+ // ever compromised it could have kept a copy.
883
+ log.warn(yellow(`The gateway no longer holds admin_role's password, but it did until now. Rotate it: ${bold("bunx @caelo-cms/provisioning rotate-secret postgres-password")}`));
884
+ }
720
885
  if (rolls.some((r) => liveEnvHasInlinePassword(r.liveEnv))) {
721
886
  // The services just moved their database password to Secret Manager,
722
887
  // but every earlier revision still shows it in its env.
@@ -732,6 +897,7 @@ export async function upgradeCommand(opts = {}) {
732
897
  admin: digestOf("admin"),
733
898
  gateway: digestOf("gateway"),
734
899
  });
900
+ return true;
735
901
  }
736
902
  /**
737
903
  * Record the digests just rolled so nothing re-deploys an older release:
@@ -909,19 +1075,21 @@ async function rollbackPriorlyRolled(projectId, region, rolled) {
909
1075
  // backup — Cloud SQL on-demand backup + bundle Pulumi state
910
1076
  // =========================================================================
911
1077
  export async function backupCommand() {
912
- const { meta } = requireInstall();
1078
+ await forEachInstall("backup", (_installId, meta) => backupOf(meta));
1079
+ }
1080
+ async function backupOf(meta) {
913
1081
  if (meta.provider !== "gcp") {
914
1082
  log.warn(`backup for provider ${meta.provider} not yet implemented.`);
915
- return;
1083
+ return false;
916
1084
  }
917
1085
  if (!meta.projectId)
918
- return;
1086
+ return false;
919
1087
  const s = spinner();
920
1088
  s.start("Resolving Cloud SQL instance + triggering on-demand backup...");
921
1089
  const sqlName = await resolveGcpResourceName("sql-instance", "caelo-production-pg", meta.projectId);
922
1090
  if (!sqlName) {
923
1091
  s.stop(red("Could not find caelo-production-pg* Cloud SQL instance"));
924
- return;
1092
+ return false;
925
1093
  }
926
1094
  const r = await gcloud([
927
1095
  "sql",
@@ -936,9 +1104,10 @@ export async function backupCommand() {
936
1104
  ]);
937
1105
  if (!r.ok) {
938
1106
  s.stop(red(`Failed: ${r.stderr.trim()}`));
939
- return;
1107
+ return false;
940
1108
  }
941
1109
  s.stop(green(`Backup created. List with \`gcloud sql backups list --instance=${sqlName}\`.`));
1110
+ return true;
942
1111
  }
943
1112
  // =========================================================================
944
1113
  // rotate-secret <name>
@@ -955,7 +1124,7 @@ export async function rotateSecretCommand(name) {
955
1124
  process.exit(2);
956
1125
  }
957
1126
  const secret = name;
958
- const { meta } = requireInstall();
1127
+ const { meta } = await requireInstall("rotate-secret");
959
1128
  if (meta.provider !== "gcp" && meta.provider !== "gcp-firebase") {
960
1129
  log.warn(`rotate-secret for provider ${meta.provider} not yet implemented.`);
961
1130
  return;
@@ -974,7 +1143,7 @@ export async function rotateSecretCommand(name) {
974
1143
  }
975
1144
  services[slug] = serviceName;
976
1145
  }
977
- const sqlInstance = secret === "postgres-password"
1146
+ const sqlInstance = rolesOfSecret(secret).length > 0
978
1147
  ? await resolveGcpResourceName("sql-instance", "caelo-production-pg", meta.projectId)
979
1148
  : null;
980
1149
  const report = await rotateRuntimeSecret({
@@ -1010,7 +1179,7 @@ export async function rotateSecretCommand(name) {
1010
1179
  * longer renders cleanly.
1011
1180
  */
1012
1181
  export async function truncateCommand() {
1013
- const { meta } = requireInstall();
1182
+ const { meta } = await requireInstall("truncate");
1014
1183
  log.warn(yellow(`${bold("Truncate")} wipes ALL pages, modules, templates, layouts, redirects, nav menus, theme tokens, media assets, chat history, audit log, snapshots, and site memory on ${bold(meta.domain)}.\n` +
1015
1184
  `Preserved: users / roles / AI providers / domains / site_defaults / provisioning_outputs.`));
1016
1185
  const confirm1 = await confirm({
@@ -1054,7 +1223,7 @@ export async function truncateCommand() {
1054
1223
  log.warn(`Truncate not implemented for provider=${meta.provider}.`);
1055
1224
  }
1056
1225
  export async function destroyCommand() {
1057
- const { installId, meta } = requireInstall();
1226
+ const { installId, meta } = await requireInstall("destroy");
1058
1227
  log.warn(red(`${bold("Destroy will PERMANENTLY delete")} the GCP project + every Caelo resource. This is irreversible after the 30-day undelete window.`));
1059
1228
  const confirm1 = await confirm({
1060
1229
  message: `Destroy the install for ${bold(meta.domain)} (${bold(meta.projectId ?? "self-hosted")})?`,