@caelo-cms/provisioning 0.10.47 → 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 (315) hide show
  1. package/dist/agent/apply.d.ts +45 -0
  2. package/dist/agent/apply.d.ts.map +1 -0
  3. package/dist/agent/apply.js +83 -0
  4. package/dist/agent/apply.js.map +1 -0
  5. package/dist/agent/cli-provenance.d.ts +42 -0
  6. package/dist/agent/cli-provenance.d.ts.map +1 -0
  7. package/dist/agent/cli-provenance.js +138 -0
  8. package/dist/agent/cli-provenance.js.map +1 -0
  9. package/dist/agent/command.d.ts +79 -0
  10. package/dist/agent/command.d.ts.map +1 -0
  11. package/dist/agent/command.js +454 -0
  12. package/dist/agent/command.js.map +1 -0
  13. package/dist/agent/config.d.ts +30 -0
  14. package/dist/agent/config.d.ts.map +1 -0
  15. package/dist/agent/config.js +60 -0
  16. package/dist/agent/config.js.map +1 -0
  17. package/dist/agent/lock.d.ts +13 -0
  18. package/dist/agent/lock.d.ts.map +1 -0
  19. package/dist/agent/lock.js +100 -0
  20. package/dist/agent/lock.js.map +1 -0
  21. package/dist/agent/queue.d.ts +35 -0
  22. package/dist/agent/queue.d.ts.map +1 -0
  23. package/dist/agent/queue.js +50 -0
  24. package/dist/agent/queue.js.map +1 -0
  25. package/dist/agent/run.d.ts +97 -0
  26. package/dist/agent/run.d.ts.map +1 -0
  27. package/dist/agent/run.js +198 -0
  28. package/dist/agent/run.js.map +1 -0
  29. package/dist/agent/systemd.d.ts +55 -0
  30. package/dist/agent/systemd.d.ts.map +1 -0
  31. package/dist/agent/systemd.js +171 -0
  32. package/dist/agent/systemd.js.map +1 -0
  33. package/dist/agent/units.d.ts +44 -0
  34. package/dist/agent/units.d.ts.map +1 -0
  35. package/dist/agent/units.js +127 -0
  36. package/dist/agent/units.js.map +1 -0
  37. package/dist/agent-db.js +18362 -0
  38. package/dist/cli.js +119 -16
  39. package/dist/cli.js.map +1 -1
  40. package/dist/cloudsql-iam-user.d.ts +39 -0
  41. package/dist/cloudsql-iam-user.d.ts.map +1 -0
  42. package/dist/cloudsql-iam-user.js +119 -0
  43. package/dist/cloudsql-iam-user.js.map +1 -0
  44. package/dist/compose.d.ts +16 -0
  45. package/dist/compose.d.ts.map +1 -1
  46. package/dist/compose.js +26 -1
  47. package/dist/compose.js.map +1 -1
  48. package/dist/dns/index.d.ts +2 -0
  49. package/dist/dns/index.d.ts.map +1 -1
  50. package/dist/dns/index.js +3 -2
  51. package/dist/dns/index.js.map +1 -1
  52. package/dist/dns/manual.d.ts +5 -0
  53. package/dist/dns/manual.d.ts.map +1 -1
  54. package/dist/dns/manual.js +28 -12
  55. package/dist/dns/manual.js.map +1 -1
  56. package/dist/gcloud.d.ts +2 -0
  57. package/dist/gcloud.d.ts.map +1 -1
  58. package/dist/gcloud.js +1 -1
  59. package/dist/gcloud.js.map +1 -1
  60. package/dist/gcp-names.d.ts +15 -0
  61. package/dist/gcp-names.d.ts.map +1 -1
  62. package/dist/gcp-names.js +23 -0
  63. package/dist/gcp-names.js.map +1 -1
  64. package/dist/index.d.ts +6 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +4 -0
  67. package/dist/index.js.map +1 -1
  68. package/dist/infra-contract.d.ts +1 -1
  69. package/dist/infra-contract.js +7 -1
  70. package/dist/infra-contract.js.map +1 -1
  71. package/dist/install-job-runner.d.ts +13 -0
  72. package/dist/install-job-runner.d.ts.map +1 -0
  73. package/dist/install-job-runner.js +155 -0
  74. package/dist/install-job-runner.js.map +1 -0
  75. package/dist/install-job.d.ts +210 -0
  76. package/dist/install-job.d.ts.map +1 -0
  77. package/dist/install-job.js +449 -0
  78. package/dist/install-job.js.map +1 -0
  79. package/dist/install-state.d.ts +10 -1
  80. package/dist/install-state.d.ts.map +1 -1
  81. package/dist/install-state.js +10 -2
  82. package/dist/install-state.js.map +1 -1
  83. package/dist/lifecycle.d.ts.map +1 -1
  84. package/dist/lifecycle.js +19 -4
  85. package/dist/lifecycle.js.map +1 -1
  86. package/dist/migration-runner.d.ts +5 -0
  87. package/dist/migration-runner.d.ts.map +1 -1
  88. package/dist/migration-runner.js +1 -1
  89. package/dist/migration-runner.js.map +1 -1
  90. package/dist/operator-access.d.ts +14 -8
  91. package/dist/operator-access.d.ts.map +1 -1
  92. package/dist/operator-access.js +16 -102
  93. package/dist/operator-access.js.map +1 -1
  94. package/dist/run-mode.d.ts +27 -0
  95. package/dist/run-mode.d.ts.map +1 -0
  96. package/dist/run-mode.js +22 -0
  97. package/dist/run-mode.js.map +1 -0
  98. package/dist/runtime-secrets.d.ts +19 -0
  99. package/dist/runtime-secrets.d.ts.map +1 -1
  100. package/dist/runtime-secrets.js +44 -0
  101. package/dist/runtime-secrets.js.map +1 -1
  102. package/dist/self-hosted-roles.d.ts +22 -1
  103. package/dist/self-hosted-roles.d.ts.map +1 -1
  104. package/dist/self-hosted-roles.js +81 -12
  105. package/dist/self-hosted-roles.js.map +1 -1
  106. package/dist/self-hosted-upgrade.d.ts +17 -0
  107. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  108. package/dist/self-hosted-upgrade.js +16 -1
  109. package/dist/self-hosted-upgrade.js.map +1 -1
  110. package/dist/stack-contract.d.ts +6 -0
  111. package/dist/stack-contract.d.ts.map +1 -1
  112. package/dist/stack-contract.js +10 -1
  113. package/dist/stack-contract.js.map +1 -1
  114. package/dist/upgrade/converge.d.ts +6 -0
  115. package/dist/upgrade/converge.d.ts.map +1 -1
  116. package/dist/upgrade/converge.js +59 -2
  117. package/dist/upgrade/converge.js.map +1 -1
  118. package/dist/upgrade/gcp-lookup.d.ts +6 -0
  119. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  120. package/dist/upgrade/gcp-lookup.js +25 -0
  121. package/dist/upgrade/gcp-lookup.js.map +1 -1
  122. package/dist/upgrade/release-images.d.ts +12 -0
  123. package/dist/upgrade/release-images.d.ts.map +1 -1
  124. package/dist/upgrade/release-images.js +31 -0
  125. package/dist/upgrade/release-images.js.map +1 -1
  126. package/dist/upgrade/upgrade.d.ts +5 -0
  127. package/dist/upgrade/upgrade.d.ts.map +1 -1
  128. package/dist/upgrade/upgrade.js +28 -1
  129. package/dist/upgrade/upgrade.js.map +1 -1
  130. package/dist/upgrader-assess.d.ts +82 -0
  131. package/dist/upgrader-assess.d.ts.map +1 -0
  132. package/dist/upgrader-assess.js +116 -0
  133. package/dist/upgrader-assess.js.map +1 -0
  134. package/dist/upgrader-gcp.d.ts +17 -0
  135. package/dist/upgrader-gcp.d.ts.map +1 -0
  136. package/dist/upgrader-gcp.js +87 -0
  137. package/dist/upgrader-gcp.js.map +1 -0
  138. package/dist/upgrader-job.d.ts +131 -0
  139. package/dist/upgrader-job.d.ts.map +1 -0
  140. package/dist/upgrader-job.js +259 -0
  141. package/dist/upgrader-job.js.map +1 -0
  142. package/dist/upgrader-reporter.d.ts +35 -0
  143. package/dist/upgrader-reporter.d.ts.map +1 -0
  144. package/dist/upgrader-reporter.js +79 -0
  145. package/dist/upgrader-reporter.js.map +1 -0
  146. package/dist/upgrader-setup.d.ts +59 -0
  147. package/dist/upgrader-setup.d.ts.map +1 -0
  148. package/dist/upgrader-setup.js +308 -0
  149. package/dist/upgrader-setup.js.map +1 -0
  150. package/dist/wizard.d.ts +6 -0
  151. package/dist/wizard.d.ts.map +1 -1
  152. package/dist/wizard.js +32 -4
  153. package/dist/wizard.js.map +1 -1
  154. package/dist/wizards/gcp-firebase.d.ts +3 -0
  155. package/dist/wizards/gcp-firebase.d.ts.map +1 -1
  156. package/dist/wizards/gcp-firebase.js +1 -0
  157. package/dist/wizards/gcp-firebase.js.map +1 -1
  158. package/dist/wizards/gcp-install-job.d.ts +74 -0
  159. package/dist/wizards/gcp-install-job.d.ts.map +1 -0
  160. package/dist/wizards/gcp-install-job.js +398 -0
  161. package/dist/wizards/gcp-install-job.js.map +1 -0
  162. package/dist/wizards/gcp-prompts.d.ts +3 -0
  163. package/dist/wizards/gcp-prompts.d.ts.map +1 -0
  164. package/dist/wizards/gcp-prompts.js +25 -0
  165. package/dist/wizards/gcp-prompts.js.map +1 -0
  166. package/dist/wizards/gcp-pulumi.d.ts +4 -2
  167. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  168. package/dist/wizards/gcp-pulumi.js +1 -1
  169. package/dist/wizards/gcp-pulumi.js.map +1 -1
  170. package/dist/wizards/gcp.d.ts +40 -0
  171. package/dist/wizards/gcp.d.ts.map +1 -1
  172. package/dist/wizards/gcp.js +223 -59
  173. package/dist/wizards/gcp.js.map +1 -1
  174. package/package.json +6 -4
  175. package/src/adapter.ts +97 -0
  176. package/src/adc-impersonation.ts +176 -0
  177. package/src/admin-domain.test.ts +254 -0
  178. package/src/admin-domain.ts +245 -0
  179. package/src/admin-memory.test.ts +151 -0
  180. package/src/agent/apply.test.ts +185 -0
  181. package/src/agent/apply.ts +124 -0
  182. package/src/agent/cli-provenance.test.ts +159 -0
  183. package/src/agent/cli-provenance.ts +191 -0
  184. package/src/agent/command.test.ts +77 -0
  185. package/src/agent/command.ts +564 -0
  186. package/src/agent/config.ts +86 -0
  187. package/src/agent/lock-config.test.ts +97 -0
  188. package/src/agent/lock.ts +110 -0
  189. package/src/agent/queue.test.ts +10 -0
  190. package/src/agent/queue.ts +91 -0
  191. package/src/agent/run.test.ts +382 -0
  192. package/src/agent/run.ts +295 -0
  193. package/src/agent/systemd.ts +209 -0
  194. package/src/agent/units.test.ts +223 -0
  195. package/src/agent/units.ts +163 -0
  196. package/src/azure-front-door.test.ts +119 -0
  197. package/src/azure-front-door.ts +56 -0
  198. package/src/bootstrap-token.ts +20 -0
  199. package/src/caddy.ts +112 -0
  200. package/src/cdn-copy.ts +91 -0
  201. package/src/cert-wait.test.ts +194 -0
  202. package/src/cert-wait.ts +163 -0
  203. package/src/checkpoint-secrets.test.ts +380 -0
  204. package/src/checkpoint-secrets.ts +283 -0
  205. package/src/cli-routing.test.ts +45 -0
  206. package/src/cli-routing.ts +43 -0
  207. package/src/cli.ts +1491 -0
  208. package/src/cloudsql-iam-user.ts +147 -0
  209. package/src/compose.ts +334 -0
  210. package/src/default-firewall.test.ts +411 -0
  211. package/src/default-firewall.ts +342 -0
  212. package/src/deployed-release.test.ts +231 -0
  213. package/src/deployed-release.ts +220 -0
  214. package/src/dns/cloudflare.ts +182 -0
  215. package/src/dns/groups.test.ts +76 -0
  216. package/src/dns/groups.ts +86 -0
  217. package/src/dns/index.ts +56 -0
  218. package/src/dns/manual.ts +130 -0
  219. package/src/dns/types.ts +27 -0
  220. package/src/gateway-credentials.test.ts +180 -0
  221. package/src/gateway-credentials.ts +144 -0
  222. package/src/gcloud-components.test.ts +76 -0
  223. package/src/gcloud-components.ts +85 -0
  224. package/src/gcloud-retry.ts +41 -0
  225. package/src/gcloud-runner.test.ts +61 -0
  226. package/src/gcloud.test.ts +99 -0
  227. package/src/gcloud.ts +322 -0
  228. package/src/gcp-names.ts +135 -0
  229. package/src/index.test.ts +332 -0
  230. package/src/index.ts +114 -0
  231. package/src/infra-contract.ts +32 -0
  232. package/src/install-job-runner.ts +166 -0
  233. package/src/install-job.test.ts +566 -0
  234. package/src/install-job.ts +542 -0
  235. package/src/install-state.test.ts +239 -0
  236. package/src/install-state.ts +583 -0
  237. package/src/internal-token.ts +35 -0
  238. package/src/lifecycle.ts +900 -0
  239. package/src/mcp-iap-stacks.test.ts +58 -0
  240. package/src/mcp-iap.test.ts +149 -0
  241. package/src/mcp-iap.ts +131 -0
  242. package/src/media-volume.test.ts +296 -0
  243. package/src/migration-runner.ts +365 -0
  244. package/src/operator-access-stacks.test.ts +55 -0
  245. package/src/operator-access.test.ts +332 -0
  246. package/src/operator-access.ts +554 -0
  247. package/src/ops-backend.test.ts +320 -0
  248. package/src/ops-backend.ts +414 -0
  249. package/src/provisioner-identity.test.ts +482 -0
  250. package/src/provisioner-identity.ts +407 -0
  251. package/src/redirects-emit.ts +175 -0
  252. package/src/region-data.ts +428 -0
  253. package/src/regions.test.ts +277 -0
  254. package/src/regions.ts +370 -0
  255. package/src/release-contract.ts +57 -0
  256. package/src/release-manifest.test.ts +293 -0
  257. package/src/release-manifest.ts +430 -0
  258. package/src/run-mode.ts +40 -0
  259. package/src/runtime-secrets.test.ts +627 -0
  260. package/src/runtime-secrets.ts +754 -0
  261. package/src/self-command.test.ts +14 -0
  262. package/src/self-command.ts +21 -0
  263. package/src/self-hosted-compose.test.ts +177 -0
  264. package/src/self-hosted-release.ts +240 -0
  265. package/src/self-hosted-roles.integration.test.ts +161 -0
  266. package/src/self-hosted-roles.test.ts +124 -0
  267. package/src/self-hosted-roles.ts +203 -0
  268. package/src/self-hosted-upgrade.test.ts +394 -0
  269. package/src/self-hosted-upgrade.ts +548 -0
  270. package/src/stack-contract-parity.test.ts +199 -0
  271. package/src/stack-contract.test.ts +391 -0
  272. package/src/stack-contract.ts +888 -0
  273. package/src/stack-converge-secrets.test.ts +421 -0
  274. package/src/stack-converge.test.ts +833 -0
  275. package/src/stack-converge.ts +909 -0
  276. package/src/staging-admin.test.ts +246 -0
  277. package/src/staging-admin.ts +324 -0
  278. package/src/state-attach.test.ts +211 -0
  279. package/src/state-attach.ts +132 -0
  280. package/src/state-backend.test.ts +145 -0
  281. package/src/state-backend.ts +262 -0
  282. package/src/state-command.ts +293 -0
  283. package/src/state-migrate.test.ts +479 -0
  284. package/src/state-migrate.ts +434 -0
  285. package/src/state-pulumi.ts +289 -0
  286. package/src/state-store.test.ts +212 -0
  287. package/src/state-store.ts +243 -0
  288. package/src/upgrade/app-roll.ts +486 -0
  289. package/src/upgrade/backup.ts +109 -0
  290. package/src/upgrade/converge.ts +437 -0
  291. package/src/upgrade/gcp-lookup.ts +167 -0
  292. package/src/upgrade/health.test.ts +279 -0
  293. package/src/upgrade/health.ts +305 -0
  294. package/src/upgrade/release-images.test.ts +119 -0
  295. package/src/upgrade/release-images.ts +391 -0
  296. package/src/upgrade/reporter.ts +46 -0
  297. package/src/upgrade/upgrade.test.ts +626 -0
  298. package/src/upgrade/upgrade.ts +249 -0
  299. package/src/upgrader-assess.test.ts +265 -0
  300. package/src/upgrader-assess.ts +233 -0
  301. package/src/upgrader-gcp.ts +123 -0
  302. package/src/upgrader-job.test.ts +408 -0
  303. package/src/upgrader-job.ts +499 -0
  304. package/src/upgrader-reporter.test.ts +46 -0
  305. package/src/upgrader-reporter.ts +105 -0
  306. package/src/upgrader-setup.test.ts +169 -0
  307. package/src/upgrader-setup.ts +355 -0
  308. package/src/wizard.ts +425 -0
  309. package/src/wizards/gcp-cost.ts +188 -0
  310. package/src/wizards/gcp-firebase.ts +72 -0
  311. package/src/wizards/gcp-install-job.ts +506 -0
  312. package/src/wizards/gcp-prompts.ts +26 -0
  313. package/src/wizards/gcp-pulumi.test.ts +246 -0
  314. package/src/wizards/gcp-pulumi.ts +516 -0
  315. package/src/wizards/gcp.ts +1935 -0
