@caelo-cms/provisioning 0.10.44 → 0.10.47

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 (146) hide show
  1. package/dist/adc-impersonation.d.ts +28 -0
  2. package/dist/adc-impersonation.d.ts.map +1 -0
  3. package/dist/adc-impersonation.js +148 -0
  4. package/dist/adc-impersonation.js.map +1 -0
  5. package/dist/checkpoint-secrets.d.ts.map +1 -1
  6. package/dist/checkpoint-secrets.js +2 -1
  7. package/dist/checkpoint-secrets.js.map +1 -1
  8. package/dist/cli.js +202 -33
  9. package/dist/cli.js.map +1 -1
  10. package/dist/compose.d.ts +45 -0
  11. package/dist/compose.d.ts.map +1 -1
  12. package/dist/compose.js +112 -29
  13. package/dist/compose.js.map +1 -1
  14. package/dist/gcloud-retry.d.ts +5 -1
  15. package/dist/gcloud-retry.d.ts.map +1 -1
  16. package/dist/gcloud-retry.js.map +1 -1
  17. package/dist/gcloud.d.ts +2 -1
  18. package/dist/gcloud.d.ts.map +1 -1
  19. package/dist/gcloud.js +11 -13
  20. package/dist/gcloud.js.map +1 -1
  21. package/dist/index.d.ts +3 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +3 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/infra-contract.d.ts +24 -0
  26. package/dist/infra-contract.d.ts.map +1 -0
  27. package/dist/infra-contract.js +25 -0
  28. package/dist/infra-contract.js.map +1 -0
  29. package/dist/install-state.d.ts +66 -2
  30. package/dist/install-state.d.ts.map +1 -1
  31. package/dist/install-state.js +162 -22
  32. package/dist/install-state.js.map +1 -1
  33. package/dist/lifecycle.d.ts +6 -0
  34. package/dist/lifecycle.d.ts.map +1 -1
  35. package/dist/lifecycle.js +189 -819
  36. package/dist/lifecycle.js.map +1 -1
  37. package/dist/migration-runner.d.ts +3 -0
  38. package/dist/migration-runner.d.ts.map +1 -1
  39. package/dist/migration-runner.js +7 -5
  40. package/dist/migration-runner.js.map +1 -1
  41. package/dist/ops-backend.d.ts +46 -0
  42. package/dist/ops-backend.d.ts.map +1 -0
  43. package/dist/ops-backend.js +370 -0
  44. package/dist/ops-backend.js.map +1 -0
  45. package/dist/provisioner-identity.d.ts +103 -0
  46. package/dist/provisioner-identity.d.ts.map +1 -0
  47. package/dist/provisioner-identity.js +323 -0
  48. package/dist/provisioner-identity.js.map +1 -0
  49. package/dist/regions.d.ts.map +1 -1
  50. package/dist/regions.js +2 -1
  51. package/dist/regions.js.map +1 -1
  52. package/dist/release-contract.d.ts +37 -0
  53. package/dist/release-contract.d.ts.map +1 -0
  54. package/dist/release-contract.js +55 -0
  55. package/dist/release-contract.js.map +1 -0
  56. package/dist/release-manifest.d.ts +140 -0
  57. package/dist/release-manifest.d.ts.map +1 -0
  58. package/dist/release-manifest.js +319 -0
  59. package/dist/release-manifest.js.map +1 -0
  60. package/dist/self-command.d.ts +16 -0
  61. package/dist/self-command.d.ts.map +1 -0
  62. package/dist/self-command.js +20 -0
  63. package/dist/self-command.js.map +1 -0
  64. package/dist/self-hosted-release.d.ts +81 -0
  65. package/dist/self-hosted-release.d.ts.map +1 -0
  66. package/dist/self-hosted-release.js +148 -0
  67. package/dist/self-hosted-release.js.map +1 -0
  68. package/dist/self-hosted-upgrade.d.ts +91 -0
  69. package/dist/self-hosted-upgrade.d.ts.map +1 -0
  70. package/dist/self-hosted-upgrade.js +355 -0
  71. package/dist/self-hosted-upgrade.js.map +1 -0
  72. package/dist/stack-converge.d.ts.map +1 -1
  73. package/dist/stack-converge.js +2 -1
  74. package/dist/stack-converge.js.map +1 -1
  75. package/dist/state-attach.d.ts +23 -0
  76. package/dist/state-attach.d.ts.map +1 -0
  77. package/dist/state-attach.js +112 -0
  78. package/dist/state-attach.js.map +1 -0
  79. package/dist/state-backend.d.ts +114 -0
  80. package/dist/state-backend.d.ts.map +1 -0
  81. package/dist/state-backend.js +200 -0
  82. package/dist/state-backend.js.map +1 -0
  83. package/dist/state-command.d.ts +20 -0
  84. package/dist/state-command.d.ts.map +1 -0
  85. package/dist/state-command.js +237 -0
  86. package/dist/state-command.js.map +1 -0
  87. package/dist/state-migrate.d.ts +121 -0
  88. package/dist/state-migrate.d.ts.map +1 -0
  89. package/dist/state-migrate.js +290 -0
  90. package/dist/state-migrate.js.map +1 -0
  91. package/dist/state-pulumi.d.ts +17 -0
  92. package/dist/state-pulumi.d.ts.map +1 -0
  93. package/dist/state-pulumi.js +218 -0
  94. package/dist/state-pulumi.js.map +1 -0
  95. package/dist/state-store.d.ts +82 -0
  96. package/dist/state-store.d.ts.map +1 -0
  97. package/dist/state-store.js +205 -0
  98. package/dist/state-store.js.map +1 -0
  99. package/dist/upgrade/app-roll.d.ts +164 -0
  100. package/dist/upgrade/app-roll.d.ts.map +1 -0
  101. package/dist/upgrade/app-roll.js +270 -0
  102. package/dist/upgrade/app-roll.js.map +1 -0
  103. package/dist/upgrade/backup.d.ts +48 -0
  104. package/dist/upgrade/backup.d.ts.map +1 -0
  105. package/dist/upgrade/backup.js +71 -0
  106. package/dist/upgrade/backup.js.map +1 -0
  107. package/dist/upgrade/converge.d.ts +45 -0
  108. package/dist/upgrade/converge.d.ts.map +1 -0
  109. package/dist/upgrade/converge.js +287 -0
  110. package/dist/upgrade/converge.js.map +1 -0
  111. package/dist/upgrade/gcp-lookup.d.ts +23 -0
  112. package/dist/upgrade/gcp-lookup.d.ts.map +1 -0
  113. package/dist/upgrade/gcp-lookup.js +95 -0
  114. package/dist/upgrade/gcp-lookup.js.map +1 -0
  115. package/dist/upgrade/health.d.ts +99 -0
  116. package/dist/upgrade/health.d.ts.map +1 -0
  117. package/dist/upgrade/health.js +214 -0
  118. package/dist/upgrade/health.js.map +1 -0
  119. package/dist/upgrade/release-images.d.ts +125 -0
  120. package/dist/upgrade/release-images.d.ts.map +1 -0
  121. package/dist/upgrade/release-images.js +315 -0
  122. package/dist/upgrade/release-images.js.map +1 -0
  123. package/dist/upgrade/reporter.d.ts +17 -0
  124. package/dist/upgrade/reporter.d.ts.map +1 -0
  125. package/dist/upgrade/reporter.js +28 -0
  126. package/dist/upgrade/reporter.js.map +1 -0
  127. package/dist/upgrade/upgrade.d.ts +79 -0
  128. package/dist/upgrade/upgrade.d.ts.map +1 -0
  129. package/dist/upgrade/upgrade.js +105 -0
  130. package/dist/upgrade/upgrade.js.map +1 -0
  131. package/dist/wizard.d.ts.map +1 -1
  132. package/dist/wizard.js +7 -3
  133. package/dist/wizard.js.map +1 -1
  134. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  135. package/dist/wizards/gcp-cost.js +7 -1
  136. package/dist/wizards/gcp-cost.js.map +1 -1
  137. package/dist/wizards/gcp-pulumi.d.ts +104 -8
  138. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  139. package/dist/wizards/gcp-pulumi.js +159 -50
  140. package/dist/wizards/gcp-pulumi.js.map +1 -1
  141. package/dist/wizards/gcp.d.ts.map +1 -1
  142. package/dist/wizards/gcp.js +246 -78
  143. package/dist/wizards/gcp.js.map +1 -1
  144. package/package.json +4 -3
  145. package/stacks/self-hosted/README.md +4 -0
  146. package/stacks/self-hosted/index.ts +10 -2
