@caelo-cms/provisioning 0.10.46 → 0.10.48

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 (342) hide show
  1. package/dist/adc-impersonation.d.ts.map +1 -1
  2. package/dist/adc-impersonation.js +23 -1
  3. package/dist/adc-impersonation.js.map +1 -1
  4. package/dist/agent/apply.d.ts +45 -0
  5. package/dist/agent/apply.d.ts.map +1 -0
  6. package/dist/agent/apply.js +83 -0
  7. package/dist/agent/apply.js.map +1 -0
  8. package/dist/agent/cli-provenance.d.ts +42 -0
  9. package/dist/agent/cli-provenance.d.ts.map +1 -0
  10. package/dist/agent/cli-provenance.js +138 -0
  11. package/dist/agent/cli-provenance.js.map +1 -0
  12. package/dist/agent/command.d.ts +79 -0
  13. package/dist/agent/command.d.ts.map +1 -0
  14. package/dist/agent/command.js +454 -0
  15. package/dist/agent/command.js.map +1 -0
  16. package/dist/agent/config.d.ts +30 -0
  17. package/dist/agent/config.d.ts.map +1 -0
  18. package/dist/agent/config.js +60 -0
  19. package/dist/agent/config.js.map +1 -0
  20. package/dist/agent/lock.d.ts +13 -0
  21. package/dist/agent/lock.d.ts.map +1 -0
  22. package/dist/agent/lock.js +100 -0
  23. package/dist/agent/lock.js.map +1 -0
  24. package/dist/agent/queue.d.ts +35 -0
  25. package/dist/agent/queue.d.ts.map +1 -0
  26. package/dist/agent/queue.js +50 -0
  27. package/dist/agent/queue.js.map +1 -0
  28. package/dist/agent/run.d.ts +97 -0
  29. package/dist/agent/run.d.ts.map +1 -0
  30. package/dist/agent/run.js +198 -0
  31. package/dist/agent/run.js.map +1 -0
  32. package/dist/agent/systemd.d.ts +55 -0
  33. package/dist/agent/systemd.d.ts.map +1 -0
  34. package/dist/agent/systemd.js +171 -0
  35. package/dist/agent/systemd.js.map +1 -0
  36. package/dist/agent/units.d.ts +44 -0
  37. package/dist/agent/units.d.ts.map +1 -0
  38. package/dist/agent/units.js +127 -0
  39. package/dist/agent/units.js.map +1 -0
  40. package/dist/agent-db.js +18362 -0
  41. package/dist/cli.js +122 -16
  42. package/dist/cli.js.map +1 -1
  43. package/dist/cloudsql-iam-user.d.ts +39 -0
  44. package/dist/cloudsql-iam-user.d.ts.map +1 -0
  45. package/dist/cloudsql-iam-user.js +119 -0
  46. package/dist/cloudsql-iam-user.js.map +1 -0
  47. package/dist/compose.d.ts +16 -0
  48. package/dist/compose.d.ts.map +1 -1
  49. package/dist/compose.js +26 -1
  50. package/dist/compose.js.map +1 -1
  51. package/dist/dns/index.d.ts +2 -0
  52. package/dist/dns/index.d.ts.map +1 -1
  53. package/dist/dns/index.js +3 -2
  54. package/dist/dns/index.js.map +1 -1
  55. package/dist/dns/manual.d.ts +5 -0
  56. package/dist/dns/manual.d.ts.map +1 -1
  57. package/dist/dns/manual.js +28 -12
  58. package/dist/dns/manual.js.map +1 -1
  59. package/dist/gcloud.d.ts +2 -0
  60. package/dist/gcloud.d.ts.map +1 -1
  61. package/dist/gcloud.js +1 -1
  62. package/dist/gcloud.js.map +1 -1
  63. package/dist/gcp-names.d.ts +15 -0
  64. package/dist/gcp-names.d.ts.map +1 -1
  65. package/dist/gcp-names.js +23 -0
  66. package/dist/gcp-names.js.map +1 -1
  67. package/dist/index.d.ts +6 -0
  68. package/dist/index.d.ts.map +1 -1
  69. package/dist/index.js +4 -0
  70. package/dist/index.js.map +1 -1
  71. package/dist/infra-contract.d.ts +1 -1
  72. package/dist/infra-contract.js +7 -1
  73. package/dist/infra-contract.js.map +1 -1
  74. package/dist/install-job-runner.d.ts +13 -0
  75. package/dist/install-job-runner.d.ts.map +1 -0
  76. package/dist/install-job-runner.js +155 -0
  77. package/dist/install-job-runner.js.map +1 -0
  78. package/dist/install-job.d.ts +210 -0
  79. package/dist/install-job.d.ts.map +1 -0
  80. package/dist/install-job.js +449 -0
  81. package/dist/install-job.js.map +1 -0
  82. package/dist/install-state.d.ts +19 -1
  83. package/dist/install-state.d.ts.map +1 -1
  84. package/dist/install-state.js +31 -3
  85. package/dist/install-state.js.map +1 -1
  86. package/dist/lifecycle.d.ts.map +1 -1
  87. package/dist/lifecycle.js +24 -5
  88. package/dist/lifecycle.js.map +1 -1
  89. package/dist/migration-runner.d.ts +5 -0
  90. package/dist/migration-runner.d.ts.map +1 -1
  91. package/dist/migration-runner.js +1 -1
  92. package/dist/migration-runner.js.map +1 -1
  93. package/dist/operator-access.d.ts +14 -8
  94. package/dist/operator-access.d.ts.map +1 -1
  95. package/dist/operator-access.js +16 -102
  96. package/dist/operator-access.js.map +1 -1
  97. package/dist/run-mode.d.ts +27 -0
  98. package/dist/run-mode.d.ts.map +1 -0
  99. package/dist/run-mode.js +22 -0
  100. package/dist/run-mode.js.map +1 -0
  101. package/dist/runtime-secrets.d.ts +19 -0
  102. package/dist/runtime-secrets.d.ts.map +1 -1
  103. package/dist/runtime-secrets.js +44 -0
  104. package/dist/runtime-secrets.js.map +1 -1
  105. package/dist/self-hosted-roles.d.ts +22 -1
  106. package/dist/self-hosted-roles.d.ts.map +1 -1
  107. package/dist/self-hosted-roles.js +81 -12
  108. package/dist/self-hosted-roles.js.map +1 -1
  109. package/dist/self-hosted-upgrade.d.ts +17 -0
  110. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  111. package/dist/self-hosted-upgrade.js +16 -1
  112. package/dist/self-hosted-upgrade.js.map +1 -1
  113. package/dist/stack-contract.d.ts +6 -0
  114. package/dist/stack-contract.d.ts.map +1 -1
  115. package/dist/stack-contract.js +10 -1
  116. package/dist/stack-contract.js.map +1 -1
  117. package/dist/state-attach.d.ts +23 -0
  118. package/dist/state-attach.d.ts.map +1 -0
  119. package/dist/state-attach.js +112 -0
  120. package/dist/state-attach.js.map +1 -0
  121. package/dist/state-backend.d.ts +11 -0
  122. package/dist/state-backend.d.ts.map +1 -1
  123. package/dist/state-backend.js +20 -0
  124. package/dist/state-backend.js.map +1 -1
  125. package/dist/state-command.d.ts +20 -0
  126. package/dist/state-command.d.ts.map +1 -0
  127. package/dist/state-command.js +237 -0
  128. package/dist/state-command.js.map +1 -0
  129. package/dist/state-migrate.d.ts +121 -0
  130. package/dist/state-migrate.d.ts.map +1 -0
  131. package/dist/state-migrate.js +290 -0
  132. package/dist/state-migrate.js.map +1 -0
  133. package/dist/state-pulumi.d.ts +17 -0
  134. package/dist/state-pulumi.d.ts.map +1 -0
  135. package/dist/state-pulumi.js +218 -0
  136. package/dist/state-pulumi.js.map +1 -0
  137. package/dist/state-store.d.ts +27 -0
  138. package/dist/state-store.d.ts.map +1 -1
  139. package/dist/state-store.js +65 -0
  140. package/dist/state-store.js.map +1 -1
  141. package/dist/upgrade/converge.d.ts +6 -0
  142. package/dist/upgrade/converge.d.ts.map +1 -1
  143. package/dist/upgrade/converge.js +59 -2
  144. package/dist/upgrade/converge.js.map +1 -1
  145. package/dist/upgrade/gcp-lookup.d.ts +6 -0
  146. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  147. package/dist/upgrade/gcp-lookup.js +25 -0
  148. package/dist/upgrade/gcp-lookup.js.map +1 -1
  149. package/dist/upgrade/release-images.d.ts +12 -0
  150. package/dist/upgrade/release-images.d.ts.map +1 -1
  151. package/dist/upgrade/release-images.js +31 -0
  152. package/dist/upgrade/release-images.js.map +1 -1
  153. package/dist/upgrade/upgrade.d.ts +5 -0
  154. package/dist/upgrade/upgrade.d.ts.map +1 -1
  155. package/dist/upgrade/upgrade.js +28 -1
  156. package/dist/upgrade/upgrade.js.map +1 -1
  157. package/dist/upgrader-assess.d.ts +82 -0
  158. package/dist/upgrader-assess.d.ts.map +1 -0
  159. package/dist/upgrader-assess.js +116 -0
  160. package/dist/upgrader-assess.js.map +1 -0
  161. package/dist/upgrader-gcp.d.ts +17 -0
  162. package/dist/upgrader-gcp.d.ts.map +1 -0
  163. package/dist/upgrader-gcp.js +87 -0
  164. package/dist/upgrader-gcp.js.map +1 -0
  165. package/dist/upgrader-job.d.ts +131 -0
  166. package/dist/upgrader-job.d.ts.map +1 -0
  167. package/dist/upgrader-job.js +259 -0
  168. package/dist/upgrader-job.js.map +1 -0
  169. package/dist/upgrader-reporter.d.ts +35 -0
  170. package/dist/upgrader-reporter.d.ts.map +1 -0
  171. package/dist/upgrader-reporter.js +79 -0
  172. package/dist/upgrader-reporter.js.map +1 -0
  173. package/dist/upgrader-setup.d.ts +59 -0
  174. package/dist/upgrader-setup.d.ts.map +1 -0
  175. package/dist/upgrader-setup.js +308 -0
  176. package/dist/upgrader-setup.js.map +1 -0
  177. package/dist/wizard.d.ts +6 -0
  178. package/dist/wizard.d.ts.map +1 -1
  179. package/dist/wizard.js +32 -4
  180. package/dist/wizard.js.map +1 -1
  181. package/dist/wizards/gcp-firebase.d.ts +3 -0
  182. package/dist/wizards/gcp-firebase.d.ts.map +1 -1
  183. package/dist/wizards/gcp-firebase.js +1 -0
  184. package/dist/wizards/gcp-firebase.js.map +1 -1
  185. package/dist/wizards/gcp-install-job.d.ts +74 -0
  186. package/dist/wizards/gcp-install-job.d.ts.map +1 -0
  187. package/dist/wizards/gcp-install-job.js +398 -0
  188. package/dist/wizards/gcp-install-job.js.map +1 -0
  189. package/dist/wizards/gcp-prompts.d.ts +3 -0
  190. package/dist/wizards/gcp-prompts.d.ts.map +1 -0
  191. package/dist/wizards/gcp-prompts.js +25 -0
  192. package/dist/wizards/gcp-prompts.js.map +1 -0
  193. package/dist/wizards/gcp-pulumi.d.ts +9 -2
  194. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  195. package/dist/wizards/gcp-pulumi.js +8 -6
  196. package/dist/wizards/gcp-pulumi.js.map +1 -1
  197. package/dist/wizards/gcp.d.ts +40 -0
  198. package/dist/wizards/gcp.d.ts.map +1 -1
  199. package/dist/wizards/gcp.js +291 -83
  200. package/dist/wizards/gcp.js.map +1 -1
  201. package/package.json +6 -4
  202. package/src/adapter.ts +97 -0
  203. package/src/adc-impersonation.ts +176 -0
  204. package/src/admin-domain.test.ts +254 -0
  205. package/src/admin-domain.ts +245 -0
  206. package/src/admin-memory.test.ts +151 -0
  207. package/src/agent/apply.test.ts +185 -0
  208. package/src/agent/apply.ts +124 -0
  209. package/src/agent/cli-provenance.test.ts +159 -0
  210. package/src/agent/cli-provenance.ts +191 -0
  211. package/src/agent/command.test.ts +77 -0
  212. package/src/agent/command.ts +564 -0
  213. package/src/agent/config.ts +86 -0
  214. package/src/agent/lock-config.test.ts +97 -0
  215. package/src/agent/lock.ts +110 -0
  216. package/src/agent/queue.test.ts +10 -0
  217. package/src/agent/queue.ts +91 -0
  218. package/src/agent/run.test.ts +382 -0
  219. package/src/agent/run.ts +295 -0
  220. package/src/agent/systemd.ts +209 -0
  221. package/src/agent/units.test.ts +223 -0
  222. package/src/agent/units.ts +163 -0
  223. package/src/azure-front-door.test.ts +119 -0
  224. package/src/azure-front-door.ts +56 -0
  225. package/src/bootstrap-token.ts +20 -0
  226. package/src/caddy.ts +112 -0
  227. package/src/cdn-copy.ts +91 -0
  228. package/src/cert-wait.test.ts +194 -0
  229. package/src/cert-wait.ts +163 -0
  230. package/src/checkpoint-secrets.test.ts +380 -0
  231. package/src/checkpoint-secrets.ts +283 -0
  232. package/src/cli-routing.test.ts +45 -0
  233. package/src/cli-routing.ts +43 -0
  234. package/src/cli.ts +1491 -0
  235. package/src/cloudsql-iam-user.ts +147 -0
  236. package/src/compose.ts +334 -0
  237. package/src/default-firewall.test.ts +411 -0
  238. package/src/default-firewall.ts +342 -0
  239. package/src/deployed-release.test.ts +231 -0
  240. package/src/deployed-release.ts +220 -0
  241. package/src/dns/cloudflare.ts +182 -0
  242. package/src/dns/groups.test.ts +76 -0
  243. package/src/dns/groups.ts +86 -0
  244. package/src/dns/index.ts +56 -0
  245. package/src/dns/manual.ts +130 -0
  246. package/src/dns/types.ts +27 -0
  247. package/src/gateway-credentials.test.ts +180 -0
  248. package/src/gateway-credentials.ts +144 -0
  249. package/src/gcloud-components.test.ts +76 -0
  250. package/src/gcloud-components.ts +85 -0
  251. package/src/gcloud-retry.ts +41 -0
  252. package/src/gcloud-runner.test.ts +61 -0
  253. package/src/gcloud.test.ts +99 -0
  254. package/src/gcloud.ts +322 -0
  255. package/src/gcp-names.ts +135 -0
  256. package/src/index.test.ts +332 -0
  257. package/src/index.ts +114 -0
  258. package/src/infra-contract.ts +32 -0
  259. package/src/install-job-runner.ts +166 -0
  260. package/src/install-job.test.ts +566 -0
  261. package/src/install-job.ts +542 -0
  262. package/src/install-state.test.ts +239 -0
  263. package/src/install-state.ts +583 -0
  264. package/src/internal-token.ts +35 -0
  265. package/src/lifecycle.ts +900 -0
  266. package/src/mcp-iap-stacks.test.ts +58 -0
  267. package/src/mcp-iap.test.ts +149 -0
  268. package/src/mcp-iap.ts +131 -0
  269. package/src/media-volume.test.ts +296 -0
  270. package/src/migration-runner.ts +365 -0
  271. package/src/operator-access-stacks.test.ts +55 -0
  272. package/src/operator-access.test.ts +332 -0
  273. package/src/operator-access.ts +554 -0
  274. package/src/ops-backend.test.ts +320 -0
  275. package/src/ops-backend.ts +414 -0
  276. package/src/provisioner-identity.test.ts +482 -0
  277. package/src/provisioner-identity.ts +407 -0
  278. package/src/redirects-emit.ts +175 -0
  279. package/src/region-data.ts +428 -0
  280. package/src/regions.test.ts +277 -0
  281. package/src/regions.ts +370 -0
  282. package/src/release-contract.ts +57 -0
  283. package/src/release-manifest.test.ts +293 -0
  284. package/src/release-manifest.ts +430 -0
  285. package/src/run-mode.ts +40 -0
  286. package/src/runtime-secrets.test.ts +627 -0
  287. package/src/runtime-secrets.ts +754 -0
  288. package/src/self-command.test.ts +14 -0
  289. package/src/self-command.ts +21 -0
  290. package/src/self-hosted-compose.test.ts +177 -0
  291. package/src/self-hosted-release.ts +240 -0
  292. package/src/self-hosted-roles.integration.test.ts +161 -0
  293. package/src/self-hosted-roles.test.ts +124 -0
  294. package/src/self-hosted-roles.ts +203 -0
  295. package/src/self-hosted-upgrade.test.ts +394 -0
  296. package/src/self-hosted-upgrade.ts +548 -0
  297. package/src/stack-contract-parity.test.ts +199 -0
  298. package/src/stack-contract.test.ts +391 -0
  299. package/src/stack-contract.ts +888 -0
  300. package/src/stack-converge-secrets.test.ts +421 -0
  301. package/src/stack-converge.test.ts +833 -0
  302. package/src/stack-converge.ts +909 -0
  303. package/src/staging-admin.test.ts +246 -0
  304. package/src/staging-admin.ts +324 -0
  305. package/src/state-attach.test.ts +211 -0
  306. package/src/state-attach.ts +132 -0
  307. package/src/state-backend.test.ts +145 -0
  308. package/src/state-backend.ts +262 -0
  309. package/src/state-command.ts +293 -0
  310. package/src/state-migrate.test.ts +479 -0
  311. package/src/state-migrate.ts +434 -0
  312. package/src/state-pulumi.ts +289 -0
  313. package/src/state-store.test.ts +212 -0
  314. package/src/state-store.ts +243 -0
  315. package/src/upgrade/app-roll.ts +486 -0
  316. package/src/upgrade/backup.ts +109 -0
  317. package/src/upgrade/converge.ts +437 -0
  318. package/src/upgrade/gcp-lookup.ts +167 -0
  319. package/src/upgrade/health.test.ts +279 -0
  320. package/src/upgrade/health.ts +305 -0
  321. package/src/upgrade/release-images.test.ts +119 -0
  322. package/src/upgrade/release-images.ts +391 -0
  323. package/src/upgrade/reporter.ts +46 -0
  324. package/src/upgrade/upgrade.test.ts +626 -0
  325. package/src/upgrade/upgrade.ts +249 -0
  326. package/src/upgrader-assess.test.ts +265 -0
  327. package/src/upgrader-assess.ts +233 -0
  328. package/src/upgrader-gcp.ts +123 -0
  329. package/src/upgrader-job.test.ts +408 -0
  330. package/src/upgrader-job.ts +499 -0
  331. package/src/upgrader-reporter.test.ts +46 -0
  332. package/src/upgrader-reporter.ts +105 -0
  333. package/src/upgrader-setup.test.ts +169 -0
  334. package/src/upgrader-setup.ts +355 -0
  335. package/src/wizard.ts +425 -0
  336. package/src/wizards/gcp-cost.ts +188 -0
  337. package/src/wizards/gcp-firebase.ts +72 -0
  338. package/src/wizards/gcp-install-job.ts +506 -0
  339. package/src/wizards/gcp-prompts.ts +26 -0
  340. package/src/wizards/gcp-pulumi.test.ts +246 -0
  341. package/src/wizards/gcp-pulumi.ts +516 -0
  342. package/src/wizards/gcp.ts +1935 -0
