@caelo-cms/provisioning 0.10.29 → 0.10.31

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 (99) hide show
  1. package/dist/admin-domain.d.ts +54 -0
  2. package/dist/admin-domain.d.ts.map +1 -0
  3. package/dist/admin-domain.js +122 -0
  4. package/dist/admin-domain.js.map +1 -0
  5. package/dist/cli.js +71 -7
  6. package/dist/cli.js.map +1 -1
  7. package/dist/compose.d.ts +7 -9
  8. package/dist/compose.d.ts.map +1 -1
  9. package/dist/compose.js +17 -1
  10. package/dist/compose.js.map +1 -1
  11. package/dist/deployed-release.d.ts +84 -0
  12. package/dist/deployed-release.d.ts.map +1 -0
  13. package/dist/deployed-release.js +182 -0
  14. package/dist/deployed-release.js.map +1 -0
  15. package/dist/gcloud-retry.d.ts +16 -0
  16. package/dist/gcloud-retry.d.ts.map +1 -0
  17. package/dist/gcloud-retry.js +17 -0
  18. package/dist/gcloud-retry.js.map +1 -0
  19. package/dist/gcloud.d.ts.map +1 -1
  20. package/dist/gcloud.js +2 -0
  21. package/dist/gcloud.js.map +1 -1
  22. package/dist/gcp-names.d.ts +58 -0
  23. package/dist/gcp-names.d.ts.map +1 -0
  24. package/dist/gcp-names.js +88 -0
  25. package/dist/gcp-names.js.map +1 -0
  26. package/dist/install-state.d.ts +31 -0
  27. package/dist/install-state.d.ts.map +1 -1
  28. package/dist/install-state.js +38 -0
  29. package/dist/install-state.js.map +1 -1
  30. package/dist/lifecycle.d.ts +5 -0
  31. package/dist/lifecycle.d.ts.map +1 -1
  32. package/dist/lifecycle.js +340 -50
  33. package/dist/lifecycle.js.map +1 -1
  34. package/dist/mcp-iap.d.ts +5 -26
  35. package/dist/mcp-iap.d.ts.map +1 -1
  36. package/dist/mcp-iap.js +6 -18
  37. package/dist/mcp-iap.js.map +1 -1
  38. package/dist/migration-runner.d.ts +25 -0
  39. package/dist/migration-runner.d.ts.map +1 -1
  40. package/dist/migration-runner.js +60 -19
  41. package/dist/migration-runner.js.map +1 -1
  42. package/dist/operator-access.d.ts +83 -0
  43. package/dist/operator-access.d.ts.map +1 -0
  44. package/dist/operator-access.js +562 -0
  45. package/dist/operator-access.js.map +1 -0
  46. package/dist/region-data.d.ts +30 -0
  47. package/dist/region-data.d.ts.map +1 -0
  48. package/dist/region-data.js +406 -0
  49. package/dist/region-data.js.map +1 -0
  50. package/dist/regions.d.ts +130 -0
  51. package/dist/regions.d.ts.map +1 -0
  52. package/dist/regions.js +247 -0
  53. package/dist/regions.js.map +1 -0
  54. package/dist/runtime-secrets.d.ts +128 -0
  55. package/dist/runtime-secrets.d.ts.map +1 -0
  56. package/dist/runtime-secrets.js +444 -0
  57. package/dist/runtime-secrets.js.map +1 -0
  58. package/dist/stack-contract.d.ts +231 -0
  59. package/dist/stack-contract.d.ts.map +1 -0
  60. package/dist/stack-contract.js +455 -0
  61. package/dist/stack-contract.js.map +1 -0
  62. package/dist/stack-converge.d.ts +183 -0
  63. package/dist/stack-converge.d.ts.map +1 -0
  64. package/dist/stack-converge.js +513 -0
  65. package/dist/stack-converge.js.map +1 -0
  66. package/dist/wizard.d.ts +7 -0
  67. package/dist/wizard.d.ts.map +1 -1
  68. package/dist/wizard.js +11 -1
  69. package/dist/wizard.js.map +1 -1
  70. package/dist/wizards/gcp-cost.d.ts +10 -10
  71. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  72. package/dist/wizards/gcp-cost.js +40 -3
  73. package/dist/wizards/gcp-cost.js.map +1 -1
  74. package/dist/wizards/gcp-firebase.d.ts +2 -0
  75. package/dist/wizards/gcp-firebase.d.ts.map +1 -1
  76. package/dist/wizards/gcp-firebase.js +1 -0
  77. package/dist/wizards/gcp-firebase.js.map +1 -1
  78. package/dist/wizards/gcp-pulumi.d.ts +51 -1
  79. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  80. package/dist/wizards/gcp-pulumi.js +89 -6
  81. package/dist/wizards/gcp-pulumi.js.map +1 -1
  82. package/dist/wizards/gcp.d.ts +2 -0
  83. package/dist/wizards/gcp.d.ts.map +1 -1
  84. package/dist/wizards/gcp.js +171 -23
  85. package/dist/wizards/gcp.js.map +1 -1
  86. package/package.json +3 -3
  87. package/stacks/aws/Pulumi.yaml +5 -2
  88. package/stacks/aws/README.md +3 -1
  89. package/stacks/aws/index.ts +21 -1
  90. package/stacks/azure/Pulumi.yaml +6 -3
  91. package/stacks/azure/README.md +1 -1
  92. package/stacks/azure/index.ts +22 -3
  93. package/stacks/gcp/Pulumi.yaml +5 -5
  94. package/stacks/gcp/README.md +15 -3
  95. package/stacks/gcp/index.ts +204 -141
  96. package/stacks/gcp-firebase/Pulumi.yaml +5 -2
  97. package/stacks/gcp-firebase/index.ts +169 -76
  98. package/stacks/self-hosted/Pulumi.yaml +4 -0
  99. package/stacks/self-hosted/index.ts +3 -0
