@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,542 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The install job (epic #696, Phase 3): the long half of a GCP install runs
5
+ * as a Cloud Run job in the install's own project instead of in the
6
+ * operator's terminal, so closing the Cloud Shell tab (or its 40-minute idle
7
+ * timeout) never breaks an install.
8
+ *
9
+ * Split of work:
10
+ * - The operator's terminal (Cloud Shell, or a laptop with
11
+ * `--install-job`) does everything that needs the operator's own Google
12
+ * identity: project, billing, APIs, the keyless provisioner SA, the ops
13
+ * bucket + KMS key, the cost table and its y/N. Then it starts the job
14
+ * and tails it.
15
+ * - The job — `caelo-ops install-job --project <id>` from the signed ops
16
+ * image, pinned by the digest the signed release manifest names, running
17
+ * AS `caelo-provisioner` (its runtime service account: no key, no
18
+ * impersonation) — runs `pulumi up`, the DNS + certificate waits,
19
+ * migrations, the operator-access job and the Owner setup link.
20
+ *
21
+ * Progress lives in the install's ops bucket next to install.json:
22
+ * `install/install-job.json` ({@link InstallJobStatus}) says which execution
23
+ * runs the install and how far it got; progress.json keeps the per-step
24
+ * checkpoints, so a restarted execution resumes where the last one stopped.
25
+ * Re-running the launcher reads the status and attaches to a running
26
+ * execution, shows a finished one, or starts a new one ({@link decideInstallJob}).
27
+ *
28
+ * The Owner setup link is a secret (#656): it never reaches the bucket or
29
+ * the job's logs. The job stores it in Secret Manager
30
+ * ({@link INSTALL_SETUP_LINK_SECRET}); the operator's terminal reads it there.
31
+ */
32
+
33
+ import { z } from "zod";
34
+ import { assertNoSecrets } from "./checkpoint-secrets.js";
35
+ import type { GcloudRunner } from "./gcloud-retry.js";
36
+ import { GCP_STACK_ENV } from "./gcp-names.js";
37
+ import { createInstallSecret } from "./runtime-secrets.js";
38
+ import { OPS_INSTALL_PREFIX } from "./state-backend.js";
39
+ import type { SyncGcloud } from "./state-store.js";
40
+ import { IMAGE_DIGEST } from "./upgrade/release-images.js";
41
+
42
+ /** Name of the Cloud Run job that runs the install. */
43
+ export const INSTALL_JOB_NAME = "caelo-install";
44
+
45
+ /** Secret Manager secret the job stores the Owner setup link in. */
46
+ export const INSTALL_SETUP_LINK_SECRET = "caelo-install-setup-link";
47
+
48
+ /**
49
+ * Secret Manager secret holding the operator's `CLOUDFLARE_API_TOKEN` for
50
+ * the job's automatic DNS (dns/cloudflare.ts). Written by the launcher only
51
+ * when the operator's shell has the token; the job reads it as an env var
52
+ * mounted from the secret, so it never appears in the job definition.
53
+ */
54
+ export const INSTALL_CLOUDFLARE_TOKEN_SECRET = "caelo-install-cloudflare-token";
55
+
56
+ /**
57
+ * Repository of the `caelo-ops` image on the public Artifact Registry
58
+ * mirror. Cloud Run pulls from Artifact Registry, not from GHCR; the mirror
59
+ * carries the same digests and signatures (release-images.yml, `oras copy -r`).
60
+ */
61
+ export const OPS_IMAGE_REPOSITORY =
62
+ "europe-west1-docker.pkg.dev/caelo-website/caelo-cms-images/ops";
63
+
64
+ /**
65
+ * Roles the provisioner needs on top of {@link PROVISIONER_ROLE_LIST} when it
66
+ * runs the install job instead of the operator: the operator-access sync job
67
+ * creates a project custom role and a Cloud Scheduler job (operator-access.ts),
68
+ * and the job's own output has to reach Cloud Logging so the terminal can
69
+ * tail it. Granted only to installs that use the job. Not an escalation: the
70
+ * provisioner already holds `resourcemanager.projectIamAdmin`.
71
+ */
72
+ export const INSTALL_JOB_ROLES: readonly string[] = [
73
+ "roles/iam.roleAdmin",
74
+ "roles/cloudscheduler.admin",
75
+ "roles/logging.logWriter",
76
+ ];
77
+
78
+ /** Resources the job runs with: Pulumi + the Node language host want memory. */
79
+ const JOB_CPU = "2";
80
+ const JOB_MEMORY = "4Gi";
81
+ /** DNS (operator at the registrar) + certificate waits can each take an hour. */
82
+ const JOB_TASK_TIMEOUT = "6h";
83
+
84
+ /** The ops image pinned by digest. */
85
+ export function opsImageRef(digest: string): string {
86
+ if (!IMAGE_DIGEST.test(digest)) throw new Error(`not an image digest: ${digest}`);
87
+ return `${OPS_IMAGE_REPOSITORY}@${digest}`;
88
+ }
89
+
90
+ /** What `install/install-job.json` records. */
91
+ export const InstallJobStatusSchema = z.object({
92
+ statusVersion: z.literal(1),
93
+ installId: z.string().min(1),
94
+ /** The Cloud Run execution running (or last running) the install. */
95
+ execution: z.string().min(1),
96
+ state: z.enum(["running", "succeeded", "failed"]),
97
+ /** Human-readable step the job is on, e.g. "Waiting for DNS". */
98
+ phase: z.string(),
99
+ /** Extra context for the phase (the DNS records to set, a failure reason). */
100
+ detail: z.string().optional(),
101
+ /** Caelo release the job's image belongs to. */
102
+ caeloVersion: z.string(),
103
+ startedAt: z.string(),
104
+ updatedAt: z.string(),
105
+ });
106
+ export type InstallJobStatus = z.infer<typeof InstallJobStatusSchema>;
107
+
108
+ /** `gs://<bucket>/install/install-job.json`. */
109
+ export function installJobStatusUrl(bucket: string): string {
110
+ return `gs://${bucket}/${OPS_INSTALL_PREFIX}/install-job.json`;
111
+ }
112
+
113
+ const NOT_FOUND = /matched no objects|No URLs matched|NotFound|NOT_FOUND|404|does not exist/i;
114
+
115
+ /**
116
+ * The install job's status as the ops bucket holds it now (never cached:
117
+ * another process — the job — writes it), or null before the first start.
118
+ * Throws when the bucket can't be read or the document is malformed.
119
+ */
120
+ export function readInstallJobStatus(bucket: string, run: SyncGcloud): InstallJobStatus | null {
121
+ const url = installJobStatusUrl(bucket);
122
+ const r = run(["storage", "cat", url]);
123
+ if (!r.ok) {
124
+ if (NOT_FOUND.test(r.stderr)) return null;
125
+ throw new Error(`Could not read ${url}: ${r.stderr.trim() || `gcloud exited ${r.exitCode}`}`);
126
+ }
127
+ let json: unknown;
128
+ try {
129
+ json = JSON.parse(r.stdout);
130
+ } catch {
131
+ throw new Error(`${url} is not valid JSON; remove it and re-run to start the install again.`);
132
+ }
133
+ const parsed = InstallJobStatusSchema.safeParse(json);
134
+ if (!parsed.success) {
135
+ throw new Error(
136
+ `${url} is not an install-job status this CLI understands (${parsed.error.message}); remove it and re-run.`,
137
+ );
138
+ }
139
+ return parsed.data;
140
+ }
141
+
142
+ /** Replace the status document. Refuses secret material (#656). */
143
+ export function writeInstallJobStatus(
144
+ bucket: string,
145
+ status: InstallJobStatus,
146
+ run: SyncGcloud,
147
+ ): void {
148
+ const parsed = InstallJobStatusSchema.parse(status);
149
+ assertNoSecrets(parsed, "install-job.json");
150
+ const url = installJobStatusUrl(bucket);
151
+ const r = run(
152
+ ["storage", "cp", "-", url, "--content-type=application/json"],
153
+ `${JSON.stringify(parsed, null, 2)}\n`,
154
+ );
155
+ if (!r.ok) {
156
+ throw new Error(`Could not write ${url}: ${r.stderr.trim() || `gcloud exited ${r.exitCode}`}`);
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Write `status` only if the document doesn't name its execution yet and
162
+ * nobody wrote it since this call read it (`--if-generation-match`). The
163
+ * launcher records a just-started execution this way: the execution itself
164
+ * may already have written a newer phase — or its failure — which must win.
165
+ *
166
+ * @returns whether this call's write landed
167
+ */
168
+ export function claimInstallJobStatus(
169
+ bucket: string,
170
+ status: InstallJobStatus,
171
+ run: SyncGcloud,
172
+ ): boolean {
173
+ const url = installJobStatusUrl(bucket);
174
+ const described = run(["storage", "objects", "describe", url, "--format=value(generation)"]);
175
+ let generation: string;
176
+ if (described.ok) generation = described.stdout.trim();
177
+ else if (NOT_FOUND.test(described.stderr)) generation = "0";
178
+ else throw new Error(`Could not read ${url}: ${described.stderr.trim()}`);
179
+ if (!/^\d+$/.test(generation)) throw new Error(`${url}: unexpected generation "${generation}"`);
180
+ if (readInstallJobStatus(bucket, run)?.execution === status.execution) return false;
181
+ const parsed = InstallJobStatusSchema.parse(status);
182
+ assertNoSecrets(parsed, "install-job.json");
183
+ const r = run(
184
+ [
185
+ "storage",
186
+ "cp",
187
+ "-",
188
+ url,
189
+ "--content-type=application/json",
190
+ `--if-generation-match=${generation}`,
191
+ ],
192
+ `${JSON.stringify(parsed, null, 2)}\n`,
193
+ );
194
+ if (r.ok) return true;
195
+ if (/PreconditionFailed|412|pre-conditions/i.test(r.stderr)) return false;
196
+ throw new Error(`Could not write ${url}: ${r.stderr.trim() || `gcloud exited ${r.exitCode}`}`);
197
+ }
198
+
199
+ /** `gs://<bucket>/install/install-job.lock` — held while a launcher decides and starts. */
200
+ export function installJobLockUrl(bucket: string): string {
201
+ return `gs://${bucket}/${OPS_INSTALL_PREFIX}/install-job.lock`;
202
+ }
203
+
204
+ /**
205
+ * Take the launch lock (created only if absent). A lock left by a crashed
206
+ * launcher is reported with the command to remove it, never broken
207
+ * automatically — like the install's state lock (state-store.ts).
208
+ *
209
+ * @returns the release function
210
+ */
211
+ export function acquireLaunchLock(bucket: string, run: SyncGcloud): () => void {
212
+ const url = installJobLockUrl(bucket);
213
+ const body = JSON.stringify({ purpose: "start the install job", at: new Date().toISOString() });
214
+ const r = run(["storage", "cp", "-", url, "--if-generation-match=0"], body);
215
+ if (!r.ok) {
216
+ if (/PreconditionFailed|412|pre-conditions/i.test(r.stderr)) {
217
+ throw new Error(
218
+ `Another terminal is starting this install's job right now. Re-run in a minute to follow it; if no other run is starting it, remove the stale lock: gcloud storage rm ${url}`,
219
+ );
220
+ }
221
+ throw new Error(`Could not take the launch lock ${url}: ${r.stderr.trim()}`);
222
+ }
223
+ return () => {
224
+ const rm = run(["storage", "rm", url]);
225
+ if (!rm.ok) throw new Error(`Could not release ${url}; remove it: gcloud storage rm ${url}`);
226
+ };
227
+ }
228
+
229
+ /** Where a Cloud Run execution stands. */
230
+ export type ExecutionState = "running" | "succeeded" | "failed" | "missing";
231
+
232
+ interface ExecutionDescription {
233
+ status?: {
234
+ completionTime?: string;
235
+ succeededCount?: number;
236
+ failedCount?: number;
237
+ cancelledCount?: number;
238
+ };
239
+ }
240
+
241
+ /** Read one execution of the install job. */
242
+ export async function describeExecution(
243
+ run: GcloudRunner,
244
+ target: { projectId: string; region: string; execution: string },
245
+ ): Promise<ExecutionState> {
246
+ const r = await run([
247
+ "run",
248
+ "jobs",
249
+ "executions",
250
+ "describe",
251
+ target.execution,
252
+ `--region=${target.region}`,
253
+ `--project=${target.projectId}`,
254
+ "--format=json",
255
+ ]);
256
+ if (!r.ok) {
257
+ if (NOT_FOUND.test(r.stderr)) return "missing";
258
+ throw new Error(
259
+ `Could not read execution ${target.execution}: ${r.stderr.trim() || `gcloud exited ${r.exitCode}`}`,
260
+ );
261
+ }
262
+ const d = JSON.parse(r.stdout || "{}") as ExecutionDescription;
263
+ const s = d.status ?? {};
264
+ if (!s.completionTime) return "running";
265
+ return (s.succeededCount ?? 0) >= 1 && !s.failedCount && !s.cancelledCount
266
+ ? "succeeded"
267
+ : "failed";
268
+ }
269
+
270
+ /** What the launcher does with an install that may already have a job. */
271
+ export type InstallJobDecision =
272
+ | { readonly kind: "start"; readonly previous: InstallJobStatus | null; readonly why: string }
273
+ | { readonly kind: "attach"; readonly status: InstallJobStatus }
274
+ | { readonly kind: "done"; readonly status: InstallJobStatus };
275
+
276
+ /**
277
+ * Decide from the bucket's status and the execution's live state. Only a
278
+ * status the job itself finished as `succeeded` counts as done; a running
279
+ * status whose execution has ended (crash, out of memory, cancelled, task
280
+ * timeout) is started again — the steps are checkpointed, so a new
281
+ * execution resumes where the last one stopped.
282
+ */
283
+ export function decideInstallJob(
284
+ status: InstallJobStatus | null,
285
+ live: ExecutionState | null,
286
+ ): InstallJobDecision {
287
+ if (!status) return { kind: "start", previous: null, why: "no install job has run yet" };
288
+ if (status.state === "succeeded") return { kind: "done", status };
289
+ if (status.state === "running" && live === "running") return { kind: "attach", status };
290
+ if (status.state === "failed") {
291
+ return {
292
+ kind: "start",
293
+ previous: status,
294
+ why: `the last run stopped at "${status.phase}"${status.detail ? `: ${status.detail}` : ""}`,
295
+ };
296
+ }
297
+ return {
298
+ kind: "start",
299
+ previous: status,
300
+ why: `execution ${status.execution} ended (${live ?? "unknown"}) while at "${status.phase}"`,
301
+ };
302
+ }
303
+
304
+ /** Everything the job definition depends on. */
305
+ export interface InstallJobSpec {
306
+ readonly projectId: string;
307
+ readonly region: string;
308
+ /** `caelo-provisioner@<project>` — the job's runtime identity. */
309
+ readonly serviceAccount: string;
310
+ /** {@link opsImageRef} of the release's signed ops image. */
311
+ readonly image: string;
312
+ /**
313
+ * Mount {@link INSTALL_CLOUDFLARE_TOKEN_SECRET} as `CLOUDFLARE_API_TOKEN`
314
+ * (the launcher stored the operator's token there); without it the job's
315
+ * DNS step prints the records and polls.
316
+ */
317
+ readonly cloudflareToken: boolean;
318
+ }
319
+
320
+ /**
321
+ * The `gcloud run jobs deploy` argv: creates the job or replaces its
322
+ * definition (image, args, identity) in place. The only argument the job
323
+ * gets is the project; everything else it reads from the ops bucket.
324
+ */
325
+ export function installJobDeployArgs(spec: InstallJobSpec): string[] {
326
+ return [
327
+ "run",
328
+ "jobs",
329
+ "deploy",
330
+ INSTALL_JOB_NAME,
331
+ `--image=${spec.image}`,
332
+ `--region=${spec.region}`,
333
+ `--project=${spec.projectId}`,
334
+ `--service-account=${spec.serviceAccount}`,
335
+ `--args=install-job,--project,${spec.projectId}`,
336
+ "--tasks=1",
337
+ "--max-retries=0",
338
+ `--task-timeout=${JOB_TASK_TIMEOUT}`,
339
+ `--cpu=${JOB_CPU}`,
340
+ `--memory=${JOB_MEMORY}`,
341
+ // Plain log lines: no colour codes, no spinner frames.
342
+ "--set-env-vars=NO_COLOR=1,CI=true",
343
+ spec.cloudflareToken
344
+ ? `--set-secrets=CLOUDFLARE_API_TOKEN=${INSTALL_CLOUDFLARE_TOKEN_SECRET}:latest`
345
+ : "--clear-secrets",
346
+ "--labels=caelo-role=install",
347
+ "--quiet",
348
+ ];
349
+ }
350
+
351
+ /** Create or update the job, then start one execution of it. */
352
+ export async function startInstallJob(
353
+ run: GcloudRunner,
354
+ spec: InstallJobSpec,
355
+ ): Promise<{ ok: true; execution: string } | { ok: false; error: string }> {
356
+ const deploy = await run(installJobDeployArgs(spec));
357
+ if (!deploy.ok) {
358
+ return { ok: false, error: `create the install job: ${deploy.stderr.trim()}` };
359
+ }
360
+ const exec = await run([
361
+ "run",
362
+ "jobs",
363
+ "execute",
364
+ INSTALL_JOB_NAME,
365
+ `--region=${spec.region}`,
366
+ `--project=${spec.projectId}`,
367
+ "--async",
368
+ "--format=value(metadata.name)",
369
+ ]);
370
+ if (!exec.ok) return { ok: false, error: `start the install job: ${exec.stderr.trim()}` };
371
+ // gcloud prints the resource with --format on stdout; older releases only
372
+ // announce "Execution [name] has successfully started" on stderr.
373
+ const execution =
374
+ exec.stdout.trim().split(/\s+/)[0] || /Execution \[([^\]]+)\]/.exec(exec.stderr)?.[1] || "";
375
+ if (!/^[a-z0-9-]+$/.test(execution)) {
376
+ return {
377
+ ok: false,
378
+ error: `the install job started but gcloud did not name the execution (${exec.stderr.trim()})`,
379
+ };
380
+ }
381
+ return { ok: true, execution };
382
+ }
383
+
384
+ /** `since` minus the lookback window, as an RFC 3339 timestamp. */
385
+ function lookback(since: string): string {
386
+ const t = Date.parse(since);
387
+ if (Number.isNaN(t)) throw new Error(`not a log timestamp: ${since}`);
388
+ return new Date(t - LOG_LOOKBACK_MS).toISOString();
389
+ }
390
+
391
+ /** One log line of the job, as Cloud Logging returns it. */
392
+ interface LogEntry {
393
+ insertId?: string;
394
+ timestamp?: string;
395
+ textPayload?: string;
396
+ }
397
+
398
+ /**
399
+ * How far before the newest line seen each poll looks again. Cloud Logging
400
+ * can make an entry queryable after a later-timestamped one; the overlap
401
+ * (deduplicated by insert id) lets such a late entry still reach the
402
+ * terminal.
403
+ */
404
+ const LOG_LOOKBACK_MS = 2 * 60 * 1000;
405
+ /** Entries per `gcloud logging read` page, and pages per poll. */
406
+ const LOG_PAGE_SIZE = 1000;
407
+ const LOG_MAX_PAGES = 20;
408
+
409
+ /**
410
+ * The execution's log lines not printed yet, oldest first, looking back
411
+ * {@link LOG_LOOKBACK_MS} before `since` (the newest timestamp seen so far,
412
+ * or null for all). `seen` holds the insert ids already printed — the
413
+ * overlap and entries sharing a timestamp would otherwise repeat.
414
+ */
415
+ export async function readExecutionLogs(
416
+ run: GcloudRunner,
417
+ target: { projectId: string; execution: string },
418
+ since: string | null,
419
+ seen: Set<string>,
420
+ ): Promise<{ lines: string[]; cursor: string | null }> {
421
+ const lines: string[] = [];
422
+ let cursor = since;
423
+ // A page that comes back full may hide newer entries behind it (a chatty
424
+ // two-minute window): read on from its last timestamp, page by page.
425
+ let from: string | null = since ? lookback(since) : null;
426
+ for (let page = 0; page < LOG_MAX_PAGES; page++) {
427
+ const filter = [
428
+ 'resource.type="cloud_run_job"',
429
+ `resource.labels.job_name="${INSTALL_JOB_NAME}"`,
430
+ `labels."run.googleapis.com/execution_name"="${target.execution}"`,
431
+ ...(from ? [`timestamp>="${from}"`] : []),
432
+ ].join(" AND ");
433
+ const r = await run([
434
+ "logging",
435
+ "read",
436
+ filter,
437
+ `--project=${target.projectId}`,
438
+ "--order=asc",
439
+ `--limit=${LOG_PAGE_SIZE}`,
440
+ "--freshness=1d",
441
+ "--format=json",
442
+ ]);
443
+ if (!r.ok) break;
444
+ const entries = JSON.parse(r.stdout || "[]") as LogEntry[];
445
+ for (const e of entries) {
446
+ const id = e.insertId ?? `${e.timestamp}:${e.textPayload}`;
447
+ if (seen.has(id)) continue;
448
+ seen.add(id);
449
+ if (e.timestamp && (cursor === null || Date.parse(e.timestamp) > Date.parse(cursor))) {
450
+ cursor = e.timestamp;
451
+ }
452
+ if (e.textPayload !== undefined) lines.push(e.textPayload.replace(/\n$/, ""));
453
+ }
454
+ const last = entries.at(-1)?.timestamp ?? null;
455
+ // Not full: nothing newer is hidden. Full but one timestamp throughout:
456
+ // the next page would be the same one.
457
+ if (entries.length < LOG_PAGE_SIZE || last === null || last === from) break;
458
+ from = last;
459
+ }
460
+ return { lines, cursor };
461
+ }
462
+
463
+ /**
464
+ * Store `value` as the newest version of the install secret `secretId`
465
+ * (created with the install's secret replication if missing) and disable
466
+ * the versions before it, so exactly one value is readable. The value goes
467
+ * to gcloud on stdin, never on a command line.
468
+ */
469
+ export async function storeInstallSecretValue(
470
+ run: GcloudRunner,
471
+ target: { projectId: string; region: string },
472
+ secretId: string,
473
+ value: string,
474
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
475
+ const project = `--project=${target.projectId}`;
476
+ const exists = await run(["secrets", "describe", secretId, project, "--format=value(name)"]);
477
+ if (!exists.ok) {
478
+ const created = await createInstallSecret({ ...target, env: GCP_STACK_ENV, secretId }, { run });
479
+ if (!created.ok) return created;
480
+ }
481
+ const add = await run(
482
+ ["secrets", "versions", "add", secretId, project, "--data-file=-", "--format=value(name)"],
483
+ { stdin: value },
484
+ );
485
+ if (!add.ok) return { ok: false, error: `store ${secretId}: ${add.stderr.trim()}` };
486
+ const newest = add.stdout.trim().split("/").pop() ?? "";
487
+ const enabled = await run([
488
+ "secrets",
489
+ "versions",
490
+ "list",
491
+ secretId,
492
+ project,
493
+ "--filter=state:enabled",
494
+ "--format=value(name)",
495
+ ]);
496
+ for (const name of enabled.ok ? enabled.stdout.split("\n") : []) {
497
+ const version = name.trim().split("/").pop() ?? "";
498
+ if (version === "" || version === newest) continue;
499
+ await run([
500
+ "secrets",
501
+ "versions",
502
+ "disable",
503
+ version,
504
+ `--secret=${secretId}`,
505
+ project,
506
+ "--quiet",
507
+ ]);
508
+ }
509
+ return { ok: true };
510
+ }
511
+
512
+ /** Store the Owner setup link in {@link INSTALL_SETUP_LINK_SECRET}. */
513
+ export function storeSetupLink(
514
+ run: GcloudRunner,
515
+ target: { projectId: string; region: string },
516
+ link: string,
517
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
518
+ return storeInstallSecretValue(run, target, INSTALL_SETUP_LINK_SECRET, link);
519
+ }
520
+
521
+ /**
522
+ * The Owner setup link the job stored, or null when there is none (the
523
+ * secret or an enabled version is missing).
524
+ *
525
+ * @throws when it can't be read for any other reason (no permission), with
526
+ * what the operator's account needs
527
+ */
528
+ export async function readSetupLink(run: GcloudRunner, projectId: string): Promise<string | null> {
529
+ const r = await run([
530
+ "secrets",
531
+ "versions",
532
+ "access",
533
+ "latest",
534
+ `--secret=${INSTALL_SETUP_LINK_SECRET}`,
535
+ `--project=${projectId}`,
536
+ ]);
537
+ if (r.ok) return r.stdout.trim() === "" ? null : r.stdout.trim();
538
+ if (/NOT_FOUND|not found|DISABLED|DESTROYED|FAILED_PRECONDITION/i.test(r.stderr)) return null;
539
+ throw new Error(
540
+ `Could not read the Owner setup link (secret ${INSTALL_SETUP_LINK_SECRET} in ${projectId}): ${r.stderr.trim()}. Your gcloud account needs secretmanager.versions.access on the project (e.g. roles/secretmanager.secretAccessor or project Owner).`,
541
+ );
542
+ }