@@ -8,9 +8,14 @@
8
8
  * 5. Links billing
9
9
  * 6. Enables 13 APIs in one call
10
10
  * 7. Creates the provisioner SA + grants 15 IAM roles
11
- * 8. Mints a JSON SA key into `~/.caelo-<install-id>/secrets/sa-key.json`
12
- * 9. Generates Pulumi passphrase if absent → secrets/pulumi-passphrase
13
- * 10. Pre-flight cost-estimate table; single y/N confirm
11
+ * 8. Lets the operator's gcloud account impersonate that SA (#696): no
12
+ * key is ever minted; an older install's `sa-key.json` is deleted,
13
+ * locally and in IAM (provisioner-identity.ts)
14
+ * 9. Pre-flight cost-estimate table; single y/N confirm
15
+ * 10. Ensures the install's ops bucket + KMS key in its own project and
16
+ * keeps Pulumi's state + install.json there (#696, ops-backend.ts).
17
+ * An older install whose state is on this machine keeps it there
18
+ * and its passphrase in secrets/pulumi-passphrase
14
19
  * 11. Pulumi up via the Automation SDK; streams progress, then deletes
15
20
  * the firewall rules GCP pre-populates on the default network
16
21
  * 12. Prints DNS records + bootstrap URL + a note pointing the
