@caelo-cms/provisioning 0.10.47 → 0.10.49

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 (321) hide show
  1. package/caddy/Caddyfile.production +1 -1
  2. package/caddy/Caddyfile.staging +1 -1
  3. package/dist/agent/apply.d.ts +45 -0
  4. package/dist/agent/apply.d.ts.map +1 -0
  5. package/dist/agent/apply.js +83 -0
  6. package/dist/agent/apply.js.map +1 -0
  7. package/dist/agent/cli-provenance.d.ts +42 -0
  8. package/dist/agent/cli-provenance.d.ts.map +1 -0
  9. package/dist/agent/cli-provenance.js +138 -0
  10. package/dist/agent/cli-provenance.js.map +1 -0
  11. package/dist/agent/command.d.ts +79 -0
  12. package/dist/agent/command.d.ts.map +1 -0
  13. package/dist/agent/command.js +454 -0
  14. package/dist/agent/command.js.map +1 -0
  15. package/dist/agent/config.d.ts +30 -0
  16. package/dist/agent/config.d.ts.map +1 -0
  17. package/dist/agent/config.js +60 -0
  18. package/dist/agent/config.js.map +1 -0
  19. package/dist/agent/lock.d.ts +13 -0
  20. package/dist/agent/lock.d.ts.map +1 -0
  21. package/dist/agent/lock.js +100 -0
  22. package/dist/agent/lock.js.map +1 -0
  23. package/dist/agent/queue.d.ts +35 -0
  24. package/dist/agent/queue.d.ts.map +1 -0
  25. package/dist/agent/queue.js +50 -0
  26. package/dist/agent/queue.js.map +1 -0
  27. package/dist/agent/run.d.ts +97 -0
  28. package/dist/agent/run.d.ts.map +1 -0
  29. package/dist/agent/run.js +198 -0
  30. package/dist/agent/run.js.map +1 -0
  31. package/dist/agent/systemd.d.ts +55 -0
  32. package/dist/agent/systemd.d.ts.map +1 -0
  33. package/dist/agent/systemd.js +171 -0
  34. package/dist/agent/systemd.js.map +1 -0
  35. package/dist/agent/units.d.ts +44 -0
  36. package/dist/agent/units.d.ts.map +1 -0
  37. package/dist/agent/units.js +127 -0
  38. package/dist/agent/units.js.map +1 -0
  39. package/dist/agent-db.js +18402 -0
  40. package/dist/cli.js +119 -16
  41. package/dist/cli.js.map +1 -1
  42. package/dist/cloudsql-iam-user.d.ts +39 -0
  43. package/dist/cloudsql-iam-user.d.ts.map +1 -0
  44. package/dist/cloudsql-iam-user.js +119 -0
  45. package/dist/cloudsql-iam-user.js.map +1 -0
  46. package/dist/compose.d.ts +16 -0
  47. package/dist/compose.d.ts.map +1 -1
  48. package/dist/compose.js +26 -1
  49. package/dist/compose.js.map +1 -1
  50. package/dist/dns/index.d.ts +2 -0
  51. package/dist/dns/index.d.ts.map +1 -1
  52. package/dist/dns/index.js +3 -2
  53. package/dist/dns/index.js.map +1 -1
  54. package/dist/dns/manual.d.ts +5 -0
  55. package/dist/dns/manual.d.ts.map +1 -1
  56. package/dist/dns/manual.js +28 -12
  57. package/dist/dns/manual.js.map +1 -1
  58. package/dist/gcloud.d.ts +2 -0
  59. package/dist/gcloud.d.ts.map +1 -1
  60. package/dist/gcloud.js +1 -1
  61. package/dist/gcloud.js.map +1 -1
  62. package/dist/gcp-names.d.ts +15 -0
  63. package/dist/gcp-names.d.ts.map +1 -1
  64. package/dist/gcp-names.js +23 -0
  65. package/dist/gcp-names.js.map +1 -1
  66. package/dist/index.d.ts +6 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +4 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/infra-contract.d.ts +1 -1
  71. package/dist/infra-contract.js +7 -1
  72. package/dist/infra-contract.js.map +1 -1
  73. package/dist/install-job-runner.d.ts +13 -0
  74. package/dist/install-job-runner.d.ts.map +1 -0
  75. package/dist/install-job-runner.js +155 -0
  76. package/dist/install-job-runner.js.map +1 -0
  77. package/dist/install-job.d.ts +210 -0
  78. package/dist/install-job.d.ts.map +1 -0
  79. package/dist/install-job.js +449 -0
  80. package/dist/install-job.js.map +1 -0
  81. package/dist/install-state.d.ts +10 -1
  82. package/dist/install-state.d.ts.map +1 -1
  83. package/dist/install-state.js +10 -2
  84. package/dist/install-state.js.map +1 -1
  85. package/dist/lifecycle.d.ts.map +1 -1
  86. package/dist/lifecycle.js +19 -4
  87. package/dist/lifecycle.js.map +1 -1
  88. package/dist/migration-runner.d.ts +5 -0
  89. package/dist/migration-runner.d.ts.map +1 -1
  90. package/dist/migration-runner.js +1 -1
  91. package/dist/migration-runner.js.map +1 -1
  92. package/dist/operator-access.d.ts +14 -8
  93. package/dist/operator-access.d.ts.map +1 -1
  94. package/dist/operator-access.js +16 -102
  95. package/dist/operator-access.js.map +1 -1
  96. package/dist/release-manifest.d.ts +2 -41
  97. package/dist/release-manifest.d.ts.map +1 -1
  98. package/dist/release-manifest.js +18 -121
  99. package/dist/release-manifest.js.map +1 -1
  100. package/dist/run-mode.d.ts +27 -0
  101. package/dist/run-mode.d.ts.map +1 -0
  102. package/dist/run-mode.js +22 -0
  103. package/dist/run-mode.js.map +1 -0
  104. package/dist/runtime-secrets.d.ts +19 -0
  105. package/dist/runtime-secrets.d.ts.map +1 -1
  106. package/dist/runtime-secrets.js +44 -0
  107. package/dist/runtime-secrets.js.map +1 -1
  108. package/dist/self-hosted-roles.d.ts +22 -1
  109. package/dist/self-hosted-roles.d.ts.map +1 -1
  110. package/dist/self-hosted-roles.js +81 -12
  111. package/dist/self-hosted-roles.js.map +1 -1
  112. package/dist/self-hosted-upgrade.d.ts +17 -0
  113. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  114. package/dist/self-hosted-upgrade.js +16 -1
  115. package/dist/self-hosted-upgrade.js.map +1 -1
  116. package/dist/stack-contract.d.ts +6 -0
  117. package/dist/stack-contract.d.ts.map +1 -1
  118. package/dist/stack-contract.js +10 -1
  119. package/dist/stack-contract.js.map +1 -1
  120. package/dist/upgrade/converge.d.ts +6 -0
  121. package/dist/upgrade/converge.d.ts.map +1 -1
  122. package/dist/upgrade/converge.js +59 -2
  123. package/dist/upgrade/converge.js.map +1 -1
  124. package/dist/upgrade/gcp-lookup.d.ts +6 -0
  125. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  126. package/dist/upgrade/gcp-lookup.js +25 -0
  127. package/dist/upgrade/gcp-lookup.js.map +1 -1
  128. package/dist/upgrade/release-images.d.ts +26 -2
  129. package/dist/upgrade/release-images.d.ts.map +1 -1
  130. package/dist/upgrade/release-images.js +35 -2
  131. package/dist/upgrade/release-images.js.map +1 -1
  132. package/dist/upgrade/upgrade.d.ts +5 -0
  133. package/dist/upgrade/upgrade.d.ts.map +1 -1
  134. package/dist/upgrade/upgrade.js +28 -1
  135. package/dist/upgrade/upgrade.js.map +1 -1
  136. package/dist/upgrader-assess.d.ts +85 -0
  137. package/dist/upgrader-assess.d.ts.map +1 -0
  138. package/dist/upgrader-assess.js +122 -0
  139. package/dist/upgrader-assess.js.map +1 -0
  140. package/dist/upgrader-gcp.d.ts +17 -0
  141. package/dist/upgrader-gcp.d.ts.map +1 -0
  142. package/dist/upgrader-gcp.js +87 -0
  143. package/dist/upgrader-gcp.js.map +1 -0
  144. package/dist/upgrader-job.d.ts +133 -0
  145. package/dist/upgrader-job.d.ts.map +1 -0
  146. package/dist/upgrader-job.js +259 -0
  147. package/dist/upgrader-job.js.map +1 -0
  148. package/dist/upgrader-reporter.d.ts +35 -0
  149. package/dist/upgrader-reporter.d.ts.map +1 -0
  150. package/dist/upgrader-reporter.js +79 -0
  151. package/dist/upgrader-reporter.js.map +1 -0
  152. package/dist/upgrader-setup.d.ts +59 -0
  153. package/dist/upgrader-setup.d.ts.map +1 -0
  154. package/dist/upgrader-setup.js +308 -0
  155. package/dist/upgrader-setup.js.map +1 -0
  156. package/dist/wizard.d.ts +6 -0
  157. package/dist/wizard.d.ts.map +1 -1
  158. package/dist/wizard.js +32 -4
  159. package/dist/wizard.js.map +1 -1
  160. package/dist/wizards/gcp-firebase.d.ts +3 -0
  161. package/dist/wizards/gcp-firebase.d.ts.map +1 -1
  162. package/dist/wizards/gcp-firebase.js +1 -0
  163. package/dist/wizards/gcp-firebase.js.map +1 -1
  164. package/dist/wizards/gcp-install-job.d.ts +74 -0
  165. package/dist/wizards/gcp-install-job.d.ts.map +1 -0
  166. package/dist/wizards/gcp-install-job.js +398 -0
  167. package/dist/wizards/gcp-install-job.js.map +1 -0
  168. package/dist/wizards/gcp-prompts.d.ts +3 -0
  169. package/dist/wizards/gcp-prompts.d.ts.map +1 -0
  170. package/dist/wizards/gcp-prompts.js +25 -0
  171. package/dist/wizards/gcp-prompts.js.map +1 -0
  172. package/dist/wizards/gcp-pulumi.d.ts +4 -2
  173. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  174. package/dist/wizards/gcp-pulumi.js +1 -1
  175. package/dist/wizards/gcp-pulumi.js.map +1 -1
  176. package/dist/wizards/gcp.d.ts +40 -0
  177. package/dist/wizards/gcp.d.ts.map +1 -1
  178. package/dist/wizards/gcp.js +223 -59
  179. package/dist/wizards/gcp.js.map +1 -1
  180. package/package.json +6 -4
  181. package/src/adapter.ts +97 -0
  182. package/src/adc-impersonation.ts +176 -0
  183. package/src/admin-domain.test.ts +254 -0
  184. package/src/admin-domain.ts +245 -0
  185. package/src/admin-memory.test.ts +151 -0
  186. package/src/agent/apply.test.ts +185 -0
  187. package/src/agent/apply.ts +124 -0
  188. package/src/agent/cli-provenance.test.ts +159 -0
  189. package/src/agent/cli-provenance.ts +191 -0
  190. package/src/agent/command.test.ts +77 -0
  191. package/src/agent/command.ts +564 -0
  192. package/src/agent/config.ts +86 -0
  193. package/src/agent/lock-config.test.ts +97 -0
  194. package/src/agent/lock.ts +110 -0
  195. package/src/agent/queue.test.ts +10 -0
  196. package/src/agent/queue.ts +91 -0
  197. package/src/agent/run.test.ts +437 -0
  198. package/src/agent/run.ts +295 -0
  199. package/src/agent/systemd.ts +209 -0
  200. package/src/agent/units.test.ts +223 -0
  201. package/src/agent/units.ts +163 -0
  202. package/src/azure-front-door.test.ts +119 -0
  203. package/src/azure-front-door.ts +56 -0
  204. package/src/bootstrap-token.ts +20 -0
  205. package/src/caddy.ts +112 -0
  206. package/src/cdn-copy.ts +91 -0
  207. package/src/cert-wait.test.ts +194 -0
  208. package/src/cert-wait.ts +163 -0
  209. package/src/checkpoint-secrets.test.ts +380 -0
  210. package/src/checkpoint-secrets.ts +283 -0
  211. package/src/cli-routing.test.ts +45 -0
  212. package/src/cli-routing.ts +43 -0
  213. package/src/cli.ts +1491 -0
  214. package/src/cloudsql-iam-user.ts +147 -0
  215. package/src/compose.ts +334 -0
  216. package/src/default-firewall.test.ts +411 -0
  217. package/src/default-firewall.ts +342 -0
  218. package/src/deployed-release.test.ts +231 -0
  219. package/src/deployed-release.ts +220 -0
  220. package/src/dns/cloudflare.ts +182 -0
  221. package/src/dns/groups.test.ts +76 -0
  222. package/src/dns/groups.ts +86 -0
  223. package/src/dns/index.ts +56 -0
  224. package/src/dns/manual.ts +130 -0
  225. package/src/dns/types.ts +27 -0
  226. package/src/gateway-credentials.test.ts +180 -0
  227. package/src/gateway-credentials.ts +144 -0
  228. package/src/gcloud-components.test.ts +76 -0
  229. package/src/gcloud-components.ts +85 -0
  230. package/src/gcloud-retry.ts +41 -0
  231. package/src/gcloud-runner.test.ts +61 -0
  232. package/src/gcloud.test.ts +99 -0
  233. package/src/gcloud.ts +322 -0
  234. package/src/gcp-names.ts +135 -0
  235. package/src/index.test.ts +332 -0
  236. package/src/index.ts +114 -0
  237. package/src/infra-contract.ts +32 -0
  238. package/src/install-job-runner.ts +166 -0
  239. package/src/install-job.test.ts +566 -0
  240. package/src/install-job.ts +542 -0
  241. package/src/install-state.test.ts +239 -0
  242. package/src/install-state.ts +583 -0
  243. package/src/internal-token.ts +35 -0
  244. package/src/lifecycle.ts +900 -0
  245. package/src/mcp-iap-stacks.test.ts +58 -0
  246. package/src/mcp-iap.test.ts +149 -0
  247. package/src/mcp-iap.ts +131 -0
  248. package/src/media-volume.test.ts +296 -0
  249. package/src/migration-runner.ts +365 -0
  250. package/src/operator-access-stacks.test.ts +55 -0
  251. package/src/operator-access.test.ts +332 -0
  252. package/src/operator-access.ts +554 -0
  253. package/src/ops-backend.test.ts +320 -0
  254. package/src/ops-backend.ts +414 -0
  255. package/src/provisioner-identity.test.ts +482 -0
  256. package/src/provisioner-identity.ts +407 -0
  257. package/src/redirects-emit.ts +175 -0
  258. package/src/region-data.ts +428 -0
  259. package/src/regions.test.ts +277 -0
  260. package/src/regions.ts +370 -0
  261. package/src/release-contract.ts +57 -0
  262. package/src/release-manifest.test.ts +300 -0
  263. package/src/release-manifest.ts +310 -0
  264. package/src/run-mode.ts +40 -0
  265. package/src/runtime-secrets.test.ts +627 -0
  266. package/src/runtime-secrets.ts +754 -0
  267. package/src/self-command.test.ts +14 -0
  268. package/src/self-command.ts +21 -0
  269. package/src/self-hosted-compose.test.ts +177 -0
  270. package/src/self-hosted-release.ts +240 -0
  271. package/src/self-hosted-roles.integration.test.ts +161 -0
  272. package/src/self-hosted-roles.test.ts +124 -0
  273. package/src/self-hosted-roles.ts +203 -0
  274. package/src/self-hosted-upgrade.test.ts +394 -0
  275. package/src/self-hosted-upgrade.ts +548 -0
  276. package/src/stack-contract-parity.test.ts +199 -0
  277. package/src/stack-contract.test.ts +391 -0
  278. package/src/stack-contract.ts +888 -0
  279. package/src/stack-converge-secrets.test.ts +421 -0
  280. package/src/stack-converge.test.ts +833 -0
  281. package/src/stack-converge.ts +909 -0
  282. package/src/staging-admin.test.ts +246 -0
  283. package/src/staging-admin.ts +324 -0
  284. package/src/state-attach.test.ts +211 -0
  285. package/src/state-attach.ts +132 -0
  286. package/src/state-backend.test.ts +145 -0
  287. package/src/state-backend.ts +262 -0
  288. package/src/state-command.ts +293 -0
  289. package/src/state-migrate.test.ts +479 -0
  290. package/src/state-migrate.ts +434 -0
  291. package/src/state-pulumi.ts +289 -0
  292. package/src/state-store.test.ts +212 -0
  293. package/src/state-store.ts +243 -0
  294. package/src/upgrade/app-roll.ts +486 -0
  295. package/src/upgrade/backup.ts +109 -0
  296. package/src/upgrade/converge.ts +437 -0
  297. package/src/upgrade/gcp-lookup.ts +167 -0
  298. package/src/upgrade/health.test.ts +279 -0
  299. package/src/upgrade/health.ts +305 -0
  300. package/src/upgrade/release-images.test.ts +119 -0
  301. package/src/upgrade/release-images.ts +393 -0
  302. package/src/upgrade/reporter.ts +46 -0
  303. package/src/upgrade/upgrade.test.ts +626 -0
  304. package/src/upgrade/upgrade.ts +249 -0
  305. package/src/upgrader-assess.test.ts +306 -0
  306. package/src/upgrader-assess.ts +250 -0
  307. package/src/upgrader-gcp.ts +123 -0
  308. package/src/upgrader-job.test.ts +411 -0
  309. package/src/upgrader-job.ts +502 -0
  310. package/src/upgrader-reporter.test.ts +46 -0
  311. package/src/upgrader-reporter.ts +105 -0
  312. package/src/upgrader-setup.test.ts +169 -0
  313. package/src/upgrader-setup.ts +355 -0
  314. package/src/wizard.ts +425 -0
  315. package/src/wizards/gcp-cost.ts +188 -0
  316. package/src/wizards/gcp-firebase.ts +72 -0
  317. package/src/wizards/gcp-install-job.ts +506 -0
  318. package/src/wizards/gcp-prompts.ts +26 -0
  319. package/src/wizards/gcp-pulumi.test.ts +246 -0
  320. package/src/wizards/gcp-pulumi.ts +516 -0
  321. package/src/wizards/gcp.ts +1935 -0