package/dist/lifecycle.js CHANGED
@@ -15,11 +15,18 @@
15
15
  */
16
16
  import { existsSync, readdirSync } from "node:fs";
17
17
  import { homedir } from "node:os";
18
+ import { join, resolve as resolvePath } from "node:path";
18
19
  import { cancel, confirm, isCancel, log, note, spinner } from "@clack/prompts";
19
20
  import { bold, cyan, dim, green, red, yellow } from "kleur/colors";
20
21
  import { gcloud } from "./gcloud.js";
21
- import { installRoot, readMetadata, readSecret } from "./install-state.js";
22
+ import { GCP_STACK_ENV, gatewayServiceAccountEmail, runServiceAccountEmail } from "./gcp-names.js";
23
+ import { installRoot, readMetadata, readSecret, recordImageDigests, } from "./install-state.js";
22
24
  import { ensureMcpIapAccess } from "./mcp-iap.js";
25
+ import { ensureOperatorAccessSync, resolveOperatorAccessTarget } from "./operator-access.js";
26
+ import { assertRegionUnchanged } from "./regions.js";
27
+ import { ensureGatewayServiceAccount, ensureGeneratedSecrets, plainGeneratedSecretSeed, ROTATABLE_SECRETS, readSecretReplication, rotateRuntimeSecret, rotationRefusal, } from "./runtime-secrets.js";
28
+ import { MCP_ENV_VAR, OPERATOR_ACCESS_JOB_ENV_VAR } from "./stack-contract.js";
29
+ import { ensureStackInvariants, liveContainerEnv, liveContainerMemory, liveEnvHasInlinePassword, planAdminMemory, planContractEnv, rollService, serviceRollArgs, } from "./stack-converge.js";
23
30
  /** Find the single install on this machine — or warn if 0/multiple. */
24
31
  function findActiveInstall() {
25
32
  const home = homedir();
@@ -44,6 +51,25 @@ function requireInstall() {
44
51
  }
45
52
  return found;
46
53
  }