@@ -0,0 +1,434 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * `cms-provision state migrate` (#696): move an existing install's Pulumi
5
+ * state, secrets provider and install metadata between this machine
6
+ * (`~/.caelo-<id>/state` + passphrase) and the install's own GCP project
7
+ * (`gs://<project>-caelo-ops` + Cloud KMS), in either direction.
8
+ *
9
+ * The source stays authoritative until the very end:
10
+ *
11
+ * 1. preflight — Pulumi CLI version (`pulumi stack migrate` from
12
+ * {@link STACK_MIGRATE_MIN_VERSION}, else export/import +
13
+ * change-secrets-provider), the source stack exists, has no pending
14
+ * operations, and a preview of it shows no changes (an unconverged
15
+ * stack can't prove the copy is identical, so it is refused);
16
+ * 2. a mode-600 archive of `~/.caelo-<id>/` (tar.zst, else tar.gz);
17
+ * 3. the target: bucket + KMS key ensured (remote) or an empty local
18
+ * state dir + passphrase (local); a leftover copy from an interrupted
19
+ * run is removed — it was never authoritative;
20
+ * 4. copy, re-encrypting the secrets for the target's provider;
21
+ * 5. verify — a preview of the target shows no changes and its outputs
22
+ * (secrets included) hash identically to the source's; on any
23
+ * mismatch the target copy is removed and nothing else changes;
24
+ * 6. flip — install.json records the new backend (locally and in the
25
+ * bucket); the old local state dir is renamed, never deleted.
26
+ *
27
+ * `--dry-run` runs the read-only preflight and reports what it would do.
28
+ * The engine takes its side effects as {@link MigrateDeps} so the whole
29
+ * sequence is tested against fakes (state-migrate.test.ts); state-command.ts
30
+ * wires the real Pulumi CLI, gcloud and file system.
31
+ */
32
+
33
+ import type { OpsBackendResult } from "./ops-backend.js";
34
+ import { selfCommand } from "./self-command.js";
35
+ import {
36
+ describeBackend,
37
+ type GcsBackendRecord,
38
+ gcsBackendRecord,
39
+ installBackend,
40
+ type ResolvedBackend,
41
+ type StateBackendRecord,
42
+ } from "./state-backend.js";
43
+ import type { UpgradeReporter } from "./upgrade/reporter.js";
44
+
45
+ /** First Pulumi CLI release with `pulumi stack migrate` between DIY backends. */
46
+ export const STACK_MIGRATE_MIN_VERSION = "3.254.0";
47
+
48
+ /** What the ops bucket + KMS key cost (wizards/gcp-cost.ts carries the same line). */
49
+ export const OPS_BACKEND_COST_NOTE =
50
+ "Creates (if missing) a versioned Cloud Storage bucket + one Cloud KMS key in the install's project: < $0.10/mo (KMS key version $0.06/mo, a few MB of state).";
51
+
52
+ /** The Pulumi operations a migration needs, against one backend at a time. */
53
+ export interface StatePulumiOps {
54
+ /** `pulumi version` without the `v`, or null when the CLI is missing. */
55
+ cliVersion(): Promise<string | null>;
56
+ stackExists(backend: ResolvedBackend): Promise<boolean>;
57
+ /** Pending operations recorded in the stack's state (an interrupted run). */
58
+ pendingOperations(backend: ResolvedBackend): Promise<number>;
59
+ /** Steps a preview would take other than "same". */
60
+ previewChanges(backend: ResolvedBackend): Promise<number>;
61
+ /** sha256 of the stack outputs, secrets included. Never the values. */
62
+ outputsDigest(backend: ResolvedBackend): Promise<string>;
63
+ /** Whether a Pulumi operation holds the stack's lock in `backend` right now. */
64
+ locked(backend: ResolvedBackend): Promise<boolean>;
65
+ /** sha256 of the stack's exported checkpoint (ciphertext; never decrypted). */
66
+ stateDigest(backend: ResolvedBackend): Promise<string>;
67
+ /** `pulumi stack migrate <from> --secrets-provider <to's>` into `to`. */
68
+ stackMigrate(from: ResolvedBackend, to: ResolvedBackend): Promise<void>;
69
+ /** export → init → import → change-secrets-provider (older CLIs). */
70
+ exportImport(from: ResolvedBackend, to: ResolvedBackend): Promise<void>;
71
+ /** Remove the stack's state from `backend` (cloud resources untouched). */
72
+ removeStack(backend: ResolvedBackend): Promise<void>;
73
+ }
74
+
75
+ /** Everything the migration touches outside Pulumi. */
76
+ export interface MigrateDeps {
77
+ readonly pulumi: StatePulumiOps;
78
+ readonly report: UpgradeReporter;
79
+ /** The install's backend resolved for Pulumi. */
80
+ resolve(record: StateBackendRecord): ResolvedBackend;
81
+ /** Ask the operator to accept the ops backend's cost; false = cancel. */
82
+ confirmCost(note: string): Promise<boolean>;
83
+ /** Ensure (or, dry, inspect) the ops bucket + KMS key. */
84
+ ensureRemote(dryRun: boolean): Promise<OpsBackendResult>;
85
+ /** Archive `~/.caelo-<id>/`; returns the archive's path. */
86
+ backup(): Promise<string>;
87
+ /** Whether the local state dir is absent or holds no stack. */
88
+ localStateEmpty(): boolean;
89
+ /** Create the local state dir (mode 700) and `secrets/pulumi-passphrase` if missing. */
90
+ prepareLocalTarget(): void;
91
+ /**
92
+ * The backend the bucket's install.json names (`null` when the bucket or
93
+ * the document doesn't exist). `gcs` there means the bucket copy is
94
+ * authoritative — some machine finished a migration — and must never be
95
+ * replaced from this machine's (then stale) local state.
96
+ */
97
+ remoteRecordedBackend(): StateBackendRecord["kind"] | null;
98
+ /** Record the new backend (install-state.ts adoptStateBackend). */
99
+ adopt(record: StateBackendRecord): void;
100
+ /**
101
+ * Take the install's state lock (state-store.ts acquireStateLock: the
102
+ * source's, plus the bucket's when it is the target), which installer
103
+ * runs hold through `pulumi up` too; throws when another run holds it.
104
+ * Returns the release function.
105
+ */
106
+ lock(): Promise<() => Promise<void>>;
107
+ /** Rename the local state dir out of the way; returns where it went. */
108
+ retireLocalState(): string | null;
109
+ }
110
+
111
+ export interface MigrateOptions {
112
+ readonly to: "remote" | "local";
113
+ readonly dryRun: boolean;
114
+ }
115
+
116
+ export type MigrateResult =
117
+ | { readonly ok: true; readonly changed: boolean }
118
+ | { readonly ok: false; readonly error: string };
119
+
120
+ /** The subset of install metadata a migration reads. */
121
+ export interface MigrateInstall {
122
+ readonly installId: string;
123
+ readonly provider: string;
124
+ readonly projectId: string | null;
125
+ readonly region: string | null;
126
+ readonly stateBackend?: StateBackendRecord;
127
+ }
128
+
129
+ /** a >= b for dotted numeric versions (`3.254.0`). */
130
+ export function versionAtLeast(a: string, b: string): boolean {
131
+ const pa = a.replace(/^v/, "").split(/[.+-]/).map(Number);
132
+ const pb = b.replace(/^v/, "").split(".").map(Number);
133
+ for (let i = 0; i < 3; i++) {
134
+ const x = pa[i] ?? 0;
135
+ const y = pb[i] ?? 0;
136
+ if (Number.isNaN(x)) return false;
137
+ if (x !== y) return x > y;
138
+ }
139
+ return true;
140
+ }
141
+
142
+ const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
143
+
144
+ /**
145
+ * Run the migration. Never throws for an expected failure: the result says
146
+ * what failed, and at every failure point the source backend is still the
147
+ * one install.json names.
148
+ */
149
+ export async function migrateState(
150
+ meta: MigrateInstall,
151
+ opts: MigrateOptions,
152
+ deps: MigrateDeps,
153
+ ): Promise<MigrateResult> {
154
+ const { report, pulumi } = deps;
155
+ if (meta.provider !== "gcp" && meta.provider !== "gcp-firebase") {
156
+ return {
157
+ ok: false,
158
+ error: `${meta.provider} installs keep their state on the host; there is nothing to migrate.`,
159
+ };
160
+ }
161
+ if (!meta.projectId || !meta.region) {
162
+ return {
163
+ ok: false,
164
+ error: `install.json of ${meta.installId} has no projectId/region; re-run the installer first.`,
165
+ };
166
+ }
167
+ const projectId = meta.projectId;
168
+ const current = installBackend(meta);
169
+ const wantKind = opts.to === "remote" ? "gcs" : "local";
170
+ if (current.kind === wantKind) {
171
+ report.success(
172
+ `Already ${opts.to}: ${describeBackend(meta, `~/.caelo-${meta.installId}`)}. Nothing to do.`,
173
+ );
174
+ return { ok: true, changed: false };
175
+ }
176
+ const remoteRecord: GcsBackendRecord =
177
+ current.kind === "gcs" ? current : gcsBackendRecord(meta.projectId, meta.region);
178
+ const targetRecord: StateBackendRecord = opts.to === "remote" ? remoteRecord : { kind: "local" };
179
+
180
+ // --- 1. Preflight (read-only) ------------------------------------------
181
+ if (opts.to === "remote") {
182
+ let recorded: StateBackendRecord["kind"] | null;
183
+ try {
184
+ recorded = deps.remoteRecordedBackend();
185
+ } catch (e) {
186
+ return { ok: false, error: `${errorText(e)}. Nothing was changed.` };
187
+ }
188
+ if (recorded === "gcs") {
189
+ return {
190
+ ok: false,
191
+ error: `gs://${remoteRecord.bucket}/install/install.json says this install's state already lives in the bucket — another machine migrated it, so the local state here is out of date. Nothing was changed. Use the bucket instead: ${selfCommand("attach", "--project", projectId)} (after moving ~/.caelo-${meta.installId} aside).`,
192
+ };
193
+ }
194
+ }
195
+ const pre = report.step("Preflight: Pulumi CLI, source stack, pending operations, drift");
196
+ let from: ResolvedBackend;
197
+ let mode: "stack-migrate" | "export-import";
198
+ let sourceDigest: string;
199
+ /** The source's checkpoint, to notice another run writing it meanwhile. */
200
+ let sourceState: string;
201
+ try {
202
+ const version = await pulumi.cliVersion();
203
+ if (!version) {
204
+ pre.failed("The pulumi CLI is not installed");
205
+ return {
206
+ ok: false,
207
+ error: "Install the Pulumi CLI (https://www.pulumi.com/docs/install/) and re-run.",
208
+ };
209
+ }
210
+ mode = versionAtLeast(version, STACK_MIGRATE_MIN_VERSION) ? "stack-migrate" : "export-import";
211
+ from = deps.resolve(current);
212
+ if (!(await pulumi.stackExists(from))) {
213
+ pre.failed("No stack in the source backend");
214
+ return {
215
+ ok: false,
216
+ error: `The install's stack is not in ${from.url}. Nothing was changed.`,
217
+ };
218
+ }
219
+ if (await pulumi.locked(from)) {
220
+ pre.failed("A Pulumi run holds the stack's lock");
221
+ return {
222
+ ok: false,
223
+ error:
224
+ "A Pulumi run (installer) is updating this stack right now. Migrate once it has finished. Nothing was changed.",
225
+ };
226
+ }
227
+ const pending = await pulumi.pendingOperations(from);
228
+ if (pending > 0) {
229
+ pre.failed(`${pending} pending operation(s) from an interrupted run`);
230
+ return {
231
+ ok: false,
232
+ error: `The stack records ${pending} pending operation(s) from an interrupted Pulumi run. Run upgrade first, then re-run the installer (${selfCommand()}, pick this install) so the stack is refreshed, and migrate after that. Nothing was changed.`,
233
+ };
234
+ }
235
+ const drift = await pulumi.previewChanges(from);
236
+ if (drift > 0) {
237
+ pre.failed(`A preview shows ${drift} change(s)`);
238
+ return {
239
+ ok: false,
240
+ error: `The stack is not converged (a preview shows ${drift} change(s)), so a copy can't be proven identical. Run upgrade first, then re-run the installer (${selfCommand()}, pick this install) until it reports no changes, and migrate after that. Nothing was changed.`,
241
+ };
242
+ }
243
+ sourceDigest = await pulumi.outputsDigest(from);
244
+ sourceState = await pulumi.stateDigest(from);
245
+ pre.done(
246
+ `Source ok: ${from.url}, no pending operations, no drift (Pulumi ${version}, ${mode === "stack-migrate" ? "pulumi stack migrate" : "export/import fallback"})`,
247
+ );
248
+ } catch (e) {
249
+ pre.failed("Preflight failed");
250
+ return { ok: false, error: `${errorText(e)}. Nothing was changed.` };
251
+ }
252
+
253
+ // --- Dry run: report and stop -----------------------------------------
254
+ if (opts.dryRun) {
255
+ const plan: string[] = [`back up ~/.caelo-${meta.installId}/ (mode 600 archive)`];
256
+ if (opts.to === "remote") {
257
+ const r = await deps.ensureRemote(true);
258
+ if (!r.ok) return { ok: false, error: r.error };
259
+ plan.push(...r.actions);
260
+ } else {
261
+ if (!deps.localStateEmpty()) {
262
+ plan.push(
263
+ "would move the unused stack in ~/.caelo-<id>/state aside (state.migrated-<time>, kept) — a leftover of an interrupted migration",
264
+ );
265
+ }
266
+ plan.push("would create ~/.caelo-<id>/state and secrets/pulumi-passphrase if missing");
267
+ }
268
+ plan.push(
269
+ `would copy the stack ${from.url} → ${opts.to === "remote" ? `gs://${remoteRecord.bucket}` : "~/.caelo-<id>/state"} (${mode}), re-encrypting its secrets`,
270
+ "would verify: preview shows no changes, outputs hash identical",
271
+ `would record stateBackend=${targetRecord.kind} in install.json${opts.to === "remote" ? " (locally and in the bucket)" : ""}`,
272
+ ...(opts.to === "remote"
273
+ ? ["would rename ~/.caelo-<id>/state to state.migrated-<time> (kept)"]
274
+ : []),
275
+ );
276
+ report.info(
277
+ ["Dry run — nothing changed. The migration would:", ...plan.map((p) => ` ${p}`)].join("\n"),
278
+ );
279
+ return { ok: true, changed: false };
280
+ }
281
+
282
+ // --- 2. Backup -----------------------------------------------------------
283
+ const bk = report.step(`Backing up ~/.caelo-${meta.installId}/`);
284
+ try {
285
+ bk.done(`Backup: ${await deps.backup()} (mode 600; holds the passphrase — keep it private)`);
286
+ } catch (e) {
287
+ bk.failed("Backup failed");
288
+ return { ok: false, error: `${errorText(e)}. Nothing was changed.` };
289
+ }
290
+
291
+ // --- 3a. Ops bucket + KMS key (remote), then the migration lock ----------
292
+ if (opts.to === "remote") {
293
+ // CLAUDE.md §11.C — nothing billable without the operator's yes.
294
+ if (!(await deps.confirmCost(OPS_BACKEND_COST_NOTE))) {
295
+ return { ok: false, error: "Cancelled at the cost confirmation. Only the backup was taken." };
296
+ }
297
+ const en = report.step(
298
+ `Ensuring gs://${remoteRecord.bucket} + KMS key ${remoteRecord.kmsKey.split("/").slice(-3).join("/")}`,
299
+ );
300
+ const r = await deps.ensureRemote(false);
301
+ if (!r.ok) {
302
+ en.failed("Could not ensure the ops bucket + KMS key");
303
+ return { ok: false, error: `${r.error} Nothing else was changed.` };
304
+ }
305
+ en.done(`gs://${remoteRecord.bucket} + KMS key ready`);
306
+ }
307
+ // The install's state lock, shared with installer runs (`pulumi up`) and
308
+ // other migrations: held from the authority check through the flip, so
309
+ // nobody writes the source after it was copied, and no concurrent
310
+ // migration takes a copy the other just made authoritative for a leftover.
311
+ let release: () => Promise<void>;
312
+ try {
313
+ release = await deps.lock();
314
+ } catch (e) {
315
+ return { ok: false, error: `${errorText(e)}. Nothing else was changed.` };
316
+ }
317
+ try {
318
+ const recorded = deps.remoteRecordedBackend();
319
+ if (opts.to === "remote" && recorded === "gcs") {
320
+ return {
321
+ ok: false,
322
+ error: `Another machine just finished migrating this install into gs://${remoteRecord.bucket}; the local state here is out of date. Nothing else was changed. Use: ${selfCommand("attach", "--project", projectId)}`,
323
+ };
324
+ }
325
+ if (opts.to === "local" && recorded !== "gcs") {
326
+ return {
327
+ ok: false,
328
+ error: `gs://${remoteRecord.bucket}/install/install.json no longer names the bucket as this install's state (another machine moved it back to itself). Nothing else was changed; manage the install from that machine.`,
329
+ };
330
+ }
331
+ return await finish();
332
+ } finally {
333
+ await release().catch((e: unknown) => report.warn(errorText(e)));
334
+ }
335
+
336
+ async function finish(): Promise<MigrateResult> {
337
+ // --- 3. Target -----------------------------------------------------------
338
+ const tg = report.step(
339
+ opts.to === "remote" ? "Preparing the bucket target" : "Preparing the local state dir",
340
+ );
341
+ let to: ResolvedBackend;
342
+ try {
343
+ if (opts.to === "local" && !deps.localStateEmpty()) {
344
+ // install.json names the bucket, so a stack here is not in use: the
345
+ // leftover of an interrupted `--to local`. Kept, never deleted.
346
+ const moved = deps.retireLocalState();
347
+ report.info(`Moved an unused local stack (interrupted migration?) aside to ${moved}`);
348
+ }
349
+ if (opts.to === "local") deps.prepareLocalTarget();
350
+ to = deps.resolve(targetRecord);
351
+ // A remote stack here is not authoritative (checked in the preflight:
352
+ // the bucket's install.json doesn't name it), so it is the leftover of
353
+ // an interrupted migration or of an earlier `--to local`.
354
+ if (await pulumi.stackExists(to)) {
355
+ report.info(
356
+ `Replacing an unused copy in ${to.url} (left by an earlier or interrupted migration)`,
357
+ );
358
+ await pulumi.removeStack(to);
359
+ }
360
+ tg.done(`Target ready: ${to.url}`);
361
+ } catch (e) {
362
+ tg.failed("Could not prepare the target");
363
+ return { ok: false, error: `${errorText(e)}. The source is unchanged and still in use.` };
364
+ }
365
+
366
+ // --- 4 + 5. Copy and verify ----------------------------------------------
367
+ const cp = report.step(`Copying the stack to ${to.url} and verifying it`);
368
+ const abandon = async (why: string): Promise<MigrateResult> => {
369
+ cp.failed(why);
370
+ try {
371
+ await pulumi.removeStack(to);
372
+ } catch (e) {
373
+ report.warn(`Could not remove the partial copy in ${to.url}: ${errorText(e)}`);
374
+ }
375
+ return {
376
+ ok: false,
377
+ error: `${why}. The copy was discarded; install.json still names ${from.url}, which stays authoritative.`,
378
+ };
379
+ };
380
+ try {
381
+ if (mode === "stack-migrate") await pulumi.stackMigrate(from, to);
382
+ else await pulumi.exportImport(from, to);
383
+ } catch (e) {
384
+ return abandon(`Copy failed: ${errorText(e)}`);
385
+ }
386
+ try {
387
+ const changes = await pulumi.previewChanges(to);
388
+ if (changes > 0)
389
+ return abandon(`Verification failed: a preview of the copy shows ${changes} change(s)`);
390
+ if ((await pulumi.outputsDigest(to)) !== sourceDigest) {
391
+ return abandon("Verification failed: the copy's outputs differ from the source's");
392
+ }
393
+ // An installer run elsewhere may have updated the source since the
394
+ // preflight (only migrations take the lock): the copy would then miss
395
+ // that update. Checked last, right before the switch.
396
+ if ((await pulumi.locked(from)) || (await pulumi.stateDigest(from)) !== sourceState) {
397
+ return abandon(
398
+ "The source stack changed during the migration (another run updated it); migrate again once it is idle",
399
+ );
400
+ }
401
+ } catch (e) {
402
+ return abandon(`Verification failed: ${errorText(e)}`);
403
+ }
404
+ cp.done("Copied; preview shows no changes and the outputs match");
405
+
406
+ // --- 6. Flip ---------------------------------------------------------------
407
+ const fl = report.step("Recording the new backend in install.json");
408
+ try {
409
+ deps.adopt(targetRecord);
410
+ } catch (e) {
411
+ fl.failed("Could not record the new backend");
412
+ return {
413
+ ok: false,
414
+ error: `${errorText(e)}. The copy in ${to.url} is complete, but install.json still names ${from.url}; re-run state migrate to finish.`,
415
+ };
416
+ }
417
+ fl.done(`install.json: stateBackend=${targetRecord.kind}`);
418
+ if (opts.to === "remote") {
419
+ const moved = deps.retireLocalState();
420
+ if (moved)
421
+ report.info(
422
+ `Old local state kept at ${moved} (not used any more; delete it once you are confident)`,
423
+ );
424
+ report.success(
425
+ `Done. Pulumi state + install metadata now live in gs://${remoteRecord.bucket}; secrets are encrypted with Cloud KMS. Other machines: ${selfCommand("attach", "--project", projectId)}`,
426
+ );
427
+ } else {
428
+ report.success(
429
+ `Done. Pulumi state is back in ~/.caelo-${meta.installId}/state (passphrase in secrets/). The copy in gs://${remoteRecord.bucket}/pulumi is no longer used; the bucket's install.json now says so.`,
430
+ );
431
+ }
432
+ return { ok: true, changed: true };
433
+ }
434
+ }