@@ -0,0 +1,479 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * `state migrate` (#696, state-migrate.ts) against a fake Pulumi workspace
5
+ * (backends as a Map) and fake side effects. Load-bearing: a round trip
6
+ * local → remote → local keeps the stack identical and flips install.json
7
+ * only after verification; any mismatch or failure leaves the source
8
+ * authoritative and the copy discarded; a dry run changes nothing; an
9
+ * unconverged or interrupted stack is refused before anything is touched.
10
+ */
11
+
12
+ import { describe, expect, it } from "bun:test";
13
+ import {
14
+ gcsBackendRecord,
15
+ localStateNotice,
16
+ type ResolvedBackend,
17
+ resolveBackend,
18
+ type StateBackendRecord,
19
+ } from "./state-backend.js";
20
+ import {
21
+ type MigrateDeps,
22
+ type MigrateInstall,
23
+ migrateState,
24
+ type StatePulumiOps,
25
+ versionAtLeast,
26
+ } from "./state-migrate.js";
27
+ import { canonicalJson, changeCount } from "./state-pulumi.js";
28
+ import type { UpgradeReporter } from "./upgrade/reporter.js";
29
+
30
+ interface FakeStack {
31
+ outputs: Record<string, unknown>;
32
+ secretsProvider: string;
33
+ pending: number;
34
+ drift: number;
35
+ }
36
+
37
+ const ROOT = "/home/op/.caelo-gcp-firebase-acme";
38
+ const GCS = gcsBackendRecord("acme", "europe-west1");
39
+
40
+ function fakePulumi(version = "3.260.0") {
41
+ const stacks = new Map<string, FakeStack>();
42
+ const calls: string[] = [];
43
+ const faults: {
44
+ corruptCopy: boolean;
45
+ copyFails: boolean;
46
+ driftOnTarget: number;
47
+ onStateDigest?: () => void;
48
+ lockedUrl?: string;
49
+ } = { corruptCopy: false, copyFails: false, driftOnTarget: 0 };
50
+ const ops: StatePulumiOps = {
51
+ async cliVersion() {
52
+ return version;
53
+ },
54
+ async stackExists(b) {
55
+ return stacks.has(b.url);
56
+ },
57
+ async pendingOperations(b) {
58
+ return stacks.get(b.url)?.pending ?? 0;
59
+ },
60
+ async previewChanges(b) {
61
+ const s = stacks.get(b.url);
62
+ if (!s) throw new Error(`no stack in ${b.url}`);
63
+ return s.drift;
64
+ },
65
+ async locked(b) {
66
+ return faults.lockedUrl === b.url;
67
+ },
68
+ async stateDigest(b) {
69
+ const s = stacks.get(b.url);
70
+ if (!s) throw new Error(`no stack in ${b.url}`);
71
+ faults.onStateDigest?.();
72
+ return canonicalJson({ ...s, drift: 0 });
73
+ },
74
+ async outputsDigest(b) {
75
+ const s = stacks.get(b.url);
76
+ if (!s) throw new Error(`no stack in ${b.url}`);
77
+ return canonicalJson(s.outputs);
78
+ },
79
+ async stackMigrate(from, to) {
80
+ calls.push(`migrate ${from.url} -> ${to.url} (${to.secretsProvider})`);
81
+ copy(from, to);
82
+ },
83
+ async exportImport(from, to) {
84
+ calls.push(`export-import ${from.url} -> ${to.url} (${to.secretsProvider})`);
85
+ copy(from, to);
86
+ },
87
+ async removeStack(b) {
88
+ calls.push(`rm ${b.url}`);
89
+ stacks.delete(b.url);
90
+ },
91
+ };
92
+ function copy(from: ResolvedBackend, to: ResolvedBackend) {
93
+ if (faults.copyFails) throw new Error("backend unavailable");
94
+ const s = stacks.get(from.url);
95
+ if (!s) throw new Error("no source");
96
+ stacks.set(to.url, {
97
+ outputs: faults.corruptCopy
98
+ ? { ...s.outputs, adminUrl: "https://changed" }
99
+ : { ...s.outputs },
100
+ secretsProvider: to.secretsProvider,
101
+ pending: 0,
102
+ drift: faults.driftOnTarget,
103
+ });
104
+ }
105
+ return { ops, stacks, calls, faults };
106
+ }
107
+
108
+ function recorder(): UpgradeReporter & { lines: string[] } {
109
+ const lines: string[] = [];
110
+ return {
111
+ lines,
112
+ step: (label) => {
113
+ lines.push(`step ${label}`);
114
+ return {
115
+ done: (m) => lines.push(`done ${m}`),
116
+ failed: (m) => lines.push(`failed ${m}`),
117
+ warned: (m) => lines.push(`warned ${m}`),
118
+ };
119
+ },
120
+ info: (m) => lines.push(`info ${m}`),
121
+ warn: (m) => lines.push(`warn ${m}`),
122
+ error: (m) => lines.push(`error ${m}`),
123
+ success: (m) => lines.push(`success ${m}`),
124
+ };
125
+ }
126
+
127
+ /** A machine: install.json's backend, the passphrase, the local dir. */
128
+ function machine(initial: StateBackendRecord = { kind: "local" }) {
129
+ const p = fakePulumi();
130
+ const m = {
131
+ backend: initial,
132
+ passphrase: "pp" as string | null,
133
+ backups: 0,
134
+ retired: [] as string[],
135
+ ensureRemoteCalls: [] as boolean[],
136
+ /** What the bucket's install.json names. */
137
+ locked: false,
138
+ acceptCost: true,
139
+ bucketRecord: (initial.kind === "gcs" ? "gcs" : null) as "gcs" | "local" | null,
140
+ };
141
+ const meta = (): MigrateInstall => ({
142
+ installId: "gcp-firebase-acme",
143
+ provider: "gcp-firebase",
144
+ projectId: "acme",
145
+ region: "europe-west1",
146
+ stateBackend: m.backend,
147
+ });
148
+ const report = recorder();
149
+ const localUrl = `file://${ROOT}/state`;
150
+ const deps: MigrateDeps = {
151
+ pulumi: p.ops,
152
+ report,
153
+ resolve: (record) =>
154
+ resolveBackend(
155
+ { installId: "gcp-firebase-acme", stateBackend: record },
156
+ { installRoot: ROOT, readPassphrase: () => m.passphrase },
157
+ ),
158
+ confirmCost: async () => m.acceptCost,
159
+ ensureRemote: async (dry) => {
160
+ m.ensureRemoteCalls.push(dry);
161
+ return { ok: true, record: GCS, actions: dry ? ["would create gs://acme-caelo-ops"] : [] };
162
+ },
163
+ backup: async () => {
164
+ m.backups++;
165
+ return "/home/op/.caelo-backups/gcp-firebase-acme-x.tar.zst";
166
+ },
167
+ localStateEmpty: () => !p.stacks.has(localUrl),
168
+ prepareLocalTarget: () => {
169
+ m.passphrase ??= "new-pp";
170
+ },
171
+ remoteRecordedBackend: () => m.bucketRecord,
172
+ lock: async () => {
173
+ if (m.locked) throw new Error("Another state migration of this install is running");
174
+ m.locked = true;
175
+ return async () => {
176
+ m.locked = false;
177
+ };
178
+ },
179
+ adopt: (record) => {
180
+ m.backend = record;
181
+ m.bucketRecord = record.kind;
182
+ },
183
+ retireLocalState: () => {
184
+ const s = p.stacks.get(localUrl);
185
+ if (!s) return null;
186
+ // Renamed: the stack no longer sits at the backend URL.
187
+ p.stacks.delete(localUrl);
188
+ m.retired.push(`${ROOT}/state.migrated-x`);
189
+ return `${ROOT}/state.migrated-x`;
190
+ },
191
+ };
192
+ p.stacks.set(localUrl, {
193
+ outputs: {
194
+ adminUrl: "https://admin.acme.com",
195
+ bootstrapUrlOut: "https://x/setup?token=s3cret",
196
+ },
197
+ secretsProvider: "passphrase",
198
+ pending: 0,
199
+ drift: 0,
200
+ });
201
+ return { p, m, meta, deps, report, localUrl, gcsUrl: "gs://acme-caelo-ops/pulumi" };
202
+ }
203
+
204
+ describe("migrateState — round trip", () => {
205
+ it("local → remote → local keeps the stack, flips install.json only after verification", async () => {
206
+ const t = machine();
207
+ const up = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
208
+ expect(up).toEqual({ ok: true, changed: true });
209
+ expect(t.m.backend).toEqual(GCS);
210
+ expect(t.m.backups).toBe(1);
211
+ expect(t.p.stacks.get(t.gcsUrl)?.secretsProvider).toBe(
212
+ "gcpkms://projects/acme/locations/europe-west1/keyRings/caelo/cryptoKeys/pulumi-state",
213
+ );
214
+ expect(t.m.retired).toEqual([`${ROOT}/state.migrated-x`]);
215
+ expect(t.p.calls[0]).toContain("migrate file://");
216
+
217
+ const down = await migrateState(t.meta(), { to: "local", dryRun: false }, t.deps);
218
+ expect(down).toEqual({ ok: true, changed: true });
219
+ expect(t.m.backend).toEqual({ kind: "local" });
220
+ const back = t.p.stacks.get(t.localUrl);
221
+ expect(back?.secretsProvider).toBe("passphrase");
222
+ expect(back?.outputs.bootstrapUrlOut).toBe("https://x/setup?token=s3cret");
223
+ // No secret ends up in the progress output.
224
+ expect(t.report.lines.join("\n")).not.toContain("s3cret");
225
+ });
226
+
227
+ it("is idempotent: migrating to where the state already is changes nothing", async () => {
228
+ const t = machine(GCS);
229
+ const r = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
230
+ expect(r).toEqual({ ok: true, changed: false });
231
+ expect(t.m.backups).toBe(0);
232
+ expect(t.p.calls).toEqual([]);
233
+ });
234
+
235
+ it("uses export/import on a Pulumi CLI older than 3.254.0", async () => {
236
+ const t = machine();
237
+ t.deps.pulumi.cliVersion = async () => "3.234.0";
238
+ const r = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
239
+ expect(r.ok).toBe(true);
240
+ expect(t.p.calls.some((c) => c.startsWith("export-import"))).toBe(true);
241
+ });
242
+
243
+ it("removes a leftover copy from an interrupted run before copying", async () => {
244
+ const t = machine();
245
+ t.p.stacks.set(t.gcsUrl, {
246
+ outputs: { stale: true },
247
+ secretsProvider: "x",
248
+ pending: 0,
249
+ drift: 0,
250
+ });
251
+ const r = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
252
+ expect(r.ok).toBe(true);
253
+ expect(t.p.calls[0]).toBe(`rm ${t.gcsUrl}`);
254
+ expect(t.p.stacks.get(t.gcsUrl)?.outputs.stale).toBeUndefined();
255
+ });
256
+ });
257
+
258
+ describe("migrateState — failures leave the source authoritative", () => {
259
+ it("an outputs mismatch discards the copy and never flips install.json", async () => {
260
+ const t = machine();
261
+ t.p.faults.corruptCopy = true;
262
+ const r = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
263
+ expect(r.ok).toBe(false);
264
+ if (r.ok) return;
265
+ expect(r.error).toContain("outputs differ");
266
+ expect(r.error).toContain("stays authoritative");
267
+ expect(t.m.backend).toEqual({ kind: "local" });
268
+ expect(t.p.stacks.has(t.gcsUrl)).toBe(false);
269
+ expect(t.p.stacks.has(t.localUrl)).toBe(true);
270
+ expect(t.m.retired).toEqual([]);
271
+ });
272
+
273
+ it("a source updated by another run during the copy aborts before the switch", async () => {
274
+ const t = machine();
275
+ let reads = 0;
276
+ t.p.faults.onStateDigest = () => {
277
+ reads++;
278
+ // Second read (just before the switch): an installer run elsewhere wrote the source.
279
+ if (reads === 2) {
280
+ const src = t.p.stacks.get(t.localUrl);
281
+ if (src) src.outputs = { ...src.outputs, extra: 1 };
282
+ }
283
+ };
284
+ const r = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
285
+ expect(r.ok).toBe(false);
286
+ if (!r.ok) expect(r.error).toContain("changed during the migration");
287
+ expect(t.m.backend).toEqual({ kind: "local" });
288
+ expect(t.p.stacks.has(t.gcsUrl)).toBe(false);
289
+ });
290
+
291
+ it("refuses while a Pulumi run holds the source stack's lock", async () => {
292
+ const t = machine();
293
+ t.p.faults.lockedUrl = t.localUrl;
294
+ const r = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
295
+ expect(r.ok).toBe(false);
296
+ if (!r.ok) expect(r.error).toContain("updating this stack right now");
297
+ expect(t.m.backups).toBe(0);
298
+ });
299
+
300
+ it("a copy whose preview shows changes is discarded", async () => {
301
+ const t = machine();
302
+ t.p.faults.driftOnTarget = 2;
303
+ const r = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
304
+ expect(r.ok).toBe(false);
305
+ expect(t.m.backend).toEqual({ kind: "local" });
306
+ expect(t.p.stacks.has(t.gcsUrl)).toBe(false);
307
+ });
308
+
309
+ it("a failed copy changes nothing", async () => {
310
+ const t = machine();
311
+ t.p.faults.copyFails = true;
312
+ const r = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
313
+ expect(r.ok).toBe(false);
314
+ expect(t.m.backend).toEqual({ kind: "local" });
315
+ expect(t.p.stacks.has(t.localUrl)).toBe(true);
316
+ });
317
+
318
+ it("refuses an unconverged stack with 'run upgrade first', before any backup", async () => {
319
+ const t = machine();
320
+ const local = t.p.stacks.get(t.localUrl);
321
+ if (local) local.drift = 3;
322
+ const r = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
323
+ expect(r.ok).toBe(false);
324
+ if (r.ok) return;
325
+ expect(r.error).toContain("Run upgrade first");
326
+ expect(t.m.backups).toBe(0);
327
+ expect(t.m.ensureRemoteCalls).toEqual([]);
328
+ });
329
+
330
+ it("refuses a stack with pending operations", async () => {
331
+ const t = machine();
332
+ const local = t.p.stacks.get(t.localUrl);
333
+ if (local) local.pending = 1;
334
+ const r = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
335
+ expect(r.ok).toBe(false);
336
+ if (r.ok) return;
337
+ expect(r.error).toContain("pending operation");
338
+ expect(t.p.calls).toEqual([]);
339
+ });
340
+
341
+ it("--to local refuses when another machine already moved the state off the bucket", async () => {
342
+ const t = machine(GCS);
343
+ t.p.stacks.delete(t.localUrl);
344
+ t.p.stacks.set(t.gcsUrl, { outputs: { a: 1 }, secretsProvider: "kms", pending: 0, drift: 0 });
345
+ t.m.bucketRecord = "local";
346
+ const r = await migrateState(t.meta(), { to: "local", dryRun: false }, t.deps);
347
+ expect(r.ok).toBe(false);
348
+ expect(t.p.calls).toEqual([]);
349
+ expect(t.m.backend).toEqual(GCS);
350
+ });
351
+
352
+ it("never replaces a bucket copy another machine made authoritative", async () => {
353
+ const t = machine();
354
+ t.m.bucketRecord = "gcs";
355
+ t.p.stacks.set(t.gcsUrl, {
356
+ outputs: { newer: true },
357
+ secretsProvider: "kms",
358
+ pending: 0,
359
+ drift: 0,
360
+ });
361
+ const r = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
362
+ expect(r.ok).toBe(false);
363
+ if (!r.ok) expect(r.error).toContain("attach --project acme");
364
+ expect(t.p.stacks.get(t.gcsUrl)?.outputs.newer).toBe(true);
365
+ expect(t.p.calls).toEqual([]);
366
+ expect(t.m.backups).toBe(0);
367
+ });
368
+
369
+ it("self-hosted has nothing to migrate", async () => {
370
+ const t = machine();
371
+ const r = await migrateState(
372
+ { ...t.meta(), provider: "self-hosted" },
373
+ { to: "remote", dryRun: false },
374
+ t.deps,
375
+ );
376
+ expect(r.ok).toBe(false);
377
+ });
378
+ });
379
+
380
+ describe("migrateState — interrupted --to local resumes", () => {
381
+ it("moves an unused local stack aside (kept) and copies again", async () => {
382
+ const t = machine(GCS);
383
+ const s = t.p.stacks.get(t.localUrl);
384
+ if (s) t.p.stacks.set(t.gcsUrl, { ...s, secretsProvider: "kms" });
385
+ // The local stack is the leftover of the interrupted run.
386
+ const r = await migrateState(t.meta(), { to: "local", dryRun: false }, t.deps);
387
+ expect(r).toEqual({ ok: true, changed: true });
388
+ expect(t.m.retired).toEqual([`${ROOT}/state.migrated-x`]);
389
+ expect(t.m.backend).toEqual({ kind: "local" });
390
+ expect(t.p.stacks.get(t.localUrl)?.secretsProvider).toBe("passphrase");
391
+ });
392
+ });
393
+
394
+ describe("migrateState — one migration at a time", () => {
395
+ it("refuses while another migration holds the lock, and releases its own", async () => {
396
+ const t = machine();
397
+ t.m.locked = true;
398
+ const r = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
399
+ expect(r.ok).toBe(false);
400
+ expect(t.m.backend).toEqual({ kind: "local" });
401
+ expect(t.p.calls).toEqual([]);
402
+ t.m.locked = false;
403
+ expect((await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps)).ok).toBe(true);
404
+ expect(t.m.locked).toBe(false);
405
+ });
406
+
407
+ it("re-checks authority under the lock: a run that lost the race removes nothing", async () => {
408
+ const t = machine();
409
+ // The other machine finishes between this run's preflight and its lock.
410
+ t.deps.lock = async () => {
411
+ t.m.bucketRecord = "gcs";
412
+ t.p.stacks.set(t.gcsUrl, {
413
+ outputs: { theirs: true },
414
+ secretsProvider: "kms",
415
+ pending: 0,
416
+ drift: 0,
417
+ });
418
+ return async () => {};
419
+ };
420
+ const r = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
421
+ expect(r.ok).toBe(false);
422
+ expect(t.p.stacks.get(t.gcsUrl)?.outputs.theirs).toBe(true);
423
+ expect(t.p.calls).toEqual([]);
424
+ });
425
+ });
426
+
427
+ describe("migrateState — cost confirmation (CLAUDE.md §11.C)", () => {
428
+ it("creates nothing billable when the operator declines", async () => {
429
+ const t = machine();
430
+ t.m.acceptCost = false;
431
+ const r = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
432
+ expect(r.ok).toBe(false);
433
+ expect(t.m.ensureRemoteCalls).toEqual([]);
434
+ expect(t.m.backend).toEqual({ kind: "local" });
435
+ });
436
+ });
437
+
438
+ describe("migrateState — dry run", () => {
439
+ it("reads, reports the plan and changes nothing", async () => {
440
+ const t = machine();
441
+ const r = await migrateState(t.meta(), { to: "remote", dryRun: true }, t.deps);
442
+ expect(r).toEqual({ ok: true, changed: false });
443
+ expect(t.m.backend).toEqual({ kind: "local" });
444
+ expect(t.m.backups).toBe(0);
445
+ expect(t.m.ensureRemoteCalls).toEqual([true]);
446
+ expect(t.p.calls).toEqual([]);
447
+ expect(t.p.stacks.has(t.gcsUrl)).toBe(false);
448
+ const out = t.report.lines.join("\n");
449
+ expect(out).toContain("Dry run — nothing changed");
450
+ expect(out).toContain("would create gs://acme-caelo-ops");
451
+ });
452
+ });
453
+
454
+ describe("helpers", () => {
455
+ it("versionAtLeast", () => {
456
+ expect(versionAtLeast("3.254.0", "3.254.0")).toBe(true);
457
+ expect(versionAtLeast("v3.260.1", "3.254.0")).toBe(true);
458
+ expect(versionAtLeast("3.234.0", "3.254.0")).toBe(false);
459
+ expect(versionAtLeast("4.0.0", "3.254.0")).toBe(true);
460
+ });
461
+
462
+ it("canonicalJson is key-order independent", () => {
463
+ expect(canonicalJson({ b: 1, a: [{ d: 2, c: 3 }] })).toBe(
464
+ canonicalJson({ a: [{ c: 3, d: 2 }], b: 1 }),
465
+ );
466
+ });
467
+
468
+ it("changeCount ignores unchanged resources", () => {
469
+ expect(changeCount({ same: 120 })).toBe(0);
470
+ expect(changeCount({ same: 100, update: 2, create: 1 })).toBe(3);
471
+ });
472
+
473
+ it("upgrade's notice points a local GCP install at state migrate, dry run first", () => {
474
+ const n = localStateNotice({ installId: "gcp-firebase-viu-one", provider: "gcp-firebase" });
475
+ expect(n).toContain("state migrate --install gcp-firebase-viu-one --dry-run");
476
+ expect(localStateNotice({ installId: "x", provider: "gcp", stateBackend: GCS })).toBeNull();
477
+ expect(localStateNotice({ installId: "x", provider: "self-hosted" })).toBeNull();
478
+ });
479
+ });