@@ -25,6 +30,7 @@ import { homedir } from "node:os";
25
30
  import { join } from "node:path";
26
31
  import { cancel, confirm, isCancel, log, note, select, spinner, text } from "@clack/prompts";
27
32
  import { bold, cyan, dim, green, red, yellow } from "kleur/colors";
33
+ import { checkAdcImpersonation } from "../adc-impersonation.js";
28
34
  import { CERT_WAIT_TIMEOUT_MS, lbCertProbe, waitForCert } from "../cert-wait.js";
29
35
  import { redactCredentials } from "../checkpoint-secrets.js";
30
36
  import { removeDefaultFirewallRules } from "../default-firewall.js";
@@ -32,15 +38,19 @@ import { checkDeployedRuntimeEnv, chooseImageDigests, readDeployedImageDigests,
32
38
  import { formatDnsRecordGroups, groupDnsRecords } from "../dns/groups.js";
33
39
  import { pickDnsAdapter } from "../dns/index.js";
34
40
  import { ensureGatewayRolePassword, findSqlInstance } from "../gateway-credentials.js";
35
- import { activeAccount, createProject, createServiceAccount, createServiceAccountKey, enableApis, gcloud, gcloudEnv, grantProvisionerRoles, linkBilling, listBillingAccounts, PROVISIONER_ROLE_LIST, projectExists, REQUIRED_API_LIST, serviceAccountExists, } from "../gcloud.js";
41
+ import { activeAccount, createProject, createServiceAccount, enableApis, gcloud, gcloudEnv, grantProvisionerRoles, linkBilling, listBillingAccounts, PROVISIONER_ROLE_LIST, projectExists, REQUIRED_API_LIST, serviceAccountExists, } from "../gcloud.js";
36
42
  import { GCP_STACK_ENV } from "../gcp-names.js";
37
- import { ensureInstallDir, installRoot, installStagingHost, isStepDone, markStepDone, readMetadata, readSecret, recordedImageDigests, recordImageDigests, writeMetadata, writeSecret, } from "../install-state.js";
43
+ import { adoptStateBackend, ensureInstallDir, installRoot, installStagingHost, isStepDone, markStepDone, readLocalMetadata, readMetadata, readSecret, recordedImageDigests, recordImageDigests, writeMetadata, writeSecret, } from "../install-state.js";
38
44
  import { ensureOperatorAccessSync, installIapAllowlist, resolveOperatorAccessTarget, } from "../operator-access.js";
45
+ import { ensureOpsBackend } from "../ops-backend.js";
46
+ import { convergeKeylessProvisioner, PROVISIONER_ACCOUNT_ID, provisionerServiceAccountEmail, } from "../provisioner-identity.js";
39
47
  import { decideRegion, detectCliRegion, installableRegions, suggestRegion } from "../regions.js";
40
- import { ABSENT_DEFAULT_FIREWALL_RULES, ADMIN_MEMORY_DEFAULT } from "../stack-contract.js";
48
+ import { selfCommand } from "../self-command.js";
49
+ import { ABSENT_DEFAULT_FIREWALL_RULES } from "../stack-contract.js";
50
+ import { chooseInstallBackend, describeBackend, gcsBackendRecord, hasLocalPulumiState, installBackend, parseStateBackend, resolveBackend, } from "../state-backend.js";
51
+ import { acquireStateLock, GcsStateStore } from "../state-store.js";
41
52
  import { estimateGcpCost } from "./gcp-cost.js";
42
- import { pulumiUpGcp, readStackOutput } from "./gcp-pulumi.js";
43
- const SA_ACCOUNT_ID = "caelo-provisioner";
53
+ import { GCP_STACK_KNOB_DEFAULTS, pulumiUpGcp, readStackOutput } from "./gcp-pulumi.js";
44
54
  export async function runGcpWizard(opts) {
45
55
  const { installId, domain, ownerEmail } = opts;
46
56
  const { secretsDir } = ensureInstallDir(installId);
@@ -66,7 +76,7 @@ export async function runGcpWizard(opts) {
66
76
  await stepBillingLink(installId, projectId, opts.nonInteractive);
67
77
  // === 5. Enable APIs ===
68
78
  await stepEnableApis(installId, projectId, opts.provider ?? "gcp");
69
- // === 6. Service account + roles + key ===
79
+ // === 6. Service account + roles + impersonation (no key, #696) ===
70
80
  // v0.3.12 — dropped the firebase-hosting-identity step. The
71
81
  // Service Usage GenerateServiceIdentity API doesn't support
72
82
  // `firebasehosting.googleapis.com` (returns
@@ -77,30 +87,17 @@ export async function runGcpWizard(opts) {
77
87
  // PROJECT level (vs the Cloud Run service level) — project IAM
78
88
  // accepts deferred google-managed principals; v1 Cloud Run IAM
79
89
  // didn't.
80
- const saEmail = `${SA_ACCOUNT_ID}@${projectId}.iam.gserviceaccount.com`;
90
+ const saEmail = provisionerServiceAccountEmail(projectId);
81
91
  await stepServiceAccount(installId, projectId, saEmail);
82
92
  await stepGrantRoles(installId, projectId, saEmail);
83
- const keyPath = await stepMintKey(installId, projectId, saEmail, secretsDir);
84
- // === 7. Pulumi passphrase ===
85
- // v0.3.2 — removed the legacy `stepAnthropicKey` prompt. The
86
- // runtime AI provider configuration lives at /settings/ai (the
87
- // key is encrypted under the project KEK and stored in
88
- // ai_providers). Pre-v0.3.2 the wizard captured a key + stored
89
- // it in Secret Manager but the Cloud Run service never read it,
90
- // so the prompt was dead UX cost.
91
- const pulumiPassphrase = stepPulumiPassphrase(installId);
93
+ await stepKeylessProvisioner(projectId, secretsDir, opts.nonInteractive);
92
94
  // === 8. Cost-estimate pre-flight ===
93
95
  // v0.3.3 — provider variant threads through so gcp-firebase
94
96
  // drops the LB / Cloud CDN / Cloud Armor lines (saves ~$19/mo
95
97
  // vs gcp). Default 'gcp' for backwards compatibility with
96
98
  // installs that don't set the field.
97
99
  const costInputs = {
98
- cloudSqlTier: "db-f1-micro",
99
- cloudSqlHa: false,
100
- adminMinInstances: 0,
101
- adminMemory: ADMIN_MEMORY_DEFAULT,
102
- gatewayMinInstances: 0,
103
- wafAdaptiveProtection: false,
100
+ ...GCP_STACK_KNOB_DEFAULTS,
104
101
  provider: opts.provider ?? "gcp",
105
102
  region,
106
103
  };
@@ -125,6 +122,11 @@ export async function runGcpWizard(opts) {
125
122
  process.exit(0);
126
123
  }
127
124
  }
125
+ // === 8.5. Where Pulumi's state lives (#696) ===
126
+ // A new install: the ops bucket + KMS key in its own project — after the
127
+ // cost confirmation, which lists them (CLAUDE.md §11.C: nothing billable
128
+ // before the y/N). An older install keeps its local state + passphrase.
129
+ const backend = await stepStateBackend(installId, projectId, region, saEmail);
128
130
  // === 9. Image digests to deploy ===
129
131
  // An install that already runs a recorded release (from an earlier
130
132
  // wizard run or from `upgrade`) keeps it: re-running the wizard converges
@@ -182,8 +184,8 @@ export async function runGcpWizard(opts) {
182
184
  }),
183
185
  ownerEmail,
184
186
  region,
185
- saKeyPath: keyPath,
186
- pulumiPassphrase,
187
+ impersonateServiceAccount: saEmail,
188
+ backend,
187
189
  cloudSqlTier: costInputs.cloudSqlTier,
188
190
  cloudSqlHa: costInputs.cloudSqlHa,
189
191
  adminMinInstances: costInputs.adminMinInstances,
@@ -328,7 +330,7 @@ async function stepActiveGcloud() {
328
330
  const account = await activeAccount();
329
331
  if (!account) {
330
332
  log.error(red("No active gcloud account."));
331
- log.warn(`Run ${bold("gcloud auth login")} in another terminal, then re-run ${bold("bunx @caelo-cms/provisioning")}.`);
333
+ log.warn(`Run ${bold("gcloud auth login")} in another terminal, then re-run ${bold(selfCommand())}.`);
332
334
  cancel("Aborted — no gcloud auth.");
333
335
  process.exit(2);
334
336
  }
@@ -473,7 +475,7 @@ async function stepBillingLink(installId, projectId, nonInteractive) {
473
475
  const open = accounts.filter((a) => a.open);
474
476
  if (open.length === 0) {
475
477
  log.error(red("No open billing accounts found."));
476
- log.warn(`Open one at https://console.cloud.google.com/billing then re-run ${bold("bunx @caelo-cms/provisioning")}.`);
478
+ log.warn(`Open one at https://console.cloud.google.com/billing then re-run ${bold(selfCommand())}.`);
477
479
  cancel("Aborted.");
478
480
  process.exit(1);
479
481
  }
@@ -543,7 +545,7 @@ async function stepEnableApis(installId, projectId, provider = "gcp") {
543
545
  markStepDone(installId, stepName, { count: apiCount });
544
546
  }
545
547
  async function stepServiceAccount(installId, projectId, saEmail) {
546
- const stepName = `service-account-${SA_ACCOUNT_ID}`;
548
+ const stepName = `service-account-${PROVISIONER_ACCOUNT_ID}`;
547
549
  if (isStepDone(installId, stepName)) {
548
550
  log.success(`Provisioner SA ${dim("(already created)")}`);
549
551
  return;
@@ -555,7 +557,7 @@ async function stepServiceAccount(installId, projectId, saEmail) {
555
557
  }
556
558
  const s = spinner();
557
559
  s.start(`Creating provisioner service account...`);
558
- const r = await createServiceAccount(projectId, SA_ACCOUNT_ID, "Caelo provisioner");
560
+ const r = await createServiceAccount(projectId, PROVISIONER_ACCOUNT_ID, "Caelo provisioner");
559
561
  if (!r.ok) {
560
562
  s.stop(red(`Failed: ${r.stderr.trim()}`));
561
563
  cancel("Aborted.");
@@ -658,24 +660,147 @@ async function stepRemoveDefaultFirewallRules(projectId) {
658
660
  for (const o of failed)
659
661
  log.warn(yellow(` not removed: ${o.id} — ${o.why}\n ${o.error ?? ""}`));
660
662
  }
661
- async function stepMintKey(installId, projectId, saEmail, secretsDir) {
662
- const stepName = `mint-key-${projectId}`;
663
- const keyPath = join(secretsDir, "sa-key.json");
664
- if (isStepDone(installId, stepName) && existsSync(keyPath)) {
665
- log.success(`SA key ${dim("(already minted, kept)")}`);
666
- return keyPath;
663
+ /**
664
+ * #696 — make the provisioner SA usable without a key: the operator's gcloud
665
+ * account gets TokenCreator on it, an older install's `sa-key.json` is
666
+ * deleted (locally and in IAM), and the credential chain Pulumi will use —
667
+ * Application Default Credentials impersonating the SA — is verified before
668
+ * anything billable runs. Idempotent; runs on every wizard pass.
669
+ */
670
+ async function stepKeylessProvisioner(projectId, secretsDir, nonInteractive) {
671
+ const sa = provisionerServiceAccountEmail(projectId);
672
+ const account = await activeAccount();
673
+ if (!account) {
674
+ cancel("No active gcloud account. Run `gcloud auth login`, then re-run.");
675
+ process.exit(2);
667
676
  }
668
677
  const s = spinner();
669
- s.start(`Minting SA key → ${dim(keyPath)}`);
670
- const r = await createServiceAccountKey(saEmail, keyPath);
671
- if (!r.ok) {
672
- s.stop(red(`Failed: ${r.stderr.trim()}`));
678
+ s.start(`Letting ${account} impersonate ${sa} (no key file)...`);
679
+ const converged = await convergeKeylessProvisioner({
680
+ projectId,
681
+ secretsDir,
682
+ operatorAccount: account,
683
+ justCreated: true,
684
+ });
685
+ if (!converged.ok) {
686
+ s.stop(red("Could not set up impersonation of the provisioner"));
687
+ log.error(converged.error);
688
+ cancel("Aborted.");
689
+ process.exit(1);
690
+ }
691
+ if (converged.grant === "no-provisioner") {
692
+ // stepServiceAccount ensured it and the lookup waited out propagation.
693
+ s.stop(red(`${sa} does not exist`));
694
+ cancel("Aborted — re-run the installer.");
695
+ process.exit(1);
696
+ }
697
+ s.stop(green(converged.grant === "granted"
698
+ ? `${account} may now impersonate ${sa}`
699
+ : `${account} may impersonate ${sa} ${dim("(already)")}`));
700
+ if (converged.legacyKey === "removed") {
701
+ log.warn(yellow("Removed the provisioner's long-lived key; this install now uses impersonation."));
702
+ for (const line of converged.lines)
703
+ log.info(` ${line}`);
704
+ }
705
+ const check = async () => checkAdcImpersonation({
706
+ projectId,
707
+ operatorAccount: account,
708
+ freshGrant: converged.grant === "granted",
709
+ });
710
+ const sAdc = spinner();
711
+ sAdc.start("Checking that your Application Default Credentials can impersonate it...");
712
+ let adc = await check();
713
+ if (!adc.ok && adc.reason === "no-adc" && !nonInteractive) {
714
+ sAdc.stop(yellow("No Application Default Credentials yet"));
715
+ const login = await confirm({
716
+ message: `Run ${bold("gcloud auth application-default login")} now (opens your browser; sign in as ${account})?`,
717
+ initialValue: true,
718
+ });
719
+ if (isCancel(login) || !login) {
720
+ log.error(adc.error);
721
+ cancel("Aborted.");
722
+ process.exit(1);
723
+ }
724
+ const { spawnSync } = await import("node:child_process");
725
+ spawnSync("gcloud", ["auth", "application-default", "login"], { stdio: "inherit" });
726
+ sAdc.start("Checking again...");
727
+ adc = await check();
728
+ }
729
+ if (!adc.ok) {
730
+ sAdc.stop(red("Infrastructure credentials not ready"));
731
+ log.error(adc.error);
673
732
  cancel("Aborted.");
674
733
  process.exit(1);
675
734
  }
676
- s.stop(green(`SA key minted (mode 600)`));
677
- markStepDone(installId, stepName, { path: keyPath });
678
- return keyPath;
735
+ sAdc.stop(green(`Pulumi will run as ${sa} via impersonation`));
736
+ }
737
+ /**
738
+ * Decide and prepare where the install's Pulumi state lives (#696,
739
+ * state-backend.ts chooseInstallBackend) and resolve it for Pulumi.
740
+ *
741
+ * For the ops bucket: ensures bucket + KMS key + IAM (idempotent, every
742
+ * run), then records the backend in install.json and moves install.json +
743
+ * progress.json into the bucket. A project whose bucket already holds
744
+ * another install's install.json is refused — this machine would overwrite
745
+ * that install's record; this install's own copy from an interrupted run is
746
+ * taken over.
747
+ */
748
+ async function stepStateBackend(installId, projectId, region, provisionerEmail) {
749
+ const root = installRoot(installId);
750
+ const meta = readMetadata(installId);
751
+ if (!meta)
752
+ throw new Error(`No install.json for install '${installId}'.`);
753
+ const kind = chooseInstallBackend(meta, hasLocalPulumiState(root), isStepDone(installId, `pulumi-up-${projectId}`));
754
+ if (kind === "local") {
755
+ stepPulumiPassphrase(installId);
756
+ log.info(`Pulumi state: ${dim(describeBackend(meta, root))} (this install predates state in project ${projectId}). Move it there, so any machine you sign in on can manage the install: ${bold(selfCommand("state", "migrate", "--install", installId))}`);
757
+ return resolveBackend(meta, {
758
+ installRoot: root,
759
+ readPassphrase: () => readSecret(installId, "pulumi-passphrase"),
760
+ });
761
+ }
762
+ const account = await activeAccount();
763
+ if (!account) {
764
+ cancel("No active gcloud account. Run `gcloud auth login`, then re-run.");
765
+ process.exit(1);
766
+ }
767
+ const s = spinner();
768
+ s.start(`Ensuring the install's state bucket + KMS key in ${projectId}...`);
769
+ const r = await ensureOpsBackend({
770
+ projectId,
771
+ region,
772
+ operatorAccount: account,
773
+ provisionerEmail,
774
+ });
775
+ if (!r.ok) {
776
+ s.stop(red("Could not set up where the install's state lives"));
777
+ log.error(r.error);
778
+ cancel("Aborted before anything else was provisioned; fix the above and re-run.");
779
+ process.exit(1);
780
+ }
781
+ s.stop(green(r.actions.length > 0
782
+ ? `State bucket + KMS key ready ${dim(`(${r.actions.length} change(s))`)}`
783
+ : `State bucket + KMS key ok ${dim("(already in place)")}`));
784
+ for (const a of r.actions)
785
+ log.info(dim(` ${a}`));
786
+ if (meta.stateBackend?.kind !== "gcs") {
787
+ const record = gcsBackendRecord(projectId, region);
788
+ const existing = new GcsStateStore(record.bucket, root).peek("install.json");
789
+ const other = existing === null ? null : JSON.parse(existing);
790
+ // The same install (id + creation time) is this machine's own earlier
791
+ // run, interrupted between writing the bucket and the local copy:
792
+ // finishing the move is safe. Anything else is another install.
793
+ if (other && !(other.installId === meta.installId && other.createdAt === meta.createdAt)) {
794
+ cancel(`Project ${projectId} already has a Caelo install (${other.domain ?? "unknown domain"}) whose state lives in gs://${record.bucket}. This run would overwrite that install's record. To manage it from this machine run: ${selfCommand("attach", "--project", projectId)}`);
795
+ process.exit(1);
796
+ }
797
+ adoptStateBackend(installId, record);
798
+ log.success(`Pulumi state + install metadata live in ${bold(describeBackend({ stateBackend: record }, root))}`);
799
+ }
800
+ const current = readMetadata(installId);
801
+ if (!current)
802
+ throw new Error(`install.json for '${installId}' vanished from its store.`);
803
+ return resolveBackend(current, { installRoot: root, readPassphrase: () => null });
679
804
  }
680
805
  function stepPulumiPassphrase(installId) {
681
806
  const existing = readSecret(installId, "pulumi-passphrase");
@@ -690,33 +815,77 @@ function stepPulumiPassphrase(installId) {
690
815
  }
691
816
  async function stepPulumiUp(installId, opts) {
692
817
  const stepName = `pulumi-up-${opts.projectId}`;
693
- if (isStepDone(installId, stepName)) {
818
+ const deployed = isStepDone(installId, stepName);
819
+ if (deployed) {
694
820
  log.success(`Pulumi up ${dim("(checkpointed — re-running for drift refresh)")}`);
695
821
  }
696
822
  const { secretsDir } = ensureInstallDir(installId);
697
823
  const root = installRoot(installId);
824
+ // #696 — hold the install's state lock through `pulumi up` (`state
825
+ // migrate` takes the same one), then re-check, uncached, that the state
826
+ // still lives where this run resolved it: the bucket's install.json for a
827
+ // bucket-backed install (another machine may have moved it back), the
828
+ // file on disk for a local one.
829
+ const opsBucket = gcsBackendRecord(opts.projectId, opts.region).bucket;
830
+ let releaseLock;
831
+ try {
832
+ releaseLock = acquireStateLock(opts.backend.kind === "gcs" ? { kind: "gcs", bucket: opsBucket } : { kind: "local" }, root, "installer pulumi up");
833
+ }
834
+ catch (e) {
835
+ cancel(e instanceof Error ? e.message : String(e));
836
+ process.exit(1);
837
+ }
838
+ let moved;
839
+ try {
840
+ const nowText = opts.backend.kind === "gcs"
841
+ ? new GcsStateStore(opsBucket, root).peek("install.json")
842
+ : JSON.stringify(readLocalMetadata(installId));
843
+ const now = nowText ? JSON.parse(nowText) : null;
844
+ moved =
845
+ !now ||
846
+ installBackend({ stateBackend: parseStateBackend(now.stateBackend) }).kind !==
847
+ opts.backend.kind;
848
+ }
849
+ catch (e) {
850
+ releaseLock();
851
+ throw e;
852
+ }
853
+ if (moved) {
854
+ releaseLock();
855
+ cancel("The install's Pulumi state has moved (state migrate) since this run started. Re-run the installer.");
856
+ process.exit(1);
857
+ }
698
858
  log.info(`Pulumi up — wall-clock 8–15 min (Cloud SQL is the long pole). Streaming progress...`);
699
859
  let resourceCount = 0;
700
- const result = await pulumiUpGcp({
701
- installId,
702
- installRoot: root,
703
- secretsDir,
704
- ...opts,
705
- provider: opts.provider ?? "gcp",
706
- }, (kind, message) => {
707
- if (kind === "resource") {
708
- resourceCount++;
709
- if (resourceCount % 5 === 0) {
710
- process.stdout.write(`\r${dim(` ${resourceCount} resources updated`)}`);
860
+ let result;
861
+ try {
862
+ result = await pulumiUpGcp({
863
+ installId,
864
+ installRoot: root,
865
+ secretsDir,
866
+ ...opts,
867
+ // A deployed install's stack must exist: a missing one means the
868
+ // state is elsewhere, and a fresh stack would try to recreate it all.
869
+ stackMustExist: deployed,
870
+ provider: opts.provider ?? "gcp",
871
+ }, (kind, message) => {
872
+ if (kind === "resource") {
873
+ resourceCount++;
874
+ if (resourceCount % 5 === 0) {
875
+ process.stdout.write(`\r${dim(` ${resourceCount} resources updated`)}`);
876
+ }
711
877
  }
712
- }
713
- else if (kind === "error") {
714
- process.stdout.write("\n");
715
- log.error(red(redactCredentials(message)));
716
- }
717
- // logs: silent — too noisy for a live progress UI; pulumi
718
- // diagnostics will print on failure via the SDK's onOutput.
719
- });
878
+ else if (kind === "error") {
879
+ process.stdout.write("\n");
880
+ log.error(red(redactCredentials(message)));
881
+ }
882
+ // logs: silent — too noisy for a live progress UI; pulumi
883
+ // diagnostics will print on failure via the SDK's onOutput.
884
+ });
885
+ }
886
+ finally {
887
+ releaseLock();
888
+ }
720
889
  process.stdout.write("\n");
721
890
  log.success(green(`Pulumi up complete — ${result.resourceCount.created} created, ${result.resourceCount.updated} updated, ${result.resourceCount.deleted} deleted`));
722
891
  markStepDone(installId, stepName, { outputs: result.outputs });
@@ -1068,13 +1237,14 @@ async function stepUploadStaticPlaceholder(installId, projectId, domain) {
1068
1237
  * re-run picks it up.
1069
1238
  */
1070
1239
  async function readBootstrapUrl(installId, provider) {
1071
- const passphrase = readSecret(installId, "pulumi-passphrase");
1072
- if (!passphrase) {
1073
- throw new Error(`~/.caelo-${installId}/secrets/pulumi-passphrase is missing; the Owner setup link can't be read from the stack.`);
1074
- }
1240
+ const meta = readMetadata(installId);
1241
+ if (!meta)
1242
+ throw new Error(`No install.json for install '${installId}'.`);
1075
1243
  const value = await readStackOutput({
1076
- installRoot: installRoot(installId),
1077
- pulumiPassphrase: passphrase,
1244
+ backend: resolveBackend(meta, {
1245
+ installRoot: installRoot(installId),
1246
+ readPassphrase: () => readSecret(installId, "pulumi-passphrase"),
1247
+ }),
1078
1248
  provider: provider === "gcp-firebase" ? "gcp-firebase" : "gcp",
1079
1249
  name: "bootstrapUrlOut",
1080
1250
  });
@@ -1127,15 +1297,13 @@ async function stepGcpDnsRecords(installId) {
1127
1297
  }
1128
1298
  catch (e) {
1129
1299
  log.warn(yellow(`DNS records not confirmed yet: ${e instanceof Error ? e.message : String(e)}`));
1130
- log.warn(`Set them at your registrar, then re-run ${bold("bunx @caelo-cms/provisioning")} to resume.`);
1300
+ log.warn(`Set them at your registrar, then re-run ${bold(selfCommand())} to resume.`);
1131
1301
  cancel("Stopped at the DNS step.");
1132
1302
  process.exit(1);
1133
1303
  }
1134
1304
  }
1135
1305
  async function stepFinalize(installId) {
1136
1306
  const meta = readMetadata(installId);
1137
- const progress = readSecret(installId, "pulumi-passphrase"); // touch to verify state still present
1138
- void progress;
1139
1307
  if (!meta?.projectId)
1140
1308
  return;
1141
1309
  const outputs = await recordedStackOutputs(installId, meta.projectId);
@@ -1179,12 +1347,12 @@ async function stepFinalize(installId) {
1179
1347
  bold("Admin custom domain (admin.<domain>):"),
1180
1348
  ` Not configured — using the Cloud Run URL above.`,
1181
1349
  ` To bind ${cyan(`admin.${meta.domain}`)}, run`,
1182
- ` ${dim("bunx @caelo-cms/provisioning admin-domain enable")}`,
1350
+ ` ${dim(selfCommand("admin-domain", "enable"))}`,
1183
1351
  ` (it walks you through the one-time Search Console verification, prints the DNS record and waits for the certificate).`,
1184
1352
  "",
1185
1353
  bold("Protected staging site:"),
1186
1354
  ` Stage builds render ${cyan(installStagingHost(meta))}; to serve it, run`,
1187
- ` ${dim("bunx @caelo-cms/provisioning staging-domain enable")}`,
1355
+ ` ${dim(selfCommand("staging-domain", "enable"))}`,
1188
1356
  ` It maps the host, turns on password protection and prints the staging login once;`,
1189
1357
  ` add the DNS record it prints. The host serves pages after the first Stage.`,
1190
1358
  ]
@@ -1206,9 +1374,9 @@ async function stepFinalize(installId) {
1206
1374
  ...adminNote,
1207
1375
  "",
1208
1376
  bold("Lifecycle commands:"),
1209
- ` ${dim("bunx @caelo-cms/provisioning status")} — health check + monthly cost`,
1210
- ` ${dim("bunx @caelo-cms/provisioning upgrade")} — pull latest images + roll Cloud Run`,
1211
- ` ${dim("bunx @caelo-cms/provisioning destroy")} — tear everything down (irreversible)`,
1377
+ ` ${dim(selfCommand("status"))} — health check, and the exact upgrade command once a new release is out`,
1378
+ ` ${dim(selfCommand("destroy"))} — tear everything down (irreversible)`,
1379
+ ` New releases: ${cyan(`${adminBase}/settings/updates`)} shows the exact upgrade command for this install.`,
1212
1380
  ].join("\n"), "Done");
1213
1381
  void homedir; // unused-import guard
1214
1382
  void readFileSync;