@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,566 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The install job (#696, Phase 3) against a fake gcloud: the job
5
+ * definition, the bucket status document, the attach decision, starting an
6
+ * execution, log tailing and the setup-link secret.
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import type { GcloudResult } from "./gcloud.js";
11
+ import type { GcloudRunner } from "./gcloud-retry.js";
12
+ import {
13
+ acquireLaunchLock,
14
+ claimInstallJobStatus,
15
+ decideInstallJob,
16
+ describeExecution,
17
+ INSTALL_CLOUDFLARE_TOKEN_SECRET,
18
+ INSTALL_JOB_NAME,
19
+ INSTALL_SETUP_LINK_SECRET,
20
+ type InstallJobStatus,
21
+ installJobDeployArgs,
22
+ installJobStatusUrl,
23
+ OPS_IMAGE_REPOSITORY,
24
+ opsImageRef,
25
+ readExecutionLogs,
26
+ readInstallJobStatus,
27
+ readSetupLink,
28
+ startInstallJob,
29
+ storeInstallSecretValue,
30
+ storeSetupLink,
31
+ writeInstallJobStatus,
32
+ } from "./install-job.js";
33
+ import { failureDetail, installJobProject } from "./install-job-runner.js";
34
+ import { chooseRunMode, inCloudShell } from "./run-mode.js";
35
+ import type { SyncGcloud } from "./state-store.js";
36
+ import { tailInstallJob } from "./wizards/gcp-install-job.js";
37
+
38
+ const ok = (stdout = "", stderr = ""): GcloudResult => ({ ok: true, stdout, stderr, exitCode: 0 });
39
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
40
+
41
+ const DIGEST = `sha256:${"a".repeat(64)}`;
42
+ const BUCKET = "acme-site-caelo-ops";
43
+
44
+ const STATUS: InstallJobStatus = {
45
+ statusVersion: 1,
46
+ installId: "gcp-acme-site",
47
+ execution: "caelo-install-abc12",
48
+ state: "running",
49
+ phase: "Provisioning the infrastructure (pulumi up, 8-15 min)",
50
+ caeloVersion: "0.10.46",
51
+ startedAt: "2026-10-13T10:00:00.000Z",
52
+ updatedAt: "2026-10-13T10:05:00.000Z",
53
+ };
54
+
55
+ /**
56
+ * An in-memory ops bucket behind `gcloud storage cat|cp|objects describe`,
57
+ * with object generations and `--if-generation-match`.
58
+ */
59
+ function fakeBucket(
60
+ objects: Map<string, string> = new Map(),
61
+ generations: Map<string, number> = new Map(),
62
+ ): SyncGcloud {
63
+ return (args, input) => {
64
+ if (args[0] === "storage" && args[1] === "cat") {
65
+ const v = objects.get(args[2] ?? "");
66
+ return v === undefined ? fail("One or more URLs matched no objects.") : ok(v);
67
+ }
68
+ if (args[0] === "storage" && args[1] === "objects" && args[2] === "describe") {
69
+ const g = generations.get(args[3] ?? "");
70
+ return g === undefined ? fail("NotFound: 404") : ok(`${g}\n`);
71
+ }
72
+ if (args[0] === "storage" && args[1] === "cp" && args[2] === "-") {
73
+ const url = args[3] ?? "";
74
+ const match = args.find((a) => a.startsWith("--if-generation-match="));
75
+ if (match && Number(match.split("=")[1]) !== (generations.get(url) ?? 0)) {
76
+ return fail("PreconditionFailed: 412 At least one of the pre-conditions did not hold.");
77
+ }
78
+ objects.set(url, input ?? "");
79
+ generations.set(url, (generations.get(url) ?? 0) + 1);
80
+ return ok();
81
+ }
82
+ return fail(`unexpected: ${args.join(" ")}`);
83
+ };
84
+ }
85
+
86
+ /** A scripted async gcloud: `respond` gets every argv; calls are recorded. */
87
+ function fakeRun(respond: (args: string[], stdin?: string) => GcloudResult): {
88
+ run: GcloudRunner;
89
+ calls: { args: string[]; stdin?: string }[];
90
+ } {
91
+ const calls: { args: string[]; stdin?: string }[] = [];
92
+ return {
93
+ calls,
94
+ run: async (args, opts) => {
95
+ calls.push(opts?.stdin === undefined ? { args } : { args, stdin: opts.stdin });
96
+ return respond(args, opts?.stdin);
97
+ },
98
+ };
99
+ }
100
+
101
+ describe("the job definition", () => {
102
+ it("pins the ops image by digest on the public mirror", () => {
103
+ expect(opsImageRef(DIGEST)).toBe(`${OPS_IMAGE_REPOSITORY}@${DIGEST}`);
104
+ expect(() => opsImageRef("latest")).toThrow(/not an image digest/);
105
+ });
106
+
107
+ it("runs as the provisioner, with the project as its only argument and no retries", () => {
108
+ const args = installJobDeployArgs({
109
+ projectId: "acme-site",
110
+ region: "europe-west3",
111
+ serviceAccount: "caelo-provisioner@acme-site.iam.gserviceaccount.com",
112
+ image: opsImageRef(DIGEST),
113
+ cloudflareToken: false,
114
+ });
115
+ expect(args.slice(0, 4)).toEqual(["run", "jobs", "deploy", INSTALL_JOB_NAME]);
116
+ expect(args).toContain("--service-account=caelo-provisioner@acme-site.iam.gserviceaccount.com");
117
+ expect(args).toContain("--args=install-job,--project,acme-site");
118
+ expect(args).toContain("--max-retries=0");
119
+ expect(args).toContain(`--image=${OPS_IMAGE_REPOSITORY}@${DIGEST}`);
120
+ // No key, no secret, no credential of any kind in the definition.
121
+ expect(args).toContain("--clear-secrets");
122
+ expect(args.filter((a) => a !== "--clear-secrets").join(" ")).not.toMatch(
123
+ /key|secret|password|token/i,
124
+ );
125
+ });
126
+
127
+ it("mounts the Cloudflare token from Secret Manager when the operator has one", () => {
128
+ const args = installJobDeployArgs({
129
+ projectId: "acme-site",
130
+ region: "europe-west3",
131
+ serviceAccount: "caelo-provisioner@acme-site.iam.gserviceaccount.com",
132
+ image: opsImageRef(DIGEST),
133
+ cloudflareToken: true,
134
+ });
135
+ expect(args).toContain(
136
+ `--set-secrets=CLOUDFLARE_API_TOKEN=${INSTALL_CLOUDFLARE_TOKEN_SECRET}:latest`,
137
+ );
138
+ expect(args).not.toContain("--clear-secrets");
139
+ });
140
+ });
141
+
142
+ describe("the status document", () => {
143
+ it("round-trips through the bucket and is null before the first run", () => {
144
+ const objects = new Map<string, string>();
145
+ const run = fakeBucket(objects);
146
+ expect(readInstallJobStatus(BUCKET, run)).toBeNull();
147
+ writeInstallJobStatus(BUCKET, STATUS, run);
148
+ expect(objects.has(installJobStatusUrl(BUCKET))).toBe(true);
149
+ expect(readInstallJobStatus(BUCKET, run)).toEqual(STATUS);
150
+ });
151
+
152
+ it("refuses to write secret material", () => {
153
+ const run = fakeBucket();
154
+ expect(() =>
155
+ writeInstallJobStatus(
156
+ BUCKET,
157
+ { ...STATUS, detail: "postgres://admin_role:hunter2hunter2@10.0.0.3/cms_admin" },
158
+ run,
159
+ ),
160
+ ).toThrow();
161
+ });
162
+
163
+ it("fails loudly on a malformed document and on an unreadable bucket", () => {
164
+ const objects = new Map([[installJobStatusUrl(BUCKET), '{"statusVersion":9}']]);
165
+ expect(() => readInstallJobStatus(BUCKET, fakeBucket(objects))).toThrow(/not an install-job/);
166
+ expect(() => readInstallJobStatus(BUCKET, () => fail("403 Forbidden"))).toThrow(/403/);
167
+ });
168
+ });
169
+
170
+ describe("claimInstallJobStatus", () => {
171
+ it("records a just-started execution when the document doesn't name it yet", () => {
172
+ const run = fakeBucket();
173
+ writeInstallJobStatus(BUCKET, { ...STATUS, execution: "old", state: "failed" }, run);
174
+ expect(claimInstallJobStatus(BUCKET, STATUS, run)).toBe(true);
175
+ expect(readInstallJobStatus(BUCKET, run)).toEqual(STATUS);
176
+ });
177
+
178
+ it("never overwrites what the execution itself already recorded", () => {
179
+ const run = fakeBucket();
180
+ const own = { ...STATUS, phase: "Waiting for DNS", detail: "A admin.acme.com → 1.2.3.4" };
181
+ writeInstallJobStatus(BUCKET, own, run);
182
+ expect(claimInstallJobStatus(BUCKET, { ...STATUS, phase: "Starting" }, run)).toBe(false);
183
+ expect(readInstallJobStatus(BUCKET, run)).toEqual(own);
184
+ });
185
+
186
+ it("loses the race against a write that lands after its read", () => {
187
+ const inner = fakeBucket();
188
+ let raced = false;
189
+ // The execution writes between the launcher's generation read and its write.
190
+ const run: SyncGcloud = (args, input) => {
191
+ const r = inner(args, input);
192
+ if (!raced && args[1] === "objects") {
193
+ raced = true;
194
+ writeInstallJobStatus(BUCKET, { ...STATUS, phase: "Choosing the release images" }, inner);
195
+ }
196
+ return r;
197
+ };
198
+ expect(claimInstallJobStatus(BUCKET, { ...STATUS, execution: "launcher" }, run)).toBe(false);
199
+ expect(readInstallJobStatus(BUCKET, inner)?.phase).toBe("Choosing the release images");
200
+ });
201
+ });
202
+
203
+ describe("acquireLaunchLock", () => {
204
+ it("lets one launcher in at a time and releases", () => {
205
+ const objects = new Map<string, string>();
206
+ const generations = new Map<string, number>();
207
+ const bucket = fakeBucket(objects, generations);
208
+ const run: SyncGcloud = (args, input) => {
209
+ if (args[1] === "rm") {
210
+ objects.delete(args[2] ?? "");
211
+ generations.delete(args[2] ?? "");
212
+ return ok();
213
+ }
214
+ return bucket(args, input);
215
+ };
216
+ const release = acquireLaunchLock(BUCKET, run);
217
+ expect(() => acquireLaunchLock(BUCKET, run)).toThrow(/Another terminal is starting/);
218
+ release();
219
+ expect(() => acquireLaunchLock(BUCKET, run)()).not.toThrow();
220
+ });
221
+ });
222
+
223
+ describe("decideInstallJob", () => {
224
+ it("starts when nothing ran yet", () => {
225
+ expect(decideInstallJob(null, null).kind).toBe("start");
226
+ });
227
+ it("attaches to a running execution", () => {
228
+ expect(decideInstallJob(STATUS, "running")).toEqual({ kind: "attach", status: STATUS });
229
+ });
230
+ it("shows a finished install", () => {
231
+ const done = { ...STATUS, state: "succeeded" as const, phase: "Done" };
232
+ expect(decideInstallJob(done, null)).toEqual({ kind: "done", status: done });
233
+ });
234
+ it("restarts after a recorded failure, naming where it stopped", () => {
235
+ const failed = { ...STATUS, state: "failed" as const, phase: "Waiting for DNS", detail: "x" };
236
+ const d = decideInstallJob(failed, null);
237
+ expect(d.kind).toBe("start");
238
+ if (d.kind === "start") expect(d.why).toContain("Waiting for DNS");
239
+ });
240
+ it("restarts when the execution died without recording a result", () => {
241
+ for (const live of ["failed", "missing", "succeeded"] as const) {
242
+ const d = decideInstallJob(STATUS, live);
243
+ expect(d.kind).toBe("start");
244
+ if (d.kind === "start") expect(d.previous).toEqual(STATUS);
245
+ }
246
+ });
247
+ });
248
+
249
+ describe("describeExecution", () => {
250
+ const target = { projectId: "acme-site", region: "europe-west3", execution: "e1" };
251
+ it("reads running / succeeded / failed / missing", async () => {
252
+ const states: [GcloudResult, string][] = [
253
+ [ok(JSON.stringify({ status: { runningCount: 1 } })), "running"],
254
+ [ok(JSON.stringify({ status: { completionTime: "t", succeededCount: 1 } })), "succeeded"],
255
+ [ok(JSON.stringify({ status: { completionTime: "t", failedCount: 1 } })), "failed"],
256
+ [ok(JSON.stringify({ status: { completionTime: "t", cancelledCount: 1 } })), "failed"],
257
+ [fail("ERROR: (gcloud.run.jobs.executions.describe) NOT_FOUND"), "missing"],
258
+ ];
259
+ for (const [r, want] of states) {
260
+ expect(await describeExecution(fakeRun(() => r).run, target)).toBe(want as never);
261
+ }
262
+ });
263
+ it("throws on any other error", async () => {
264
+ await expect(
265
+ describeExecution(fakeRun(() => fail("PERMISSION_DENIED")).run, target),
266
+ ).rejects.toThrow(/PERMISSION_DENIED/);
267
+ });
268
+ });
269
+
270
+ describe("startInstallJob", () => {
271
+ const spec = {
272
+ projectId: "acme-site",
273
+ region: "europe-west3",
274
+ serviceAccount: "caelo-provisioner@acme-site.iam.gserviceaccount.com",
275
+ image: opsImageRef(DIGEST),
276
+ cloudflareToken: false,
277
+ };
278
+ it("deploys the job, then executes it asynchronously", async () => {
279
+ const { run, calls } = fakeRun((args) =>
280
+ args[2] === "execute" ? ok("caelo-install-x7k2p\n") : ok(),
281
+ );
282
+ expect(await startInstallJob(run, spec)).toEqual({
283
+ ok: true,
284
+ execution: "caelo-install-x7k2p",
285
+ });
286
+ expect(calls.map((c) => c.args[2])).toEqual(["deploy", "execute"]);
287
+ expect(calls[1]?.args).toContain("--async");
288
+ });
289
+ it("takes the execution name from gcloud's stderr announcement", async () => {
290
+ const { run } = fakeRun((args) =>
291
+ args[2] === "execute"
292
+ ? ok("", "Execution [caelo-install-q9] has successfully started running.")
293
+ : ok(),
294
+ );
295
+ expect(await startInstallJob(run, spec)).toEqual({ ok: true, execution: "caelo-install-q9" });
296
+ });
297
+ it("reports a failed deploy without executing", async () => {
298
+ const { run, calls } = fakeRun(() => fail("PERMISSION_DENIED: iam.serviceAccounts.actAs"));
299
+ const r = await startInstallJob(run, spec);
300
+ expect(r.ok).toBe(false);
301
+ expect(calls).toHaveLength(1);
302
+ });
303
+ });
304
+
305
+ describe("readExecutionLogs", () => {
306
+ it("returns new lines in order, skipping entries already seen", async () => {
307
+ const entries = [
308
+ { insertId: "a", timestamp: "2026-10-13T10:00:00Z", textPayload: "first\n" },
309
+ { insertId: "b", timestamp: "2026-10-13T10:00:01Z", textPayload: "second" },
310
+ ];
311
+ const { run, calls } = fakeRun(() => ok(JSON.stringify(entries)));
312
+ const seen = new Set<string>();
313
+ const target = { projectId: "acme-site", execution: "e1" };
314
+ const one = await readExecutionLogs(run, target, null, seen);
315
+ expect(one).toEqual({ lines: ["first", "second"], cursor: "2026-10-13T10:00:01Z" });
316
+ const two = await readExecutionLogs(run, target, one.cursor, seen);
317
+ expect(two.lines).toEqual([]);
318
+ // Looks back two minutes before the newest line seen.
319
+ expect(calls[1]?.args[2]).toContain('timestamp>="2026-10-13T09:58:01.000Z"');
320
+ expect(calls[0]?.args[2]).toContain(`labels."run.googleapis.com/execution_name"="e1"`);
321
+ });
322
+ });
323
+
324
+ describe("readExecutionLogs and late entries", () => {
325
+ it("still prints an entry ingested after a later-timestamped one, exactly once", async () => {
326
+ const batches = [
327
+ [{ insertId: "b", timestamp: "2026-10-13T10:00:05Z", textPayload: "later" }],
328
+ [
329
+ { insertId: "a", timestamp: "2026-10-13T10:00:04Z", textPayload: "late error" },
330
+ { insertId: "b", timestamp: "2026-10-13T10:00:05Z", textPayload: "later" },
331
+ ],
332
+ ];
333
+ let i = 0;
334
+ const { run } = fakeRun(() => ok(JSON.stringify(batches[i++] ?? [])));
335
+ const seen = new Set<string>();
336
+ const target = { projectId: "acme-site", execution: "e1" };
337
+ const one = await readExecutionLogs(run, target, null, seen);
338
+ const two = await readExecutionLogs(run, target, one.cursor, seen);
339
+ expect(one.lines).toEqual(["later"]);
340
+ expect(two.lines).toEqual(["late error"]);
341
+ expect(two.cursor).toBe("2026-10-13T10:00:05Z");
342
+ });
343
+ });
344
+
345
+ describe("readExecutionLogs and full pages", () => {
346
+ it("reads on past a full page instead of re-reading the same oldest entries", async () => {
347
+ const full = Array.from({ length: 1000 }, (_, i) => ({
348
+ insertId: `old${i}`,
349
+ timestamp: `2026-10-13T10:00:${String(Math.floor(i / 20)).padStart(2, "0")}Z`,
350
+ textPayload: `old ${i}`,
351
+ }));
352
+ const { run, calls } = fakeRun((args) =>
353
+ args[2]?.includes('timestamp>="2026-10-13T10:00:49Z"')
354
+ ? ok(
355
+ JSON.stringify([
356
+ { insertId: "new", timestamp: "2026-10-13T10:00:55Z", textPayload: "new line" },
357
+ ]),
358
+ )
359
+ : ok(JSON.stringify(full)),
360
+ );
361
+ const seen = new Set<string>();
362
+ const r = await readExecutionLogs(run, { projectId: "p", execution: "e1" }, null, seen);
363
+ expect(r.lines.at(-1)).toBe("new line");
364
+ expect(r.lines).toHaveLength(1001);
365
+ expect(calls).toHaveLength(2);
366
+ });
367
+ });
368
+
369
+ describe("the setup-link secret", () => {
370
+ it("creates the secret with the install's replication, adds the link on stdin and disables older versions", async () => {
371
+ const { run, calls } = fakeRun((args) => {
372
+ if (args[1] === "describe" && args[2] === INSTALL_SETUP_LINK_SECRET) return fail("NOT_FOUND");
373
+ if (args[1] === "describe")
374
+ return ok(
375
+ JSON.stringify({
376
+ replication: { userManaged: { replicas: [{ location: "europe-west3" }] } },
377
+ }),
378
+ );
379
+ if (args[1] === "versions" && args[2] === "add")
380
+ return ok(`projects/1/secrets/${INSTALL_SETUP_LINK_SECRET}/versions/3\n`);
381
+ if (args[1] === "versions" && args[2] === "list")
382
+ return ok(
383
+ [1, 2, 3]
384
+ .map((v) => `projects/1/secrets/${INSTALL_SETUP_LINK_SECRET}/versions/${v}`)
385
+ .join("\n"),
386
+ );
387
+ return ok();
388
+ });
389
+ const link = "https://admin.acme.com/setup?token=abc";
390
+ expect(
391
+ await storeSetupLink(run, { projectId: "acme-site", region: "europe-west3" }, link),
392
+ ).toEqual({
393
+ ok: true,
394
+ });
395
+ const create = calls.find((c) => c.args[1] === "create");
396
+ expect(create?.args).toContain("--locations=europe-west3");
397
+ const add = calls.find((c) => c.args[2] === "add");
398
+ expect(add?.stdin).toBe(link);
399
+ // The link never appears on a command line.
400
+ expect(calls.some((c) => c.args.join(" ").includes("token=abc"))).toBe(false);
401
+ const disabled = calls.filter((c) => c.args[2] === "disable").map((c) => c.args[3]);
402
+ expect(disabled).toEqual(["1", "2"]);
403
+ });
404
+
405
+ it("reads the link back, null when there is none", async () => {
406
+ expect(await readSetupLink(fakeRun(() => ok("https://x/setup?token=1\n")).run, "p")).toBe(
407
+ "https://x/setup?token=1",
408
+ );
409
+ expect(await readSetupLink(fakeRun(() => fail("NOT_FOUND")).run, "p")).toBeNull();
410
+ expect(
411
+ await readSetupLink(
412
+ fakeRun(() => fail("FAILED_PRECONDITION: version is in DISABLED state")).run,
413
+ "p",
414
+ ),
415
+ ).toBeNull();
416
+ });
417
+
418
+ it("says what access is missing instead of treating a denial as 'no link'", async () => {
419
+ await expect(
420
+ readSetupLink(
421
+ fakeRun(() => fail("PERMISSION_DENIED: secretmanager.versions.access")).run,
422
+ "p",
423
+ ),
424
+ ).rejects.toThrow(/secretmanager.versions.access/);
425
+ });
426
+
427
+ it("creates a secret regionally before the stack's secrets exist", async () => {
428
+ const { run, calls } = fakeRun((args) => {
429
+ if (args[1] === "describe") return fail("NOT_FOUND: Secret not found");
430
+ if (args[1] === "versions" && args[2] === "add") return ok("projects/1/secrets/x/versions/1");
431
+ return ok();
432
+ });
433
+ const r = await storeInstallSecretValue(
434
+ run,
435
+ { projectId: "acme-site", region: "europe-west3" },
436
+ INSTALL_CLOUDFLARE_TOKEN_SECRET,
437
+ "cf-token-value",
438
+ );
439
+ expect(r).toEqual({ ok: true });
440
+ const create = calls.find((c) => c.args[1] === "create");
441
+ expect(create?.args).toContain("--replication-policy=user-managed");
442
+ expect(create?.args).toContain("--locations=europe-west3");
443
+ expect(calls.some((c) => c.args.join(" ").includes("cf-token-value"))).toBe(false);
444
+ });
445
+ });
446
+
447
+ describe("tailInstallJob", () => {
448
+ const target = { projectId: "acme-site", region: "europe-west3", execution: STATUS.execution };
449
+
450
+ it("prints log lines and phases until the job records success", async () => {
451
+ const objects = new Map<string, string>();
452
+ const syncRun = fakeBucket(objects);
453
+ writeInstallJobStatus(BUCKET, STATUS, syncRun);
454
+ let poll = 0;
455
+ const printed: string[] = [];
456
+ const { run } = fakeRun((args) => {
457
+ if (args[0] === "logging") {
458
+ poll++;
459
+ // Lines 4 and 5 arrive after the job recorded success (late ingestion).
460
+ if (poll > 5) return ok("[]");
461
+ return ok(
462
+ JSON.stringify([
463
+ {
464
+ insertId: `i${poll}`,
465
+ timestamp: `2026-10-13T10:00:0${poll}Z`,
466
+ textPayload: `line ${poll}`,
467
+ },
468
+ ]),
469
+ );
470
+ }
471
+ return ok(JSON.stringify({ status: {} }));
472
+ });
473
+ const outcome = await tailInstallJob(target, {
474
+ run,
475
+ syncRun,
476
+ print: (l) => printed.push(l),
477
+ intervalMs: 0,
478
+ sleep: async () => {
479
+ if (poll === 2)
480
+ writeInstallJobStatus(BUCKET, { ...STATUS, state: "succeeded", phase: "Done" }, syncRun);
481
+ },
482
+ });
483
+ expect(outcome).toEqual({ kind: "succeeded" });
484
+ expect(printed.filter((l) => l.startsWith("line"))).toEqual([
485
+ "line 1",
486
+ "line 2",
487
+ "line 3",
488
+ "line 4",
489
+ "line 5",
490
+ ]);
491
+ expect(printed.some((l) => l.includes(STATUS.phase))).toBe(true);
492
+ });
493
+
494
+ it("reports an execution that ended without recording a result", async () => {
495
+ const syncRun = fakeBucket();
496
+ writeInstallJobStatus(BUCKET, STATUS, syncRun);
497
+ const { run } = fakeRun((args) =>
498
+ args[0] === "logging"
499
+ ? ok("[]")
500
+ : ok(JSON.stringify({ status: { completionTime: "t", failedCount: 1 } })),
501
+ );
502
+ const outcome = await tailInstallJob(target, {
503
+ run,
504
+ syncRun,
505
+ print: () => {},
506
+ intervalMs: 0,
507
+ sleep: async () => {},
508
+ });
509
+ expect(outcome.kind).toBe("failed");
510
+ if (outcome.kind === "failed") expect(outcome.why).toContain("without recording a result");
511
+ });
512
+
513
+ it("returns the recorded failure", async () => {
514
+ const syncRun = fakeBucket();
515
+ writeInstallJobStatus(
516
+ BUCKET,
517
+ { ...STATUS, state: "failed", phase: "Waiting for DNS", detail: "exit 1: unresolved" },
518
+ syncRun,
519
+ );
520
+ const outcome = await tailInstallJob(target, {
521
+ run: fakeRun(() => ok("[]")).run,
522
+ syncRun,
523
+ print: () => {},
524
+ intervalMs: 0,
525
+ sleep: async () => {},
526
+ });
527
+ expect(outcome).toMatchObject({ kind: "failed", why: "exit 1: unresolved" });
528
+ });
529
+ });
530
+
531
+ describe("the job's entry point", () => {
532
+ it("takes a valid --project and nothing else", () => {
533
+ expect(installJobProject(["bun", "cli", "install-job", "--project", "acme-site"])).toBe(
534
+ "acme-site",
535
+ );
536
+ expect(() => installJobProject(["bun", "cli", "install-job"])).toThrow(/--project/);
537
+ expect(() => installJobProject(["bun", "cli", "install-job", "--project", "x;rm"])).toThrow();
538
+ });
539
+
540
+ it("records the last output lines as the failure detail, credentials masked", () => {
541
+ const d = failureDetail(
542
+ [
543
+ "\u001b[31m│\u001b[39m",
544
+ "Migrations failed: connect postgres://u:pw1234567890@h/db",
545
+ "■ Aborted.",
546
+ ],
547
+ 1,
548
+ );
549
+ expect(d).toStartWith("exit 1: ");
550
+ expect(d).not.toContain("pw1234567890");
551
+ expect(d).toContain("Aborted.");
552
+ });
553
+ });
554
+
555
+ describe("run mode", () => {
556
+ it("defaults to the install job in Cloud Shell and to local elsewhere", () => {
557
+ expect(inCloudShell({ CLOUD_SHELL: "true" })).toBe(true);
558
+ expect(chooseRunMode([], { CLOUD_SHELL: "true" })).toBe("job");
559
+ expect(chooseRunMode([], {})).toBe("local");
560
+ });
561
+ it("honours --local and --install-job, and refuses both", () => {
562
+ expect(chooseRunMode(["--local"], { CLOUD_SHELL: "true" })).toBe("local");
563
+ expect(chooseRunMode(["--install-job"], {})).toBe("job");
564
+ expect(() => chooseRunMode(["--local", "--install-job"], {})).toThrow(/either/);
565
+ });
566
+ });