54
+ /**
55
+ * The install's recorded region (#607); exits with the actionable reason
56
+ * when install.json has none, or when `requested` names a different one.
57
+ */
58
+ function installRegion(meta, requested) {
59
+ try {
60
+ return assertRegionUnchanged(meta, requested);
61
+ }
62
+ catch (e) {
63
+ log.error(red(e instanceof Error ? e.message : String(e)));
64
+ process.exit(1);
65
+ }
66
+ }
67
+ /** A Cloud Run lookup is regional; there is no default region to guess. */
68
+ function requiredRegion(region) {
69
+ if (!region)
70
+ throw new Error("a Cloud Run lookup needs the install region (install.json)");
71
+ return region;
72
+ }
47
73
  /**
48
74
  * Pulumi auto-naming appends a random 7-char suffix to every resource
49
75
  * (e.g. `caelo-production-admin-3efcfea`). Lifecycle commands need the
@@ -56,7 +82,7 @@ function requireInstall() {
56
82
  async function resolveGcpResourceName(kind, prefix, projectId, region) {
57
83
  const args = [];
58
84
  if (kind === "run-service") {
59
- args.push("run", "services", "list", "--region", region ?? "europe-west1", "--project", projectId, "--filter", `metadata.name~^${prefix}`, "--format=value(metadata.name)");
85
+ args.push("run", "services", "list", "--region", requiredRegion(region), "--project", projectId, "--filter", `metadata.name~^${prefix}`, "--format=value(metadata.name)");
60
86
  }
61
87
  else {
62
88
  args.push("sql", "instances", "list", "--project", projectId, "--filter", `name~^${prefix}`, "--format=value(name)");
@@ -122,7 +148,7 @@ async function gcpStatus(meta) {
122
148
  return;
123
149
  const s = spinner();
124
150
  s.start("Resolving deployed resources + checking health...");
125
- const region = meta.region ?? "europe-west1";
151
+ const region = installRegion(meta);
126
152
  const adminName = await resolveGcpResourceName("run-service", "caelo-production-admin", meta.projectId, region);
127
153
  const sqlName = await resolveGcpResourceName("sql-instance", "caelo-production-pg", meta.projectId);
128
154
  if (!adminName || !sqlName) {
@@ -202,6 +228,21 @@ async function resolveTagDigest(region, project, repo, image, tag) {
202
228
  return { ok: false, reason: e instanceof Error ? e.message : String(e) };
203
229
  }
204
230
  }
231
+ /** The deployed service as `gcloud run services describe --format=json` returns it. */
232
+ async function describeServiceJson(projectId, region, serviceName) {
233
+ const r = await gcloud([
234
+ "run",
235
+ "services",
236
+ "describe",
237
+ serviceName,
238
+ "--region",
239
+ region,
240
+ "--project",
241
+ projectId,
242
+ "--format=json",
243
+ ]);
244
+ return r.ok ? r.stdout : null;
245
+ }
205
246
  /** Look up the currently-serving Cloud Run revision so we can roll back to it. */