@@ -0,0 +1,289 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The real {@link StatePulumiOps} for `state migrate` (#696): the Pulumi CLI
5
+ * (state operations) and the Automation API (previews), always in the
6
+ * install's stack dir and with exactly the backend's environment. The
7
+ * operator never runs `pulumi` by hand (CLAUDE.md §11.C).
8
+ *
9
+ * Secrets: outputs are read with `--show-secrets` only to hash them; the
10
+ * values never leave {@link outputsDigest}. The export/import fallback
11
+ * writes the deployment without `--show-secrets` (ciphertext only) to a
12
+ * mode-600 file in the install dir and deletes it afterwards.
13
+ */
14
+
15
+ import { createHash } from "node:crypto";
16
+ import { existsSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import * as automation from "@pulumi/pulumi/automation";
19
+ import { redactCredentials } from "./checkpoint-secrets.js";
20
+ import { GCP_STACK_ENV } from "./gcp-names.js";
21
+ import type { ResolvedBackend } from "./state-backend.js";
22
+ import type { StatePulumiOps } from "./state-migrate.js";
23
+ import {
24
+ alignStackSettingsFile,
25
+ applyStackConfig,
26
+ gcpStackWorkDir,
27
+ pulumiGcpEnv,
28
+ type StackConfigInputs,
29
+ type StateResource,
30
+ stateOnlyEnv,
31
+ } from "./wizards/gcp-pulumi.js";
32
+
33
+ /** Every file below `dir`. */
34
+ function listFilesRecursive(dir: string): string[] {
35
+ return readdirSync(dir, { recursive: true, withFileTypes: true })
36
+ .filter((e) => e.isFile())
37
+ .map((e) => e.name);
38
+ }
39
+
40
+ /** Canonical JSON (sorted keys) so equal outputs hash equally. */
41
+ export function canonicalJson(value: unknown): string {
42
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
43
+ if (value !== null && typeof value === "object") {
44
+ const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) =>
45
+ a < b ? -1 : a > b ? 1 : 0,
46
+ );
47
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
48
+ }
49
+ return JSON.stringify(value);
50
+ }
51
+
52
+ /** Steps of a preview's change summary other than "same". */
53
+ export function changeCount(summary: Readonly<Record<string, number | undefined>>): number {
54
+ return Object.entries(summary)
55
+ .filter(([op]) => op !== "same")
56
+ .reduce((n, [, c]) => n + (c ?? 0), 0);
57
+ }
58
+
59
+ interface Deployment {
60
+ pending_operations?: unknown[];
61
+ secrets_providers?: {
62
+ type?: string;
63
+ state?: { salt?: string; url?: string; encryptedkey?: string };
64
+ };
65
+ resources?: StateResource[];
66
+ }
67
+
68
+ export interface RealPulumiOpsInputs {
69
+ readonly provider: "gcp" | "gcp-firebase";
70
+ readonly installRoot: string;
71
+ /** The service account the GCP provider impersonates during previews. */
72
+ readonly impersonateServiceAccount: string;
73
+ /** The config a wizard run sets, so a preview shows real drift only. */
74
+ readonly stackConfig: StackConfigInputs;
75
+ }
76
+
77
+ /** {@link StatePulumiOps} backed by the `pulumi` CLI + Automation API. */
78
+ export function realStatePulumiOps(inputs: RealPulumiOpsInputs): StatePulumiOps {
79
+ const workDir = gcpStackWorkDir(inputs.provider);
80
+
81
+ const cli = async (
82
+ args: string[],
83
+ env: Record<string, string>,
84
+ ): Promise<{ stdout: string; stderr: string }> => {
85
+ const p = Bun.spawn(["pulumi", ...args, "--cwd", workDir, "--non-interactive"], {
86
+ env: { ...process.env, ...env, PULUMI_SKIP_UPDATE_CHECK: "true" },
87
+ stdout: "pipe",
88
+ stderr: "pipe",
89
+ });
90
+ const [stdout, stderr] = await Promise.all([
91
+ new Response(p.stdout).text(),
92
+ new Response(p.stderr).text(),
93
+ ]);
94
+ if ((await p.exited) !== 0) {
95
+ throw new Error(
96
+ `pulumi ${args[0]} ${args[1] ?? ""} failed: ${redactCredentials(stderr.trim().split("\n").slice(-3).join(" "))}`,
97
+ );
98
+ }
99
+ return { stdout, stderr };
100
+ };
101
+
102
+ // The read-only steps (preflight, verification) align the package's
103
+ // stack settings file and may set config to run a preview; the file is
104
+ // put back exactly as it was afterwards, so a refused migration or a dry
105
+ // run leaves it untouched.
106
+ const settingsFile = join(workDir, `Pulumi.${GCP_STACK_ENV}.yaml`);
107
+ const preserved = async <T>(fn: () => Promise<T>): Promise<T> => {
108
+ // Read without a separate existence check (no check-then-use race), and
109
+ // put the file back atomically (temp file + rename).
110
+ let before: Buffer | null;
111
+ try {
112
+ before = readFileSync(settingsFile);
113
+ } catch (e) {
114
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
115
+ before = null;
116
+ }
117
+ try {
118
+ return await fn();
119
+ } finally {
120
+ if (before === null) rmSync(settingsFile, { force: true });
121
+ else {
122
+ const tmp = `${settingsFile}.restore-${process.pid}`;
123
+ writeFileSync(tmp, before, { flag: "w" });
124
+ renameSync(tmp, settingsFile);
125
+ }
126
+ }
127
+ };
128
+
129
+ const exportDeployment = async (b: ResolvedBackend): Promise<Deployment> => {
130
+ await alignStackSettingsFile(b, inputs.provider);
131
+ const { stdout } = await cli(["stack", "export", "--stack", GCP_STACK_ENV], stateOnlyEnv(b));
132
+ return ((JSON.parse(stdout) as { deployment?: Deployment }).deployment ?? {}) as Deployment;
133
+ };
134
+
135
+ /** Env for an operation that reads one backend and writes another. */
136
+ const bothEnv = (from: ResolvedBackend, to: ResolvedBackend): Record<string, string> => ({
137
+ ...stateOnlyEnv(from),
138
+ ...stateOnlyEnv(to),
139
+ });
140
+
141
+ const ops: StatePulumiOps = {
142
+ async cliVersion() {
143
+ const p = Bun.spawnSync(["pulumi", "version"], { stdout: "pipe", stderr: "pipe" });
144
+ if (p.exitCode !== 0) return null;
145
+ return new TextDecoder().decode(p.stdout).trim().replace(/^v/, "") || null;
146
+ },
147
+
148
+ async stackExists(b) {
149
+ const { stdout } = await cli(["stack", "ls", "--json"], stateOnlyEnv(b));
150
+ const stacks = JSON.parse(stdout || "[]") as { name?: string }[];
151
+ return stacks.some((s) => s.name === GCP_STACK_ENV || s.name?.endsWith(`/${GCP_STACK_ENV}`));
152
+ },
153
+
154
+ async locked(b) {
155
+ // Pulumi's DIY backends keep one lock file per running operation under
156
+ // .pulumi/locks/ (file:// and gs:// alike).
157
+ if (b.url.startsWith("file://")) {
158
+ const dir = join(b.url.slice("file://".length), ".pulumi", "locks");
159
+ return existsSync(dir) && listFilesRecursive(dir).length > 0;
160
+ }
161
+ const r = Bun.spawnSync(["gcloud", "storage", "ls", `${b.url}/.pulumi/locks/**`], {
162
+ stdout: "pipe",
163
+ stderr: "pipe",
164
+ env: { ...process.env, CLOUDSDK_CORE_DISABLE_PROMPTS: "1" },
165
+ });
166
+ if (r.exitCode === 0) return new TextDecoder().decode(r.stdout).trim().length > 0;
167
+ const err = new TextDecoder().decode(r.stderr);
168
+ if (/matched no objects|No URLs matched|NotFound|404/i.test(err)) return false;
169
+ throw new Error(`Could not check the stack lock in ${b.url}: ${err.trim()}`);
170
+ },
171
+
172
+ async stateDigest(b) {
173
+ const deployment = await exportDeployment(b);
174
+ return createHash("sha256").update(canonicalJson(deployment)).digest("hex");
175
+ },
176
+
177
+ async pendingOperations(b) {
178
+ return (await exportDeployment(b)).pending_operations?.length ?? 0;
179
+ },
180
+
181
+ async previewChanges(b) {
182
+ await alignStackSettingsFile(b, inputs.provider);
183
+ const stack = await automation.LocalWorkspace.selectStack(
184
+ { stackName: GCP_STACK_ENV, workDir },
185
+ {
186
+ envVars: pulumiGcpEnv(process.env, {
187
+ backend: b,
188
+ impersonateServiceAccount: inputs.impersonateServiceAccount,
189
+ }),
190
+ },
191
+ );
192
+ // The stack's own config wins: an operator may have tuned a knob
193
+ // (`pulumi config set`), and resetting it would show as drift. Only a
194
+ // stack dir without the install's config (a fresh `bunx` version)
195
+ // gets what the wizard sets — as does one holding another install's
196
+ // config (installs of one provider share the package's stack dir).
197
+ const config: automation.ConfigMap = await stack.getAllConfig().catch(() => ({}));
198
+ if (config[`caelo-${inputs.provider}:project`]?.value !== inputs.stackConfig.projectId) {
199
+ const resources = ((await stack.exportStack()).deployment?.resources ??
200
+ []) as StateResource[];
201
+ await applyStackConfig(stack, inputs.stackConfig, resources);
202
+ }
203
+ const r = await stack.preview({ refresh: true });
204
+ return changeCount(r.changeSummary);
205
+ },
206
+
207
+ async outputsDigest(b) {
208
+ await alignStackSettingsFile(b, inputs.provider);
209
+ const { stdout } = await cli(
210
+ ["stack", "output", "--stack", GCP_STACK_ENV, "--json", "--show-secrets"],
211
+ stateOnlyEnv(b),
212
+ );
213
+ return createHash("sha256")
214
+ .update(canonicalJson(JSON.parse(stdout || "{}")))
215
+ .digest("hex");
216
+ },
217
+
218
+ async stackMigrate(from, to) {
219
+ await alignStackSettingsFile(from, inputs.provider);
220
+ await cli(
221
+ [
222
+ "stack",
223
+ "migrate",
224
+ from.url,
225
+ GCP_STACK_ENV,
226
+ "--secrets-provider",
227
+ to.secretsProvider,
228
+ "--yes",
229
+ ],
230
+ bothEnv(from, to),
231
+ );
232
+ },
233
+
234
+ async exportImport(from, to) {
235
+ const deployment = await exportDeployment(from);
236
+ const file = join(inputs.installRoot, `.migrate-export-${process.pid}.json`);
237
+ try {
238
+ const { stdout } = await cli(
239
+ ["stack", "export", "--stack", GCP_STACK_ENV],
240
+ stateOnlyEnv(from),
241
+ );
242
+ writeFileSync(file, stdout, { mode: 0o600 });
243
+ // The target stack starts with the source's secrets manager, so the
244
+ // imported ciphertext decrypts; change-secrets-provider re-encrypts.
245
+ const ws = await automation.LocalWorkspace.create({ workDir, envVars: stateOnlyEnv(to) });
246
+ const settings = await ws.stackSettings(GCP_STACK_ENV).catch(() => ({}));
247
+ const sp = deployment.secrets_providers;
248
+ const {
249
+ secretsProvider: _p,
250
+ encryptedKey: _k,
251
+ encryptionSalt: _s,
252
+ ...rest
253
+ } = settings as automation.StackSettings;
254
+ await ws.saveStackSettings(GCP_STACK_ENV, {
255
+ ...rest,
256
+ ...(sp?.type === "passphrase" && sp.state?.salt ? { encryptionSalt: sp.state.salt } : {}),
257
+ ...(sp?.type === "cloud" && sp.state?.url
258
+ ? { secretsProvider: sp.state.url, encryptedKey: sp.state.encryptedkey }
259
+ : {}),
260
+ });
261
+ await cli(
262
+ ["stack", "init", GCP_STACK_ENV, "--secrets-provider", from.secretsProvider],
263
+ bothEnv(from, to),
264
+ );
265
+ await cli(["stack", "import", "--stack", GCP_STACK_ENV, "--file", file], bothEnv(from, to));
266
+ await cli(
267
+ ["stack", "change-secrets-provider", "--stack", GCP_STACK_ENV, to.secretsProvider],
268
+ bothEnv(from, to),
269
+ );
270
+ } finally {
271
+ rmSync(file, { force: true });
272
+ }
273
+ },
274
+
275
+ async removeStack(b) {
276
+ await cli(
277
+ ["stack", "rm", GCP_STACK_ENV, "--force", "--yes", "--preserve-config"],
278
+ stateOnlyEnv(b),
279
+ );
280
+ },
281
+ };
282
+ return {
283
+ ...ops,
284
+ pendingOperations: (b) => preserved(() => ops.pendingOperations(b)),
285
+ stateDigest: (b) => preserved(() => ops.stateDigest(b)),
286
+ previewChanges: (b) => preserved(() => ops.previewChanges(b)),
287
+ outputsDigest: (b) => preserved(() => ops.outputsDigest(b)),
288
+ };
289
+ }
@@ -0,0 +1,212 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * install.json / progress.json stores (#696, state-store.ts) against a fake
5
+ * `gcloud storage`: the bucket is authoritative, the local copy a mirror,
6
+ * and both stores refuse secret material (#656 / #659) before anything is
7
+ * written — locally or to the bucket.
8
+ */
9
+
10
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
11
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join, resolve } from "node:path";
14
+ import type { GcloudResult } from "./gcloud.js";
15
+ import { GcsStateStore, LocalStateStore, type SyncGcloud } from "./state-store.js";
16
+
17
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
18
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
19
+
20
+ /** A bucket as a Map, reached through `gcloud storage cat|cp -`. */
21
+ function fakeBucket() {
22
+ const objects = new Map<string, string>();
23
+ const calls: string[] = [];
24
+ const run: SyncGcloud = (args, input) => {
25
+ calls.push(args.join(" "));
26
+ if (args[0] === "storage" && args[1] === "cat") {
27
+ const v = objects.get(args[2] ?? "");
28
+ return v === undefined ? fail("ERROR: One or more URLs matched no objects.") : ok(v);
29
+ }
30
+ if (args[0] === "storage" && args[1] === "cp" && args[2] === "-") {
31
+ objects.set(args[3] ?? "", input ?? "");
32
+ return ok();
33
+ }
34
+ return fail(`unexpected gcloud ${args.join(" ")}`);
35
+ };
36
+ return { objects, calls, run };
37
+ }
38
+
39
+ const PASSWORD_URL = "postgres://admin_role:hunter2hunter2@10.0.0.3:5432/cms_admin";
40
+
41
+ let root = "";
42
+ beforeEach(() => {
43
+ root = mkdtempSync(join(tmpdir(), "caelo-store-"));
44
+ });
45
+ afterEach(() => {
46
+ rmSync(root, { recursive: true, force: true });
47
+ });
48
+
49
+ describe("LocalStateStore", () => {
50
+ it("round-trips a document with mode 0600", () => {
51
+ const s = new LocalStateStore(root);
52
+ expect(s.read("install.json")).toBeNull();
53
+ s.write("install.json", { installId: "gcp-acme" });
54
+ expect(JSON.parse(s.read("install.json") ?? "")).toEqual({ installId: "gcp-acme" });
55
+ expect(statSync(join(root, "install.json")).mode & 0o777).toBe(0o600);
56
+ });
57
+
58
+ it("refuses secret material in either document", () => {
59
+ const s = new LocalStateStore(root);
60
+ expect(() => s.write("install.json", { db: PASSWORD_URL })).toThrow(/Refusing to write/);
61
+ expect(() =>
62
+ s.write("progress.json", { lastCompletedStep: null, steps: { up: { url: PASSWORD_URL } } }),
63
+ ).toThrow(/Refusing to write/);
64
+ expect(existsSync(join(root, "install.json"))).toBe(false);
65
+ });
66
+ });
67
+
68
+ describe("GcsStateStore", () => {
69
+ it("writes to the bucket first, then mirrors locally", () => {
70
+ const b = fakeBucket();
71
+ const s = new GcsStateStore("acme-caelo-ops", root, b.run);
72
+ s.write("progress.json", { lastCompletedStep: "x", steps: { x: { done: true } } });
73
+ const text = b.objects.get("gs://acme-caelo-ops/install/progress.json");
74
+ expect(JSON.parse(text ?? "")).toMatchObject({ lastCompletedStep: "x" });
75
+ expect(readFileSync(join(root, "progress.json"), "utf8")).toBe(text ?? "");
76
+ });
77
+
78
+ it("reads from the bucket, not the (possibly stale) local mirror, once per process", () => {
79
+ const b = fakeBucket();
80
+ b.objects.set("gs://acme-caelo-ops/install/install.json", '{"installId":"bucket"}\n');
81
+ writeFileSync(join(root, "install.json"), '{"installId":"stale-mirror"}\n');
82
+ const s = new GcsStateStore("acme-caelo-ops", root, b.run);
83
+ expect(JSON.parse(s.read("install.json") ?? "")).toEqual({ installId: "bucket" });
84
+ expect(JSON.parse(readFileSync(join(root, "install.json"), "utf8"))).toEqual({
85
+ installId: "bucket",
86
+ });
87
+ s.read("install.json");
88
+ expect(b.calls.filter((c) => c.startsWith("storage cat")).length).toBe(1);
89
+ });
90
+
91
+ it("peek reads the bucket without caching or touching the local mirror", () => {
92
+ const b = fakeBucket();
93
+ b.objects.set("gs://acme-caelo-ops/install/install.json", '{"installId":"someone-else"}\n');
94
+ writeFileSync(join(root, "install.json"), '{"installId":"mine"}\n');
95
+ const s = new GcsStateStore("acme-caelo-ops", root, b.run);
96
+ expect(s.peek("install.json")).toContain("someone-else");
97
+ expect(readFileSync(join(root, "install.json"), "utf8")).toContain("mine");
98
+ });
99
+
100
+ it("a missing object is null; any other bucket error throws (no silent local fallback)", () => {
101
+ const b = fakeBucket();
102
+ const s = new GcsStateStore("acme-caelo-ops", root, b.run);
103
+ expect(s.read("progress.json")).toBeNull();
104
+ const denied = new GcsStateStore("acme-caelo-ops", root, () =>
105
+ fail("ERROR: 403 does not have storage.objects.get access"),
106
+ );
107
+ expect(() => denied.read("install.json")).toThrow(/Could not read gs:\/\/acme-caelo-ops/);
108
+ });
109
+
110
+ it("refuses secret material before anything reaches the bucket (#659 guard)", () => {
111
+ const b = fakeBucket();
112
+ const s = new GcsStateStore("acme-caelo-ops", root, b.run);
113
+ expect(() =>
114
+ s.write("progress.json", {
115
+ lastCompletedStep: "pulumi-up-acme",
116
+ steps: { "pulumi-up-acme": { outputs: { adminDatabaseUrlOut: PASSWORD_URL } } },
117
+ }),
118
+ ).toThrow(/Refusing to write secret material to progress.json/);
119
+ expect(() => s.write("install.json", { installId: "x", password: "hunter2hunter2" })).toThrow(
120
+ /Refusing/,
121
+ );
122
+ expect(b.calls).toEqual([]);
123
+ expect(existsSync(join(root, "progress.json"))).toBe(false);
124
+ });
125
+
126
+ it("a failed upload changes nothing locally", () => {
127
+ const s = new GcsStateStore("acme-caelo-ops", root, () => fail("ERROR: 503 backend error"));
128
+ expect(() => s.write("install.json", { installId: "x" })).toThrow(/Could not write/);
129
+ expect(existsSync(join(root, "install.json"))).toBe(false);
130
+ });
131
+ });
132
+
133
+ describe("install-state through the stores (child process, own HOME)", () => {
134
+ it("a gcs install reads/writes the bucket; leaving it updates the bucket's install.json", () => {
135
+ // installRoot() resolves ~ via os.homedir(), read once at process start —
136
+ // drive install-state in a child process with its own HOME, and a fake
137
+ // `gcloud storage` that keeps objects in files under <home>/bucket.
138
+ const home = mkdtempSync(join(tmpdir(), "caelo-store-home-"));
139
+ try {
140
+ const mod = resolve(import.meta.dir, "install-state.ts");
141
+ const script = `
142
+ const fs = await import("node:fs");
143
+ const path = await import("node:path");
144
+ const m = await import(${JSON.stringify(mod)});
145
+ const home = ${JSON.stringify(home)};
146
+ const obj = (url) => path.join(home, "bucket", url.replace("gs://", ""));
147
+ const calls = [];
148
+ m.setStateStoreGcloud((args, input) => {
149
+ calls.push(args.slice(0, 2).join(" "));
150
+ if (args[1] === "cat") {
151
+ return fs.existsSync(obj(args[2]))
152
+ ? { ok: true, stdout: fs.readFileSync(obj(args[2]), "utf8"), stderr: "", exitCode: 0 }
153
+ : { ok: false, stdout: "", stderr: "matched no objects", exitCode: 1 };
154
+ }
155
+ fs.mkdirSync(path.dirname(obj(args[3])), { recursive: true });
156
+ fs.writeFileSync(obj(args[3]), input);
157
+ return { ok: true, stdout: "", stderr: "", exitCode: 0 };
158
+ });
159
+ const id = "gcp-acme";
160
+ m.ensureInstallDir(id);
161
+ m.writeMetadata(id, { installId: id, provider: "gcp", projectId: "acme", domain: "acme.com",
162
+ ownerEmail: "o@acme.com", region: "europe-west1", createdAt: "2026-01-01T00:00:00.000Z" });
163
+ m.markStepDone(id, "project-acme", { created: true });
164
+ const localCalls = calls.length;
165
+ m.adoptStateBackend(id, { kind: "gcs", bucket: "acme-caelo-ops",
166
+ kmsKey: "projects/acme/locations/europe-west1/keyRings/caelo/cryptoKeys/pulumi-state" });
167
+ m.markStepDone(id, "pulumi-up-acme", { outputs: { providerOut: "gcp" } });
168
+ const bucketProgress = JSON.parse(fs.readFileSync(obj("gs://acme-caelo-ops/install/progress.json"), "utf8"));
169
+ const bucketMeta = JSON.parse(fs.readFileSync(obj("gs://acme-caelo-ops/install/install.json"), "utf8"));
170
+ let refused = "";
171
+ try { m.markStepDone(id, "bad", { url: ${JSON.stringify(PASSWORD_URL)} }); } catch (e) { refused = e.message; }
172
+ // A caller holding pre-migration metadata can't switch the backend back.
173
+ m.writeMetadata(id, { ...m.readMetadata(id), stateBackend: { kind: "local" } });
174
+ const keptBackend = JSON.parse(fs.readFileSync(obj("gs://acme-caelo-ops/install/install.json"), "utf8")).stateBackend.kind;
175
+ const stateDirAfterAdopt = fs.existsSync(path.join(home, ".caelo-" + id, "state"));
176
+ m.adoptStateBackend(id, { kind: "local" });
177
+ const bucketAfterLeave = JSON.parse(fs.readFileSync(obj("gs://acme-caelo-ops/install/install.json"), "utf8"));
178
+ const callsBefore = calls.length;
179
+ m.markStepDone(id, "after-leave", { x: 1 });
180
+ console.log(JSON.stringify({
181
+ localCalls, bucketSteps: Object.keys(bucketProgress.steps), bucketBackend: bucketMeta.stateBackend.kind,
182
+ refused, stateDirAfterAdopt, keptBackend, bucketAfterLeave: bucketAfterLeave.stateBackend.kind,
183
+ callsAfterLeave: calls.length - callsBefore, done: m.isStepDone(id, "after-leave"),
184
+ listed: m.listInstalls().map((i) => i.installId),
185
+ }));
186
+ `;
187
+ const child = Bun.spawnSync([process.execPath, "-e", script], {
188
+ env: { ...process.env, HOME: home },
189
+ stderr: "pipe",
190
+ });
191
+ expect(new TextDecoder().decode(child.stderr)).toBe("");
192
+ expect(child.exitCode).toBe(0);
193
+ const r = JSON.parse(new TextDecoder().decode(child.stdout));
194
+ expect(r.localCalls).toBe(0);
195
+ expect(r.bucketSteps).toEqual(["project-acme", "pulumi-up-acme"]);
196
+ expect(r.bucketBackend).toBe("gcs");
197
+ expect(r.keptBackend).toBe("gcs");
198
+ expect(r.refused).toContain("credential-url");
199
+ expect(r.bucketAfterLeave).toBe("local");
200
+ expect(r.callsAfterLeave).toBe(0);
201
+ expect(r.done).toBe(true);
202
+ expect(r.listed).toEqual(["gcp-acme"]);
203
+ const bucketText = readFileSync(
204
+ join(home, "bucket", "acme-caelo-ops", "install", "progress.json"),
205
+ "utf8",
206
+ );
207
+ expect(bucketText).not.toContain("hunter2");
208
+ } finally {
209
+ rmSync(home, { recursive: true, force: true });
210
+ }
211
+ }, 30_000);
212
+ });