@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,262 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Where an install's Pulumi state and install metadata live (#696, D2).
5
+ *
6
+ * Two kinds:
7
+ *
8
+ * - `local` — the original layout: Pulumi's DIY backend at
9
+ * `file://~/.caelo-<id>/state`, secrets encrypted with the passphrase in
10
+ * `~/.caelo-<id>/secrets/pulumi-passphrase`. Only the machine that
11
+ * installed can upgrade or re-run the install. Self-hosted always stays
12
+ * here: its state is on the host, next to the install.
13
+ * - `gcs` — the install's own GCP project holds everything: a versioned,
14
+ * uniform-access, public-access-prevented bucket `<project>-caelo-ops`
15
+ * (`pulumi/` for Pulumi's DIY backend, `install/` for install.json and
16
+ * progress.json) and a Cloud KMS key `caelo/pulumi-state` as Pulumi's
17
+ * secrets provider. No passphrase exists. Any machine (laptop, Cloud
18
+ * Shell, an in-cloud job) the operator is signed in on can run the
19
+ * install's lifecycle commands after `cms-provision attach`.
20
+ *
21
+ * install.json records the kind in `stateBackend`; an install without the
22
+ * field predates #696 and is `local`. Every Pulumi call resolves its backend
23
+ * URL, secrets provider and environment through {@link resolveBackend} — no
24
+ * other code builds a `file://` URL or reads the passphrase.
25
+ */
26
+
27
+ import { existsSync, readdirSync } from "node:fs";
28
+ import { join } from "node:path";
29
+ import { selfCommand } from "./self-command.js";
30
+
31
+ /** Name of the Cloud KMS key ring holding Caelo's keys in the install project. */
32
+ export const OPS_KEY_RING = "caelo";
33
+ /** Name of the Cloud KMS key Pulumi encrypts the stack's secrets with. */
34
+ export const OPS_STATE_KEY = "pulumi-state";
35
+ /** Prefix inside the ops bucket that Pulumi's DIY backend uses. */
36
+ export const OPS_PULUMI_PREFIX = "pulumi";
37
+ /** Prefix inside the ops bucket that holds install.json + progress.json. */
38
+ export const OPS_INSTALL_PREFIX = "install";
39
+
40
+ /** What install.json records about the install's state backend. */
41
+ export type StateBackendRecord =
42
+ | { readonly kind: "local" }
43
+ | {
44
+ readonly kind: "gcs";
45
+ /** The ops bucket, without `gs://`. */
46
+ readonly bucket: string;
47
+ /** Full resource name of the KMS key (`projects/…/cryptoKeys/pulumi-state`). */
48
+ readonly kmsKey: string;
49
+ };
50
+
51
+ /** The ops-bucket variant of {@link StateBackendRecord}. */
52
+ export type GcsBackendRecord = Extract<StateBackendRecord, { kind: "gcs" }>;
53
+
54
+ /** The backend a Pulumi command runs against, ready to pass to the CLI. */
55
+ export interface ResolvedBackend {
56
+ readonly kind: StateBackendRecord["kind"];
57
+ /** `PULUMI_BACKEND_URL`. */
58
+ readonly url: string;
59
+ /** What `pulumi stack init/migrate --secrets-provider` takes. */
60
+ readonly secretsProvider: string;
61
+ /** The environment Pulumi needs for this backend (and nothing else). */
62
+ readonly envVars: Readonly<Record<string, string>>;
63
+ }
64
+
65
+ /** `<project>-caelo-ops` — the install's ops bucket (project ids are globally unique). */
66
+ export function opsBucketName(projectId: string): string {
67
+ return `${projectId}-caelo-ops`;
68
+ }
69
+
70
+ /** Full resource name of the install's Pulumi state key. */
71
+ export function opsKmsKeyName(projectId: string, region: string): string {
72
+ return `projects/${projectId}/locations/${region}/keyRings/${OPS_KEY_RING}/cryptoKeys/${OPS_STATE_KEY}`;
73
+ }
74
+
75
+ /** The `gcs` record for an install in `projectId`, its ops resources in `region`. */
76
+ export function gcsBackendRecord(projectId: string, region: string): GcsBackendRecord {
77
+ return {
78
+ kind: "gcs",
79
+ bucket: opsBucketName(projectId),
80
+ kmsKey: opsKmsKeyName(projectId, region),
81
+ };
82
+ }
83
+
84
+ /** The install's recorded backend; installs that predate #696 are `local`. */
85
+ export function installBackend(meta: { readonly stateBackend?: StateBackendRecord }) {
86
+ return meta.stateBackend ?? ({ kind: "local" } as const);
87
+ }
88
+
89
+ const BUCKET = /^[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]$/;
90
+ const KMS_KEY =
91
+ /^projects\/[a-z][a-z0-9.:-]{0,61}[a-z0-9]\/locations\/[a-z0-9-]+\/keyRings\/[A-Za-z0-9_-]+\/cryptoKeys\/[A-Za-z0-9_-]+$/;
92
+
93
+ /**
94
+ * Validate a `stateBackend` value read from install.json (on disk or in the
95
+ * bucket). A malformed record would point Pulumi at the wrong place, so it
96
+ * throws instead of guessing.
97
+ */
98
+ export function parseStateBackend(value: unknown): StateBackendRecord | undefined {
99
+ if (value === undefined) return undefined;
100
+ const v = value as Record<string, unknown> | null;
101
+ if (v && v.kind === "local") return { kind: "local" };
102
+ if (
103
+ v &&
104
+ v.kind === "gcs" &&
105
+ typeof v.bucket === "string" &&
106
+ BUCKET.test(v.bucket) &&
107
+ typeof v.kmsKey === "string" &&
108
+ KMS_KEY.test(v.kmsKey)
109
+ ) {
110
+ return { kind: "gcs", bucket: v.bucket, kmsKey: v.kmsKey };
111
+ }
112
+ throw new Error(
113
+ `install.json stateBackend is malformed (${JSON.stringify(value)}). Expected {"kind":"local"} or {"kind":"gcs","bucket":"<project>-caelo-ops","kmsKey":"projects/…/cryptoKeys/${OPS_STATE_KEY}"}.`,
114
+ );
115
+ }
116
+
117
+ /** Where a `local` install keeps Pulumi's state. */
118
+ export function localStateDir(installRoot: string): string {
119
+ return join(installRoot, "state");
120
+ }
121
+
122
+ /**
123
+ * Resolve the backend URL, secrets provider and environment for an install.
124
+ *
125
+ * `local` needs the passphrase and fails loudly without it — Pulumi would
126
+ * otherwise prompt for it on a terminal nobody sees. `gcs` needs no secret:
127
+ * Pulumi reaches the bucket and the KMS key with the operator's Application
128
+ * Default Credentials. Both set `PULUMI_FALLBACK_TO_STATE_SECRETS_MANAGER`:
129
+ * Pulumi then takes the secrets provider (KMS key or passphrase salt) from
130
+ * the install's own state when the stack's config file in the package's
131
+ * stack dir names none (wizards/gcp-pulumi.ts alignedStackSettings).
132
+ *
133
+ * @param meta the install (its `stateBackend`, absent = local)
134
+ * @param opts.installRoot `~/.caelo-<id>`
135
+ * @param opts.readPassphrase reads `secrets/pulumi-passphrase`; called for local only
136
+ */
137
+ export function resolveBackend(
138
+ meta: { readonly installId: string; readonly stateBackend?: StateBackendRecord },
139
+ opts: { readonly installRoot: string; readonly readPassphrase: () => string | null },
140
+ ): ResolvedBackend {
141
+ const record = installBackend(meta);
142
+ if (record.kind === "gcs") {
143
+ const url = `gs://${record.bucket}/${OPS_PULUMI_PREFIX}`;
144
+ return {
145
+ kind: "gcs",
146
+ url,
147
+ secretsProvider: `gcpkms://${record.kmsKey}`,
148
+ envVars: {
149
+ PULUMI_BACKEND_URL: url,
150
+ PULUMI_FALLBACK_TO_STATE_SECRETS_MANAGER: "true",
151
+ },
152
+ };
153
+ }
154
+ const passphrase = opts.readPassphrase();
155
+ if (!passphrase) {
156
+ throw new Error(
157
+ `~/.caelo-${meta.installId}/secrets/pulumi-passphrase is missing, so the install's local Pulumi state can't be decrypted. Restore it from your backup of ~/.caelo-${meta.installId}/.`,
158
+ );
159
+ }
160
+ const url = `file://${localStateDir(opts.installRoot)}`;
161
+ return {
162
+ kind: "local",
163
+ url,
164
+ secretsProvider: "passphrase",
165
+ envVars: {
166
+ PULUMI_BACKEND_URL: url,
167
+ PULUMI_CONFIG_PASSPHRASE: passphrase,
168
+ PULUMI_FALLBACK_TO_STATE_SECRETS_MANAGER: "true",
169
+ },
170
+ };
171
+ }
172
+
173
+ /**
174
+ * The shell prefix that points a manual `pulumi …` command at the install's
175
+ * backend, for recovery instructions the CLI prints. Never contains the
176
+ * passphrase itself: a local install reads it from its file at run time.
177
+ */
178
+ export function backendShellEnv(
179
+ meta: { readonly stateBackend?: StateBackendRecord },
180
+ installRoot: string,
181
+ ): string {
182
+ const record = installBackend(meta);
183
+ if (record.kind === "gcs") {
184
+ return `PULUMI_BACKEND_URL=gs://${record.bucket}/${OPS_PULUMI_PREFIX} PULUMI_FALLBACK_TO_STATE_SECRETS_MANAGER=true`;
185
+ }
186
+ return `PULUMI_BACKEND_URL=file://${localStateDir(installRoot)} PULUMI_CONFIG_PASSPHRASE="$(cat ${join(installRoot, "secrets", "pulumi-passphrase")})" PULUMI_FALLBACK_TO_STATE_SECRETS_MANAGER=true`;
187
+ }
188
+
189
+ /** Human-readable location of the install's state, for status and progress output. */
190
+ export function describeBackend(
191
+ meta: { readonly stateBackend?: StateBackendRecord },
192
+ installRoot: string,
193
+ ): string {
194
+ const record = installBackend(meta);
195
+ return record.kind === "gcs"
196
+ ? `gs://${record.bucket} (secrets: Cloud KMS ${record.kmsKey.split("/").slice(-3).join("/")})`
197
+ : `${localStateDir(installRoot)} on this machine (secrets: passphrase file)`;
198
+ }
199
+
200
+ /**
201
+ * Whether a local Pulumi backend dir holds any stack (Pulumi's DIY layout
202
+ * keeps them under `.pulumi/stacks/`).
203
+ */
204
+ export function hasLocalPulumiState(installRoot: string): boolean {
205
+ const stacks = join(localStateDir(installRoot), ".pulumi", "stacks");
206
+ if (!existsSync(stacks)) return false;
207
+ // An unreadable dir throws: guessing "no state" would move a deployed
208
+ // install onto an empty backend.
209
+ return readdirSync(stacks).length > 0;
210
+ }
211
+
212
+ /**
213
+ * Which backend the GCP wizard runs an install on (#696). A recorded
214
+ * backend wins. An install without one whose state already sits on this
215
+ * machine stays local — moving state needs a preflight, a backup and a
216
+ * verification, never a side effect of a re-run.
217
+ * Everything else (a new GCP install) gets the ops bucket. Self-hosted, AWS
218
+ * and Azure stay local until their adapters have an equivalent.
219
+ */
220
+ export function chooseInstallBackend(
221
+ meta: {
222
+ readonly installId?: string;
223
+ readonly provider: string;
224
+ readonly stateBackend?: StateBackendRecord;
225
+ },
226
+ localStateExists: boolean,
227
+ deployed = false,
228
+ ): StateBackendRecord["kind"] {
229
+ if (meta.stateBackend) return meta.stateBackend.kind;
230
+ if (meta.provider !== "gcp" && meta.provider !== "gcp-firebase") return "local";
231
+ if (localStateExists) return "local";
232
+ if (deployed) {
233
+ // Deployed, yet no state anywhere: a fresh backend would make Pulumi
234
+ // recreate (and collide with) every resource.
235
+ throw new Error(
236
+ `Install ${meta.installId ?? ""} was deployed, but its Pulumi state is neither recorded in install.json nor in ~/.caelo-${meta.installId ?? "<install-id>"}/state. Restore that directory from your backup and re-run.`,
237
+ );
238
+ }
239
+ return "gcs";
240
+ }
241
+
242
+ /**
243
+ * What `upgrade` tells the operator about an install whose Pulumi state is
244
+ * still on this machine (#696), or null when it lives in the install's own
245
+ * cloud. `upgrade` never moves state itself: the move has its own
246
+ * preflight, backup and verification (`state migrate`).
247
+ */
248
+ export function localStateNotice(meta: {
249
+ readonly installId: string;
250
+ readonly provider: string;
251
+ readonly stateBackend?: StateBackendRecord;
252
+ }): string | null {
253
+ if (installBackend(meta).kind !== "local") return null;
254
+ if (meta.provider !== "gcp" && meta.provider !== "gcp-firebase") return null;
255
+ return [
256
+ `This install's Pulumi state and passphrase live only on this machine (~/.caelo-${meta.installId}/).`,
257
+ "Lose this machine and the install's infrastructure can no longer be converged from anywhere.",
258
+ "Move them into the install's own GCP project (versioned bucket + Cloud KMS key, < $0.10/mo):",
259
+ ` ${selfCommand("state", "migrate", "--install", meta.installId, "--dry-run")}`,
260
+ ` ${selfCommand("state", "migrate", "--install", meta.installId)}`,
261
+ ].join("\n");
262
+ }
@@ -0,0 +1,293 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * CLI wiring for `cms-provision state migrate` and `cms-provision attach`
5
+ * (#696): parses the flags, picks the install, and hands the engines
6
+ * (state-migrate.ts, state-attach.ts) the real gcloud, Pulumi and file
7
+ * system.
8
+ */
9
+
10
+ import { spawnSync } from "node:child_process";
11
+ import { randomBytes } from "node:crypto";
12
+ import { chmodSync, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { confirm, isCancel, log, note } from "@clack/prompts";
16
+ import { bold, red } from "kleur/colors";
17
+ import { activeAccount } from "./gcloud.js";
18
+ import {
19
+ adoptStateBackend,
20
+ currentMetadata,
21
+ ensureInstallDir,
22
+ installFlag,
23
+ installRoot,
24
+ installStagingHost,
25
+ listInstalls,
26
+ readSecret,
27
+ recordedImageDigests,
28
+ selectInstall,
29
+ writeSecret,
30
+ } from "./install-state.js";
31
+ import { installIapAllowlist } from "./operator-access.js";
32
+ import { ensureOpsBackend } from "./ops-backend.js";
33
+ import { provisionerServiceAccountEmail } from "./provisioner-identity.js";
34
+ import { selfCommand } from "./self-command.js";
35
+ import { attachInstall } from "./state-attach.js";
36
+ import {
37
+ gcsBackendRecord,
38
+ hasLocalPulumiState,
39
+ installBackend,
40
+ localStateDir,
41
+ parseStateBackend,
42
+ resolveBackend,
43
+ } from "./state-backend.js";
44
+ import { migrateState } from "./state-migrate.js";
45
+ import { realStatePulumiOps } from "./state-pulumi.js";
46
+ import { acquireStateLock, GcsStateStore, realSyncGcloud } from "./state-store.js";
47
+ import { clackReporter } from "./upgrade/reporter.js";
48
+ import { GCP_STACK_KNOB_DEFAULTS } from "./wizards/gcp-pulumi.js";
49
+
50
+ /** Where install-dir archives go: `~/.caelo-backups/` (mode 700). */
51
+ export function backupsDir(home: string = homedir()): string {
52
+ return join(home, ".caelo-backups");
53
+ }
54
+
55
+ /**
56
+ * Archive `~/.caelo-<id>/` (secrets and state included) to
57
+ * `~/.caelo-backups/<id>-<time>.tar.zst`, or `.tar.gz` when `zstd` is not
58
+ * installed. The archive and its directory are private (600 / 700): it
59
+ * holds the passphrase. Throws when the archive can't be made.
60
+ *
61
+ * @returns the archive's path
62
+ */
63
+ export function backupInstallDir(
64
+ installId: string,
65
+ opts: { home?: string; now?: Date; hasZstd?: boolean } = {},
66
+ ): string {
67
+ const home = opts.home ?? homedir();
68
+ const dir = backupsDir(home);
69
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
70
+ chmodSync(dir, 0o700);
71
+ const stamp = (opts.now ?? new Date()).toISOString().replace(/[:.]/g, "-");
72
+ const base = join(dir, `${installId}-${stamp}`);
73
+ const zstd = opts.hasZstd ?? spawnSync("zstd", ["--version"], { stdio: "ignore" }).status === 0;
74
+ const source = ["-C", home, `.caelo-${installId}`];
75
+ const run = (cmd: string, args: string[]) => {
76
+ const r = spawnSync(cmd, args, { stdio: ["ignore", "ignore", "pipe"], encoding: "utf8" });
77
+ if (r.status !== 0) {
78
+ throw new Error(
79
+ `${cmd} failed while archiving ~/.caelo-${installId}: ${(r.stderr ?? "").trim()}`,
80
+ );
81
+ }
82
+ };
83
+ if (zstd) {
84
+ const tar = `${base}.tar`;
85
+ try {
86
+ run("tar", ["-cf", tar, ...source]);
87
+ chmodSync(tar, 0o600);
88
+ run("zstd", ["-q", "--rm", "-o", `${tar}.zst`, tar]);
89
+ } finally {
90
+ rmSync(tar, { force: true });
91
+ }
92
+ chmodSync(`${tar}.zst`, 0o600);
93
+ return `${tar}.zst`;
94
+ }
95
+ const out = `${base}.tar.gz`;
96
+ run("tar", ["-czf", out, ...source]);
97
+ chmodSync(out, 0o600);
98
+ return out;
99
+ }
100
+
101
+ /** The value of `--<name> <v>` / `--<name>=<v>` on argv. */
102
+ function flagValue(argv: readonly string[], name: string): string | undefined {
103
+ for (let i = 0; i < argv.length; i++) {
104
+ const a = argv[i] ?? "";
105
+ if (a === `--${name}`) return argv[i + 1];
106
+ if (a.startsWith(`--${name}=`)) return a.slice(name.length + 3);
107
+ }
108
+ return undefined;
109
+ }
110
+
111
+ const USAGE =
112
+ "Usage: cms-provision state migrate [--install <install-id or domain>] [--to remote|local] [--dry-run] [--yes]";
113
+
114
+ /** `cms-provision state migrate …`. */
115
+ export async function stateCommand(argv: readonly string[]): Promise<void> {
116
+ if (argv[3] !== "migrate") {
117
+ console.log(USAGE);
118
+ process.exit(2);
119
+ }
120
+ const to = flagValue(argv, "to") ?? "remote";
121
+ if (to !== "remote" && to !== "local") {
122
+ console.error(`--to must be remote or local.\n${USAGE}`);
123
+ process.exit(2);
124
+ }
125
+ const dryRun = argv.includes("--dry-run");
126
+ const selected = selectInstall(listInstalls(), installFlag(argv));
127
+ if (!selected.ok) {
128
+ console.error(selected.message);
129
+ process.exit(1);
130
+ }
131
+ const meta = currentMetadata(selected.meta);
132
+ const { installId } = meta;
133
+ const root = installRoot(installId);
134
+ if (
135
+ (meta.provider !== "gcp" && meta.provider !== "gcp-firebase") ||
136
+ !meta.projectId ||
137
+ !meta.region
138
+ ) {
139
+ log.error(
140
+ red(
141
+ `${installId} (${meta.provider}) keeps its state on the host or has no project/region recorded; state migrate is for gcp and gcp-firebase installs.`,
142
+ ),
143
+ );
144
+ process.exit(1);
145
+ }
146
+ const { projectId, region, provider } = meta;
147
+ const imageDigests = recordedImageDigests(meta);
148
+ if (!imageDigests) {
149
+ log.error(
150
+ red(
151
+ `install.json of ${installId} records no image digests. Run ${bold(selfCommand("upgrade", "--install", installId))} first, then migrate.`,
152
+ ),
153
+ );
154
+ process.exit(1);
155
+ }
156
+ const operator = await activeAccount();
157
+ if (!operator) {
158
+ log.error(red("No active gcloud account. Run `gcloud auth login`, then re-run."));
159
+ process.exit(1);
160
+ }
161
+ const provisioner = provisionerServiceAccountEmail(projectId);
162
+ note(
163
+ [
164
+ `Install: ${bold(meta.domain)} (${installId}, project ${projectId}, ${region})`,
165
+ `Direction: ${to === "remote" ? `this machine → gs://${gcsBackendRecord(projectId, region).bucket} + Cloud KMS` : "the install's bucket → this machine"}`,
166
+ dryRun
167
+ ? "Dry run: nothing will be changed"
168
+ : "The source stays in use until the copy is verified.",
169
+ ].join("\n"),
170
+ "state migrate",
171
+ );
172
+
173
+ const result = await migrateState(
174
+ meta,
175
+ { to, dryRun },
176
+ {
177
+ pulumi: realStatePulumiOps({
178
+ provider,
179
+ installRoot: root,
180
+ impersonateServiceAccount: provisioner,
181
+ stackConfig: {
182
+ ...GCP_STACK_KNOB_DEFAULTS,
183
+ provider,
184
+ projectId,
185
+ domain: meta.domain,
186
+ stagingDomain: installStagingHost(meta),
187
+ ownerEmail: meta.ownerEmail,
188
+ region,
189
+ iapAllowlist: installIapAllowlist(meta.ownerEmail),
190
+ imageDigests,
191
+ },
192
+ }),
193
+ report: clackReporter(),
194
+ resolve: (record) =>
195
+ resolveBackend(
196
+ { installId, stateBackend: record },
197
+ { installRoot: root, readPassphrase: () => readSecret(installId, "pulumi-passphrase") },
198
+ ),
199
+ confirmCost: async (costNote) => {
200
+ log.info(costNote);
201
+ if (argv.includes("--yes")) return true;
202
+ if (!process.stdin.isTTY) {
203
+ log.error(red("Not a terminal: pass --yes to accept the cost above."));
204
+ return false;
205
+ }
206
+ const yes = await confirm({ message: "Create them and continue?", initialValue: true });
207
+ return !isCancel(yes) && yes === true;
208
+ },
209
+ ensureRemote: (dry) =>
210
+ ensureOpsBackend(
211
+ { projectId, region, operatorAccount: operator, provisionerEmail: provisioner },
212
+ { dryRun: dry },
213
+ ),
214
+ backup: async () => backupInstallDir(installId),
215
+ localStateEmpty: () => !hasLocalPulumiState(root),
216
+ prepareLocalTarget: () => {
217
+ mkdirSync(localStateDir(root), { recursive: true, mode: 0o700 });
218
+ if (!readSecret(installId, "pulumi-passphrase")) {
219
+ writeSecret(installId, "pulumi-passphrase", randomBytes(32).toString("hex"));
220
+ }
221
+ },
222
+ lock: async () => {
223
+ // The source's lock (local file or bucket object) keeps installer
224
+ // runs out; the bucket's also keeps out a migration started on
225
+ // another machine when the bucket is the target.
226
+ const source = meta.stateBackend ?? { kind: "local" as const };
227
+ const releases = [acquireStateLock(source, root, "state migrate")];
228
+ if (source.kind === "local" && to === "remote") {
229
+ try {
230
+ releases.push(
231
+ acquireStateLock(gcsBackendRecord(projectId, region), root, "state migrate"),
232
+ );
233
+ } catch (e) {
234
+ for (const r of releases) r();
235
+ throw e;
236
+ }
237
+ }
238
+ return async () => {
239
+ for (const r of releases.reverse()) r();
240
+ };
241
+ },
242
+ remoteRecordedBackend: () => {
243
+ const text = new GcsStateStore(gcsBackendRecord(projectId, region).bucket, root).peek(
244
+ "install.json",
245
+ );
246
+ if (text === null) return null;
247
+ return installBackend({ stateBackend: parseStateBackend(JSON.parse(text).stateBackend) })
248
+ .kind;
249
+ },
250
+ adopt: (record) => {
251
+ adoptStateBackend(installId, record);
252
+ },
253
+ retireLocalState: () => {
254
+ const dir = localStateDir(root);
255
+ if (!existsSync(dir)) return null;
256
+ const moved = `${dir}.migrated-${new Date().toISOString().replace(/[:.]/g, "-")}`;
257
+ renameSync(dir, moved);
258
+ return moved;
259
+ },
260
+ },
261
+ );
262
+ if (!result.ok) {
263
+ log.error(red(result.error));
264
+ process.exit(1);
265
+ }
266
+ }
267
+
268
+ /** `cms-provision attach --project <id>`. */
269
+ export async function attachCommand(argv: readonly string[]): Promise<void> {
270
+ const projectId = flagValue(argv, "project");
271
+ if (!projectId || projectId.startsWith("-")) {
272
+ console.log("Usage: cms-provision attach --project <gcp-project-id>");
273
+ process.exit(2);
274
+ }
275
+ const r = attachInstall(projectId, {
276
+ run: realSyncGcloud,
277
+ rootFor: installRoot,
278
+ hasLocalState: hasLocalPulumiState,
279
+ });
280
+ if (!r.ok) {
281
+ log.error(red(r.error));
282
+ process.exit(1);
283
+ }
284
+ ensureInstallDir(r.installId);
285
+ log.success(
286
+ r.status === "refreshed"
287
+ ? `${r.domain} was already attached; its local copy of install.json is up to date.`
288
+ : `Attached ${bold(r.domain)} (${r.installId}). status, upgrade, backup and re-running the installer now work from this machine.`,
289
+ );
290
+ log.info(
291
+ "Infrastructure runs need Application Default Credentials once on this machine: gcloud auth application-default login",
292
+ );
293
+ }