206
247
  async function findCurrentRevision(projectId, region, serviceName) {
207
248
  const r = await gcloud([
@@ -274,7 +315,7 @@ async function resolveAdminIapResource(meta, region, adminServiceName) {
274
315
  return name ? { kind: "backend-services", service: name } : null;
275
316
  }
276
317
  export async function upgradeCommand(opts = {}) {
277
- const { meta } = requireInstall();
318
+ const { installId, meta } = requireInstall();
278
319
  // v0.5.15 — extended to cover gcp-firebase too. Both providers share
279
320
  // the identical admin + gateway shape on Cloud Run (Artifact
280
321
  // Registry image, `caelo-production-<slug>` service naming, the same
@@ -288,7 +329,9 @@ export async function upgradeCommand(opts = {}) {
288
329
  }
289
330
  if (!meta.projectId)
290
331
  return;
291
- const region = meta.region ?? "europe-west1";
332
+ // #607 — upgrade never moves an install: a `--region` other than the
333
+ // recorded one is refused before anything rolls.
334
+ const region = installRegion(meta, opts.region);
292
335
  const registryProject = "caelo-website";
293
336
  const registryRegion = "europe-west1";
294
337
  const registryRepo = "caelo-cms-images";
@@ -339,15 +382,57 @@ export async function upgradeCommand(opts = {}) {
339
382
  sPre.stop(red(`Could not capture current revision for ${slug} — refusing to roll`));
340
383
  return;
341
384
  }
385
+ const serviceJson = await describeServiceJson(meta.projectId, region, serviceName);
386
+ if (!serviceJson) {
387
+ sPre.stop(red(`Could not read the ${slug} service's configuration — refusing to roll`));
388
+ return;
389
+ }
342
390
  plans.push({
343
391
  slug,
344
392
  serviceName,
345
393
  digest,
346
394
  imageRef: `${registryRegion}-docker.pkg.dev/${registryProject}/${registryRepo}/${slug}@${digest}`,
347
395
  priorRevision,
396
+ liveEnv: liveContainerEnv(serviceJson),
397
+ liveMemory: liveContainerMemory(serviceJson),
348
398
  });
349
399
  }
350
- sPre.stop(green(`Pre-flight ok — ${plans.length} services planned`));
400
+ const install = {
401
+ provider: meta.provider,
402
+ projectId: meta.projectId,
403
+ env: GCP_STACK_ENV,
404
+ domain: meta.domain,
405
+ region,
406
+ };
407
+ const deployed = Object.fromEntries(plans.map((p) => [p.slug, { serviceName: p.serviceName, liveEnv: p.liveEnv }]));
408
+ const envPlan = planContractEnv(install, deployed);
409
+ if (!envPlan.ok) {
410
+ sPre.stop(red("Pre-flight failed: the install's env contract can't be applied"));
411
+ log.error(envPlan.error);
412
+ return;
413
+ }
414
+ let rolls = plans.map((p) => {
415
+ // #553 — the admin runs the Lighthouse quality audit; raise it to the
416
+ // stack's memory default (never lowered; an unparsable value is kept).
417
+ const memory = p.slug === "admin" ? planAdminMemory(p.liveMemory) : null;
418
+ if (memory && !memory.ok)
419
+ log.warn(yellow(`admin memory left as is: ${memory.error}`));
420
+ return {
421
+ ...p,
422
+ envFlags: envPlan.services[p.slug].flags,
423
+ envChanges: envPlan.services[p.slug].changes,
424
+ resourceFlags: memory?.ok ? memory.flags : [],
425
+ };
426
+ });
427
+ sPre.stop(green(`Pre-flight ok — ${rolls.length} services planned`));
428
+ for (const roll of rolls) {
429
+ for (const f of roll.resourceFlags) {
430
+ log.info(`${roll.slug} ${bold("resources")}: ${roll.liveMemory ?? dim("(default)")} → ${f}`);
431
+ }
432
+ for (const c of roll.envChanges) {
433
+ log.info(`${roll.slug} env ${bold(c.name)}: ${c.from === undefined ? dim("(unset)") : c.from} → ${c.to === undefined ? dim("(removed)") : c.to}`);
434
+ }
435
+ }
351
436
  // ────────────────────────────────────────────────────────────────
352
437
  // P21 ship 4 — cosign verify each resolved digest against the
353
438
  // Caelo release workflow's keyless OIDC identity. Refuses to roll
@@ -368,6 +453,75 @@ export async function upgradeCommand(opts = {}) {
368
453
  log.warn(yellow("--skip-verify set — image signatures NOT verified."));
369
454
  }
370
455
  // ────────────────────────────────────────────────────────────────
456
+ // Runtime identities + secrets the env contract references: the
457
+ // gateway's own run SA and the CLI-generated secrets
458
+ // (runtime-secrets.ts). Created once, never overwritten. Must exist
459
+ // before the IAM invariants bind them and before the rolls reference
460
+ // them, so any failure aborts here.
461
+ // ────────────────────────────────────────────────────────────────
462
+ const sRt = spinner();
463
+ sRt.start("Ensuring the gateway service account + generated runtime secrets...");
464
+ const secretsTarget = { projectId: meta.projectId, env: GCP_STACK_ENV };
465
+ const replication = await readSecretReplication(secretsTarget);
466
+ const runtime = [
467
+ await ensureGatewayServiceAccount(secretsTarget),
468
+ ...(replication.ok
469
+ ? await ensureGeneratedSecrets({
470
+ ...secretsTarget,
471
+ replication: replication.replication,
472
+ seed: plainGeneratedSecretSeed(deployed.admin.liveEnv),
473
+ })
474
+ : [{ id: "secret replication", status: "failed", error: replication.error }]),
475
+ ];
476
+ const runtimeFailed = runtime.filter((o) => o.status === "failed");
477
+ if (runtimeFailed.length > 0) {
478
+ sRt.stop(red("Runtime identities/secrets could not be ensured. Aborting upgrade."));
479
+ for (const o of runtimeFailed)
480
+ log.error(red(` FAILED: ${o.id}\n ${o.error ?? ""}`));
481
+ log.warn("No traffic was shifted and no migrations ran. Fix the above and re-run.");
482
+ return;
483
+ }
484
+ sRt.stop(green("Gateway service account + runtime secrets ok"));
485
+ for (const o of runtime.filter((o) => o.status === "applied"))
486
+ log.info(` created: ${o.id}`);
487
+ // ────────────────────────────────────────────────────────────────
488
+ // Converge the infrastructure the stack declares but upgrade can't get
489
+ // from an image roll: IAM bindings + CDN policy added to the stacks after
490
+ // this install was provisioned (stack-contract.ts). Additive + idempotent.
491
+ // A failure the install can't work without aborts here, before
492
+ // migrations or any traffic shift; the rest warn.
493
+ // ────────────────────────────────────────────────────────────────
494
+ const sInv = spinner();
495
+ sInv.start("Ensuring the IAM bindings + CDN settings the stack declares...");
496
+ const invariants = await ensureStackInvariants({
497
+ provider: meta.provider,
498
+ projectId: meta.projectId,
499
+ region,
500
+ env: GCP_STACK_ENV,
501
+ services: Object.fromEntries(rolls.map((r) => [r.slug, r.serviceName])),
502
+ });
503
+ const applied = invariants.outcomes.filter((o) => o.status === "applied");
504
+ const failed = invariants.outcomes.filter((o) => o.status === "failed");
505
+ if (invariants.mustAbort) {
506
+ sInv.stop(red("Stack invariants could not be ensured. Aborting upgrade."));
507
+ }
508
+ else {
509
+ sInv.stop(green(`Stack invariants ok (${invariants.outcomes.length - failed.length}/${invariants.outcomes.length}, ${applied.length} applied)`));
510
+ }
511
+ for (const o of applied)
512
+ log.info(` applied: ${o.id} ${dim(`(${o.why})`)}`);
513
+ for (const o of failed) {
514
+ const line = ` ${o.onFailure === "abort" ? "FAILED" : "not applied"}: ${o.id} — ${o.why}\n ${o.error ?? ""}`;
515
+ if (o.onFailure === "abort")
516
+ log.error(red(line));
517
+ else
518
+ log.warn(yellow(line));
519
+ }
520
+ if (invariants.mustAbort) {
521
+ log.warn("No traffic was shifted and no migrations ran. Fix the bindings above and re-run.");
522
+ return;
523
+ }
524
+ // ────────────────────────────────────────────────────────────────
371
525
  // P21 ship 3 — DB migrations BEFORE traffic shifts. Idempotent
372
526
  // (drizzle bookkeeping table); a failure here aborts the upgrade
373
527
  // before the new image touches traffic, so the admin keeps
@@ -382,7 +536,7 @@ export async function upgradeCommand(opts = {}) {
382
536
  // so new migrations were silently skipped. Production hit this on
383
537
  // every upgrade that introduced schema changes — the symptom was
384
538
  // post-rollout queries failing with "column does not exist".
385
- const adminPlan = plans.find((p) => p.slug === "admin");
539
+ const adminPlan = rolls.find((p) => p.slug === "admin");
386
540
  const mig = await runMigrationsViaCloudRunJob({
387
541
  projectId: meta.projectId,
388
542
  region,
@@ -399,13 +553,14 @@ export async function upgradeCommand(opts = {}) {
399
553
  sMig.stop(green("Migrations applied"));
400
554
  // ────────────────────────────────────────────────────────────────
401
555
  // Issue #37 — MCP through IAP. Idempotently ensure the MCP service
402
- // account + its IAP/token-creator bindings, then hand its email to the
403
- // new admin revision (CAELO_MCP_IAP_SERVICE_ACCOUNT) so /security/mcp
404
- // shows a working `claude mcp add` command. Installs provisioned before
405
- // this existed get it here, with no operator config. A failure only
406
- // costs MCP access, so it warns instead of aborting the upgrade.
556
+ // account + its IAP/token-creator bindings; the admin learns the SA's
557
+ // email from CAELO_MCP_IAP_SERVICE_ACCOUNT in the env contract. Installs
558
+ // provisioned before this existed get it here, with no operator config.
559
+ // A failure only costs MCP access, so it warns instead of aborting.
407
560
  // ────────────────────────────────────────────────────────────────
408
- let mcpServiceAccount = null;
561
+ // Env vars the rolls must leave as the service has them, because the
562
+ // thing they point at could not be set up.
563
+ const leaveUntouched = [];
409
564
  if (adminPlan) {
410
565
  const sMcp = spinner();
411
566
  sMcp.start("Ensuring MCP access through IAP...");
@@ -414,39 +569,82 @@ export async function upgradeCommand(opts = {}) {
414
569
  ? await ensureMcpIapAccess({ projectId: meta.projectId, resource })
415
570
  : { ok: false, error: "admin IAP backend service not found" };
416
571
  if (mcp.ok) {
417
- mcpServiceAccount = mcp.serviceAccount;
418
572
  sMcp.stop(green(`MCP access ready (${mcp.serviceAccount}; ${mcp.operators.length} operator(s))`));
419
573
  }
420
574
  else {
421
575
  sMcp.stop(yellow(`MCP access not configured: ${mcp.error}`));
422
576
  log.warn("The upgrade continues; external MCP clients stay blocked by IAP until this succeeds.");
577
+ // Don't hand the admin an MCP service account that may not exist or
578
+ // lacks its bindings: /security/mcp would print a `claude mcp add`
579
+ // command that can't work. Leave the var as the service has it.
580
+ leaveUntouched.push(MCP_ENV_VAR);
581
+ }
582
+ // ──────────────────────────────────────────────────────────────
583
+ // Operator access: the sync job that keeps Google IAP in step with the
584
+ // user list (operator-access.ts) — its own SA, the only principal that
585
+ // may change the IAP binding; the admin may only start it. Also removes
586
+ // the operator-access rights an earlier revision gave the admin's SA.
587
+ // After migrations: the job's database user gets the role migration
588
+ // 0239 creates. A failure only costs IAP following the user list, so it
589
+ // warns; the admin then reports the sync as not set up, loudly.
590
+ // ──────────────────────────────────────────────────────────────
591
+ const sOa = spinner();
592
+ sOa.start("Ensuring the operator-access sync job...");
593
+ const resolved = await resolveOperatorAccessTarget({
594
+ provider: meta.provider,
595
+ projectId: meta.projectId,
596
+ region,
597
+ env: GCP_STACK_ENV,
598
+ ownerEmail: meta.ownerEmail,
599
+ imageRef: adminPlan.imageRef,
600
+ });
601
+ const oa = resolved.ok
602
+ ? await ensureOperatorAccessSync(resolved.target)
603
+ : { ok: false, done: [], error: resolved.error };
604
+ for (const step of oa.done)
605
+ log.info(` ${dim(step)}`);
606
+ if (oa.ok) {
607
+ sOa.stop(green("Operator-access sync job ready (IAP follows the user list)"));
608
+ }
609
+ else {
610
+ sOa.stop(yellow(`Operator-access sync job not set up: ${oa.error}`));
611
+ log.warn("The upgrade continues; user changes will report that Google IAP could not be updated until this succeeds. Re-run upgrade once the cause is fixed.");
612
+ leaveUntouched.push(OPERATOR_ACCESS_JOB_ENV_VAR);
613
+ }
614
+ }
615
+ if (leaveUntouched.length > 0) {
616
+ const replan = planContractEnv(install, deployed, { leaveUntouched });
617
+ if (replan.ok) {
618
+ rolls = rolls.map((r) => ({
619
+ ...r,
620
+ envFlags: replan.services[r.slug].flags,
621
+ envChanges: replan.services[r.slug].changes,
622
+ }));
423
623
  }
424
624
  }
425
625
  // ────────────────────────────────────────────────────────────────
426
626
  // Phase 2: roll each service, probe health, auto-rollback on fail.
427
627
  // If admin succeeds but gateway fails, also roll admin back so the
428
- // operator never ends up on a mismatched-version pair.
628
+ // operator never ends up on a mismatched-version pair. The env
629
+ // contract's changes ride the same `services update` as the image, so
630
+ // they land in the new revision (and roll back with it). So does the
631
+ // service's run SA: the gateway moves to its own SA here.
429
632
  // ────────────────────────────────────────────────────────────────
430
633
  const rolled = [];
431
- for (const plan of plans) {
634
+ for (const plan of rolls) {
432
635
  const s = spinner();
433
636
  s.start(`Rolling ${plan.slug} → ${plan.digest.slice(0, 19)}...`);
434
- const upd = await gcloud([
435
- "run",
436
- "services",
437
- "update",
438
- plan.serviceName,
439
- "--region",
637
+ const upd = await rollService(serviceRollArgs({
638
+ serviceName: plan.serviceName,
440
639
  region,
441
- "--project",
442
- meta.projectId,
443
- "--image",
444
- plan.imageRef,
445
- ...(plan.slug === "admin" && mcpServiceAccount
446
- ? [`--update-env-vars=CAELO_MCP_IAP_SERVICE_ACCOUNT=${mcpServiceAccount}`]
447
- : []),
448
- "--quiet",
449
- ]);
640
+ projectId: meta.projectId,
641
+ imageRef: plan.imageRef,
642
+ serviceAccount: plan.slug === "admin"
643
+ ? runServiceAccountEmail(meta.projectId, GCP_STACK_ENV)
644
+ : gatewayServiceAccountEmail(meta.projectId, GCP_STACK_ENV),
645
+ envFlags: plan.envFlags,
646
+ resourceFlags: plan.resourceFlags,
647
+ }));
450
648
  if (!upd.ok) {
451
649
  s.stop(red(`Failed: ${upd.stderr.trim()}`));
452
650
  await rollbackPriorlyRolled(meta.projectId, region, rolled);
@@ -501,6 +699,74 @@ export async function upgradeCommand(opts = {}) {
501
699
  rolled.push(plan);
502
700
  }
503
701
  log.success(`Upgrade to ${bold(targetTag)} complete (admin + gateway revisions Ready).`);
702
+ if (rolls.some((r) => liveEnvHasInlinePassword(r.liveEnv))) {
703
+ // The services just moved their database password to Secret Manager,
704
+ // but every earlier revision still shows it in its env.
705
+ log.warn(yellow(`The database password was stored in plain env vars before this upgrade and stays readable in the services' old revisions. Rotate it now: ${bold("bunx @caelo-cms/provisioning rotate-secret postgres-password")}`));
706
+ }
707
+ const digestOf = (slug) => {
708
+ const roll = rolls.find((r) => r.slug === slug);
709
+ if (!roll)
710
+ throw new Error(`upgrade rolled no ${slug} service`);
711
+ return roll.digest;
712
+ };
713
+ await recordRolledDigests(installId, meta.provider, {
714
+ admin: digestOf("admin"),
715
+ gateway: digestOf("gateway"),
716
+ });
717
+ }
718
+ /**
719
+ * Record the digests just rolled so nothing re-deploys an older release:
720
+ * install.json (what a wizard re-run deploys) and the Pulumi stack config
721
+ * (what a plain `pulumi up` deploys). The roll already succeeded, so each
722
+ * write that fails warns with the exact manual fix instead of failing the
723
+ * upgrade, and one failing never skips the other.
724
+ */
725
+ async function recordRolledDigests(installId, provider, digests) {
726
+ const reason = (e) => (e instanceof Error ? e.message.split("\n")[0] : String(e));
727
+ try {
728
+ recordImageDigests(installId, digests);
729
+ }
730
+ catch (e) {
731
+ log.warn(yellow(`Could not record the rolled digests in ${installRoot(installId)}/install.json (${reason(e)}). Add this before re-running the installer, or it deploys the newest release instead:\n "imageDigests": ${JSON.stringify(digests)}`));
732
+ }
733
+ const manual = stackConfigRecovery(installId, provider, digests);
734
+ const passphrase = readSecret(installId, "pulumi-passphrase");
735
+ if (!passphrase) {
736
+ log.warn(yellow(`No Pulumi passphrase in ~/.caelo-${installId}/secrets — stack config not updated. Before any manual \`pulumi up\`, run:\n${manual}`));
737
+ return;
738
+ }
739
+ try {
740
+ const { writeImageDigestsToStack } = await import("./wizards/gcp-pulumi.js");
741
+ const { removedOverrides } = await writeImageDigestsToStack({
742
+ installRoot: installRoot(installId),
743
+ pulumiPassphrase: passphrase,
744
+ provider,
745
+ digests,
746
+ });
747
+ log.info(dim("Pinned the rolled image digests in the Pulumi stack config."));
748
+ if (removedOverrides.length > 0) {
749
+ log.warn(yellow(`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.`));
750
+ }
751
+ }
752
+ catch (e) {
753
+ log.warn(yellow(`Could not pin the rolled digests in the Pulumi stack config (${reason(e)}). Before any manual \`pulumi up\`, run:\n${manual}`));
754
+ }
755
+ }
756
+ /**
757
+ * Shell commands that pin `digests` in the install's own Pulumi stack, with
758
+ * the same workspace, backend, stack and passphrase the CLI uses — run from
759
+ * any directory, they cannot hit an unrelated stack.
760
+ */
761
+ function stackConfigRecovery(installId, provider, digests) {
762
+ const root = installRoot(installId);
763
+ const env = `PULUMI_BACKEND_URL=file://${join(root, "state")} PULUMI_CONFIG_PASSPHRASE="$(cat ${join(root, "secrets", "pulumi-passphrase")})"`;
764
+ const cwd = `--cwd ${resolvePath(import.meta.dir, "../stacks", provider)} --stack ${GCP_STACK_ENV}`;
765
+ const lines = Object.entries(digests).map(([svc, d]) => ` ${env} pulumi config set ${cwd} caelo-${provider}:image-digest-${svc} ${d}`);
766
+ if (provider === "gcp") {
767
+ lines.push(` ${env} pulumi config rm ${cwd} caelo-gcp:image-admin # only if set`, ` ${env} pulumi config rm ${cwd} caelo-gcp:image-gateway # only if set`);
768
+ }
769
+ return lines.join("\n");
504
770
  }
505
771
  /**
506
772
  * Roll back any services that already passed their probe in this
@@ -662,30 +928,54 @@ export async function backupCommand() {
662
928
  export async function rotateSecretCommand(name) {
663
929
  if (!name) {
664
930
  log.error(red("Usage: caelo-cms rotate-secret <name>"));
665
- log.warn(`Names: ${[
666
- "postgres-password",
667
- "csrf-secret",
668
- "cookie-secret",
669
- "secret-kek",
670
- "anthropic-api-key",
671
- "resend-api-key",
672
- ].join(", ")}`);
931
+ log.warn(`Names: ${ROTATABLE_SECRETS.join(", ")}`);
673
932
  process.exit(2);
674
933
  }
934
+ const refusal = rotationRefusal(name);
935
+ if (refusal) {
936
+ log.error(red(refusal));
937
+ process.exit(2);
938
+ }
939
+ const secret = name;
675
940
  const { meta } = requireInstall();
676
- if (meta.provider !== "gcp") {
941
+ if (meta.provider !== "gcp" && meta.provider !== "gcp-firebase") {
677
942
  log.warn(`rotate-secret for provider ${meta.provider} not yet implemented.`);
678
943
  return;
679
944
  }
680
- log.warn(yellow(`Secret rotation v1 prints the gcloud command for you to run. Full automation lands in a follow-up.`));
681
- note([
682
- bold("Run this from your terminal:"),
683
- "",
684
- ` ${cyan(`echo -n "<new-value>" | gcloud secrets versions add caelo-production-${name} --data-file=- --project=${meta.projectId}`)}`,
685
- "",
686
- `Then redeploy admin + gateway to pick up the new value:`,
687
- ` ${cyan(`bunx @caelo-cms/provisioning upgrade`)}`,
688
- ].join("\n"), "Rotate secret");
945
+ if (!meta.projectId)
946
+ return;
947
+ const region = installRegion(meta);
948
+ const s = spinner();
949
+ s.start(`Rotating ${secret}...`);
950
+ const services = {};
951
+ for (const slug of ["admin", "gateway"]) {
952
+ const serviceName = await resolveGcpResourceName("run-service", `caelo-production-${slug}`, meta.projectId, region);
953
+ if (!serviceName) {
954
+ s.stop(red(`Could not find caelo-production-${slug}* Cloud Run service — nothing rotated`));
955
+ return;
956
+ }
957
+ services[slug] = serviceName;
958
+ }
959
+ const sqlInstance = secret === "postgres-password"
960
+ ? await resolveGcpResourceName("sql-instance", "caelo-production-pg", meta.projectId)
961
+ : null;
962
+ const report = await rotateRuntimeSecret({
963
+ projectId: meta.projectId,
964
+ region,
965
+ env: GCP_STACK_ENV,
966
+ services: services,
967
+ ...(sqlInstance ? { sqlInstance } : {}),
968
+ }, secret);
969
+ if (report.ok) {
970
+ s.stop(green(`${secret} rotated`));
971
+ }
972
+ else {
973
+ s.stop(red(`Rotating ${secret} failed`));
974
+ }
975
+ for (const step of report.steps)
976
+ log.info(` ${step}`);
977
+ if (report.error)
978
+ log.error(red(report.error));
689
979
  }
690
980
  // =========================================================================
691
981
  // destroy — pulumi destroy + gcloud projects delete
@@ -729,7 +1019,7 @@ export async function truncateCommand() {
729
1019
  }
730
1020
  if ((meta.provider === "gcp" || meta.provider === "gcp-firebase") && meta.projectId) {
731
1021
  const { truncateViaCloudRunJob } = await import("./migration-runner.js");
732
- const region = meta.region ?? "europe-west1";
1022
+ const region = installRegion(meta);
733
1023
  const r = await truncateViaCloudRunJob({ projectId: meta.projectId, region });
734
1024
  if (!r.ok) {
735
1025
  cancel(`Truncate failed: ${r.error}.`);