@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,220 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The release a deployed GCP install runs, read from its Cloud Run services.
5
+ *
6
+ * Installs created before `install.json` recorded `imageDigests` have no
7
+ * other trustworthy record: `upgrade` rolled them through gcloud without
8
+ * touching the Pulumi stack config, so the config may name an older release.
9
+ * The wizard reads the live services instead, so re-running it on such an
10
+ * install keeps what runs rather than rolling to `:latest` without the
11
+ * migrations `upgrade` would run first.
12
+ */
13
+
14
+ import { gcloud as defaultGcloud } from "./gcloud.js";
15
+ import type { GcloudRunner } from "./gcloud-retry.js";
16
+ import { GCP_STACK_ENV, gcpNamePrefix } from "./gcp-names.js";
17
+ import type { ImageDigests } from "./install-state.js";
18
+ import { type CloudRunSlug, SERVICE_SECRET_ENV } from "./stack-contract.js";
19
+ import { liveContainerEnv } from "./stack-converge.js";
20
+
21
+ /** The `sha256:…` digest an image reference pins, or null for a tag reference. */
22
+ export function digestFromImageRef(ref: string): string | null {
23
+ return ref.trim().match(/@(sha256:[0-9a-f]{64})$/)?.[1] ?? null;
24
+ }
25
+
26
+ /** The single deployed (Pulumi-suffixed) Cloud Run service for `slug`. */
27
+ async function findDeployedService(
28
+ run: GcloudRunner,
29
+ opts: { projectId: string; region: string },
30
+ slug: CloudRunSlug,
31
+ ): Promise<{ ok: true; name: string } | { ok: false; error: string }> {
32
+ const prefix = `${gcpNamePrefix(GCP_STACK_ENV)}-${slug}`;
33
+ const list = await run([
34
+ "run",
35
+ "services",
36
+ "list",
37
+ `--region=${opts.region}`,
38
+ `--project=${opts.projectId}`,
39
+ `--filter=metadata.name~^${prefix}`,
40
+ "--format=value(metadata.name)",
41
+ ]);
42
+ if (!list.ok) return { ok: false, error: `list Cloud Run services: ${list.stderr.trim()}` };
43
+ const names = list.stdout
44
+ .split("\n")
45
+ .map((s) => s.trim())
46
+ .filter(Boolean);
47
+ if (names.length !== 1) {
48
+ return {
49
+ ok: false,
50
+ error: `expected one ${prefix}* Cloud Run service, found ${names.length === 0 ? "none" : names.join(", ")}`,
51
+ };
52
+ }
53
+ return { ok: true, name: names[0] as string };
54
+ }
55
+
56
+ /**
57
+ * Whether the deployed services already run on the Secret Manager runtime
58
+ * env (password-less database URLs + `*_PASSWORD` secret references).
59
+ *
60
+ * The stacks deploy that env; a release from before it can't use it (it
61
+ * reads the password from the URL). The wizard keeps the release an install
62
+ * runs, so re-running it on an install `upgrade` hasn't moved over yet would
63
+ * pair the old release with env it can't connect with. The same holds for a
64
+ * gateway from before #613: its release needs the admin_role pool the
65
+ * current contract no longer gives it (no GATEWAY_DATABASE_PASSWORD yet).
66
+ * Such an install has to go through `upgrade` first, which moves the image
67
+ * and the env in one revision.
68
+ */
69
+ export async function checkDeployedRuntimeEnv(opts: {
70
+ projectId: string;
71
+ region: string;
72
+ run?: GcloudRunner;
73
+ }): Promise<{ ok: true } | { ok: false; error: string }> {
74
+ const run = opts.run ?? defaultGcloud;
75
+ for (const slug of ["admin", "gateway"] as const) {
76
+ const found = await findDeployedService(run, opts, slug);
77
+ if (!found.ok) return found;
78
+ const describe = await run([
79
+ "run",
80
+ "services",
81
+ "describe",
82
+ found.name,
83
+ `--region=${opts.region}`,
84
+ `--project=${opts.projectId}`,
85
+ "--format=json",
86
+ ]);
87
+ if (!describe.ok) {
88
+ return { ok: false, error: `describe ${found.name}: ${describe.stderr.trim()}` };
89
+ }
90
+ const env = liveContainerEnv(describe.stdout);
91
+ const missing = Object.keys(SERVICE_SECRET_ENV[slug]).filter(
92
+ (name) => name.endsWith("_PASSWORD") && env.get(name)?.kind !== "secret",
93
+ );
94
+ if (missing.length > 0) {
95
+ return {
96
+ ok: false,
97
+ error: `${found.name} does not read ${missing.join(", ")} from Secret Manager — its release predates the current database env (Secret Manager runtime secrets; for the gateway, its own database logins, #613). Run \`upgrade\` first: it moves the release and its env over together. Re-running the installer now would give the running release a database env it can't connect with.`,
98
+ };
99
+ }
100
+ }
101
+ return { ok: true };
102
+ }
103
+
104
+ /**
105
+ * Read the image digests the admin and gateway services run. Fails, naming
106
+ * what is missing, when a service can't be found unambiguously or doesn't
107
+ * run a digest-pinned image.
108
+ */
109
+ export async function readDeployedImageDigests(opts: {
110
+ projectId: string;
111
+ region: string;
112
+ run?: GcloudRunner;
113
+ }): Promise<{ ok: true; digests: ImageDigests } | { ok: false; error: string }> {
114
+ const run = opts.run ?? defaultGcloud;
115
+ const digests = { admin: "", gateway: "" };
116
+ for (const slug of ["admin", "gateway"] as const) {
117
+ const found = await findDeployedService(run, opts, slug);
118
+ if (!found.ok) return found;
119
+ const image = await run([
120
+ "run",
121
+ "services",
122
+ "describe",
123
+ found.name,
124
+ `--region=${opts.region}`,
125
+ `--project=${opts.projectId}`,
126
+ "--format=value(spec.template.spec.containers[0].image)",
127
+ ]);
128
+ if (!image.ok) return { ok: false, error: `describe ${found.name}: ${image.stderr.trim()}` };
129
+ const digest = digestFromImageRef(image.stdout);
130
+ if (!digest) {
131
+ return {
132
+ ok: false,
133
+ error: `${found.name} runs '${image.stdout.trim()}', which is not pinned to a digest`,
134
+ };
135
+ }
136
+ digests[slug] = digest;
137
+ }
138
+ return { ok: true, digests };
139
+ }
140
+
141
+ /** Where the digests a wizard run deploys come from. */
142
+ export type DigestChoice =
143
+ | {
144
+ readonly ok: true;
145
+ readonly source: "recorded" | "live" | "latest";
146
+ readonly digests: ImageDigests;
147
+ }
148
+ | { readonly ok: false; readonly error: string };
149
+
150
+ /**
151
+ * Pick the release a wizard run deploys. A re-run never changes versions —
152
+ * that is `upgrade`'s job, which migrates the database before shifting
153
+ * traffic:
154
+ *
155
+ * - digests recorded in install.json win;
156
+ * - an install that was provisioned (`deployed`) but predates the record
157
+ * keeps what its services run;
158
+ * - only a new install resolves the floating release tag.
159
+ */
160
+ export async function chooseImageDigests(opts: {
161
+ readonly recorded: ImageDigests | null;
162
+ readonly deployed: boolean;
163
+ readonly readLive: () => ReturnType<typeof readDeployedImageDigests>;
164
+ readonly resolveLatest: () => Promise<ImageDigests>;
165
+ }): Promise<DigestChoice> {
166
+ if (opts.recorded) return { ok: true, source: "recorded", digests: opts.recorded };
167
+ if (!opts.deployed) return { ok: true, source: "latest", digests: await opts.resolveLatest() };
168
+ const live = await opts.readLive();
169
+ if (!live.ok) {
170
+ return {
171
+ ok: false,
172
+ error: `This install was provisioned before the release it runs was recorded, and the running release can't be read (${live.error}). Run \`upgrade\` once — it records the release it rolls to — then re-run the installer.`,
173
+ };
174
+ }
175
+ return { ok: true, source: "live", digests: live.digests };
176
+ }
177
+
178
+ /**
179
+ * The region a deployed install's admin service runs in, read across all
180
+ * regions, or null when no admin service exists (nothing deployed yet).
181
+ * Used when install.json has no region recorded, so a re-run keeps the
182
+ * install where it is instead of asking (#607). Fails when the services
183
+ * can't be listed or the admin service exists in more than one region.
184
+ */
185
+ export async function readDeployedRegion(opts: {
186
+ projectId: string;
187
+ run?: GcloudRunner;
188
+ }): Promise<{ ok: true; region: string | null } | { ok: false; error: string }> {
189
+ const run = opts.run ?? defaultGcloud;
190
+ const prefix = `${gcpNamePrefix(GCP_STACK_ENV)}-admin`;
191
+ const list = await run([
192
+ "run",
193
+ "services",
194
+ "list",
195
+ `--project=${opts.projectId}`,
196
+ `--filter=metadata.name~^${prefix}`,
197
+ '--format=value(metadata.labels."cloud.googleapis.com/location")',
198
+ ]);
199
+ if (!list.ok) {
200
+ return {
201
+ ok: false,
202
+ error: `list Cloud Run services to find the install's region: ${list.stderr.trim() || "no output"}`,
203
+ };
204
+ }
205
+ const regions = [
206
+ ...new Set(
207
+ list.stdout
208
+ .split("\n")
209
+ .map((s) => s.trim())
210
+ .filter(Boolean),
211
+ ),
212
+ ];
213
+ if (regions.length > 1) {
214
+ return {
215
+ ok: false,
216
+ error: `${prefix}* Cloud Run services exist in several regions (${regions.join(", ")}); record the install's region in install.json ("region"), then re-run.`,
217
+ };
218
+ }
219
+ return { ok: true, region: regions[0] ?? null };
220
+ }
@@ -0,0 +1,182 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Cloudflare DNS adapter — zero-touch via the Cloudflare API.
5
+ * Uses the public REST API directly (no SDK) so the Caelo install
6
+ * doesn't carry the cloudflare-sdk's transitive dependency tree.
7
+ *
8
+ * Auth: CLOUDFLARE_API_TOKEN env var. Token scope needed:
9
+ * Zone:DNS:Edit on the target zone
10
+ * (Read-only Zone:Zone:Read is enough for `detectCloudflareAuth`.)
11
+ *
12
+ * Records are upserted — repeated runs are idempotent. Each CREATE
13
+ * is followed by a public-DNS poll to verify the change is visible
14
+ * (Cloudflare propagates within ~60s typically).
15
+ */
16
+
17
+ import { promises as dns } from "node:dns";
18
+ import { setTimeout as sleep } from "node:timers/promises";
19
+ import { log, spinner } from "@clack/prompts";
20
+ import { dim, green, red } from "kleur/colors";
21
+ import type { DnsAdapter, DnsRecord } from "./types.js";
22
+
23
+ const CF_API = "https://api.cloudflare.com/client/v4";
24
+
25
+ export async function detectCloudflareAuth(): Promise<{ token: string | null }> {
26
+ const token = process.env.CLOUDFLARE_API_TOKEN ?? null;
27
+ if (!token) return { token: null };
28
+ // Verify the token resolves (cheap HEAD-style call).
29
+ try {
30
+ const r = await fetch(`${CF_API}/user/tokens/verify`, {
31
+ headers: { Authorization: `Bearer ${token}` },
32
+ });
33
+ if (!r.ok) return { token: null };
34
+ return { token };
35
+ } catch {
36
+ return { token: null };
37
+ }
38
+ }
39
+
40
+ interface CfZone {
41
+ id: string;
42
+ name: string;
43
+ }
44
+
45
+ async function findZoneId(token: string, domain: string): Promise<string | null> {
46
+ // Try the apex first, then walk up subdomains until we hit a zone.
47
+ // (e.g. caelo-cms.com → caelo-cms.com)
48
+ const parts = domain.split(".");
49
+ for (let i = 0; i < parts.length - 1; i++) {
50
+ const candidate = parts.slice(i).join(".");
51
+ const r = await fetch(`${CF_API}/zones?name=${encodeURIComponent(candidate)}`, {
52
+ headers: { Authorization: `Bearer ${token}` },
53
+ });
54
+ if (!r.ok) continue;
55
+ const body = (await r.json()) as { result?: CfZone[] };
56
+ if (body.result && body.result.length > 0) return body.result[0]?.id ?? null;
57
+ }
58
+ return null;
59
+ }
60
+
61
+ interface CfRecord {
62
+ id: string;
63
+ type: string;
64
+ name: string;
65
+ content: string;
66
+ }
67
+
68
+ async function existingRecord(
69
+ token: string,
70
+ zoneId: string,
71
+ hostname: string,
72
+ type: string,
73
+ ): Promise<CfRecord | null> {
74
+ const r = await fetch(
75
+ `${CF_API}/zones/${zoneId}/dns_records?type=${type}&name=${encodeURIComponent(hostname)}`,
76
+ { headers: { Authorization: `Bearer ${token}` } },
77
+ );
78
+ if (!r.ok) return null;
79
+ const body = (await r.json()) as { result?: CfRecord[] };
80
+ return body.result?.[0] ?? null;
81
+ }
82
+
83
+ async function upsertRecord(token: string, zoneId: string, record: DnsRecord): Promise<void> {
84
+ const existing = await existingRecord(token, zoneId, record.hostname, record.type);
85
+ const body = {
86
+ type: record.type,
87
+ name: record.hostname,
88
+ content: record.value,
89
+ ttl: record.ttl ?? 1, // 1 = auto in Cloudflare
90
+ proxied: false, // Caelo expects raw A records hitting GCP LB IP, not Cloudflare's proxy
91
+ };
92
+ if (existing) {
93
+ if (existing.content === record.value) return; // already correct
94
+ const r = await fetch(`${CF_API}/zones/${zoneId}/dns_records/${existing.id}`, {
95
+ method: "PUT",
96
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
97
+ body: JSON.stringify(body),
98
+ });
99
+ if (!r.ok) throw new Error(`Cloudflare PUT ${record.hostname}: ${r.status} ${await r.text()}`);
100
+ return;
101
+ }
102
+ const r = await fetch(`${CF_API}/zones/${zoneId}/dns_records`, {
103
+ method: "POST",
104
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
105
+ body: JSON.stringify(body),
106
+ });
107
+ if (!r.ok) throw new Error(`Cloudflare POST ${record.hostname}: ${r.status} ${await r.text()}`);
108
+ }
109
+
110
+ export function makeCloudflareAdapter(opts: { domain: string; apiToken: string }): DnsAdapter {
111
+ return {
112
+ name: `Cloudflare (zero-touch via API)`,
113
+ async applyRecords(records: DnsRecord[]): Promise<void> {
114
+ const s = spinner();
115
+ s.start(`Locating Cloudflare zone for ${opts.domain}...`);
116
+ const zoneId = await findZoneId(opts.apiToken, opts.domain);
117
+ if (!zoneId) {
118
+ s.stop(red(`No Cloudflare zone found containing ${opts.domain}.`));
119
+ throw new Error(
120
+ `Domain ${opts.domain} isn't in your Cloudflare account. Add it as a zone first, or unset CLOUDFLARE_API_TOKEN to use the manual paste flow.`,
121
+ );
122
+ }
123
+ s.stop(green(`Cloudflare zone ${dim(zoneId)} found`));
124
+
125
+ for (const record of records) {
126
+ const s2 = spinner();
127
+ s2.start(`Upserting ${record.type} ${record.hostname} → ${record.value}...`);
128
+ try {
129
+ await upsertRecord(opts.apiToken, zoneId, record);
130
+ } catch (e) {
131
+ s2.stop(red(`Failed: ${e instanceof Error ? e.message : String(e)}`));
132
+ throw e;
133
+ }
134
+ s2.stop(green(`${record.type} ${record.hostname} set`));
135
+ }
136
+
137
+ // Quick public-DNS poll — Cloudflare usually propagates within
138
+ // ~60s. We give 5 min before giving up + telling the operator
139
+ // to verify manually.
140
+ const s3 = spinner();
141
+ s3.start("Verifying public DNS resolution...");
142
+ const start = Date.now();
143
+ const remaining = new Set(records.map((r) => `${r.type}:${r.hostname}`));
144
+ while (remaining.size > 0 && Date.now() - start < 5 * 60 * 1000) {
145
+ for (const key of [...remaining]) {
146
+ const record = records.find((r) => `${r.type}:${r.hostname}` === key);
147
+ if (!record) {
148
+ remaining.delete(key);
149
+ continue;
150
+ }
151
+ if (await checkRecord(record)) remaining.delete(key);
152
+ }
153
+ if (remaining.size > 0) await sleep(5000);
154
+ }
155
+ if (remaining.size > 0) {
156
+ s3.stop(red(`Some records still unresolved: ${[...remaining].join(", ")}`));
157
+ log.error(
158
+ `Cloudflare API succeeded but public DNS doesn't reflect the change yet. Wait + verify with \`dig\` then re-run.`,
159
+ );
160
+ throw new Error("DNS verify timeout.");
161
+ }
162
+ s3.stop(green("All records resolve publicly."));
163
+ },
164
+ };
165
+ }
166
+
167
+ async function checkRecord(record: DnsRecord): Promise<boolean> {
168
+ try {
169
+ if (record.type === "A") {
170
+ const result = await dns.resolve4(record.hostname);
171
+ return result.includes(record.value);
172
+ }
173
+ if (record.type === "CNAME") {
174
+ const result = await dns.resolveCname(record.hostname);
175
+ const expected = record.value.replace(/\.$/, "");
176
+ return result.some((v) => v.replace(/\.$/, "") === expected);
177
+ }
178
+ return true; // AAAA + TXT — assume Cloudflare API success means it's there
179
+ } catch {
180
+ return false;
181
+ }
182
+ }
@@ -0,0 +1,76 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import { formatDnsRecordGroups, groupDnsRecords, switchesPublicSite } from "./groups.js";
5
+
6
+ const domain = "example.com";
7
+
8
+ describe("groupDnsRecords", () => {
9
+ it("keeps the main domain's traffic records for go-live (gcp load balancer)", () => {
10
+ const records = [
11
+ { hostname: "example.com", type: "A", value: "203.0.113.7" },
12
+ { hostname: "admin.example.com", type: "A", value: "203.0.113.7" },
13
+ ];
14
+ const g = groupDnsRecords(records, domain);
15
+ expect(g.setNow.map((r) => r.hostname)).toEqual(["admin.example.com"]);
16
+ expect(g.atGoLive.map((r) => r.hostname)).toEqual(["example.com"]);
17
+ });
18
+
19
+ it("sets verification TXT records now and apex A records at go-live (gcp-firebase)", () => {
20
+ const records = [
21
+ { hostname: "admin.example.com", type: "CNAME", value: "ghs.googlehosted.com." },
22
+ { hostname: "example.com", type: "TXT", value: "hosting-site=caelo" },
23
+ { hostname: "_acme-challenge.example.com", type: "TXT", value: "abc" },
24
+ { hostname: "example.com", type: "A", value: "199.36.158.100" },
25
+ { hostname: "www.example.com", type: "CNAME", value: "example.web.app." },
26
+ ];
27
+ const g = groupDnsRecords(records, domain);
28
+ expect(g.setNow.map((r) => `${r.type} ${r.hostname}`)).toEqual([
29
+ "CNAME admin.example.com",
30
+ "TXT example.com",
31
+ "TXT _acme-challenge.example.com",
32
+ ]);
33
+ expect(g.atGoLive.map((r) => `${r.type} ${r.hostname}`)).toEqual([
34
+ "A example.com",
35
+ "CNAME www.example.com",
36
+ ]);
37
+ });
38
+
39
+ it("compares host names case- and trailing-dot-insensitively", () => {
40
+ expect(
41
+ switchesPublicSite({ hostname: "Example.com.", type: "aaaa", value: "::1" }, domain),
42
+ ).toBe(true);
43
+ expect(
44
+ switchesPublicSite({ hostname: "staging.example.com", type: "CNAME", value: "x" }, domain),
45
+ ).toBe(false);
46
+ });
47
+ });
48
+
49
+ describe("formatDnsRecordGroups", () => {
50
+ it("labels both groups and warns about the go-live records", () => {
51
+ const lines = formatDnsRecordGroups(
52
+ groupDnsRecords(
53
+ [
54
+ { hostname: "example.com", type: "A", value: "1.2.3.4" },
55
+ { hostname: "admin.example.com", type: "A", value: "1.2.3.4" },
56
+ ],
57
+ domain,
58
+ ),
59
+ domain,
60
+ );
61
+ expect(lines[0]).toBe("Set now (new hosts, nothing live uses them):");
62
+ expect(lines).toContain("Only at go-live (these move example.com to Caelo):");
63
+ expect(lines.join("\n")).toContain("takes a website that is live on example.com offline");
64
+ expect(lines.indexOf("Only at go-live (these move example.com to Caelo):")).toBeGreaterThan(
65
+ lines.findIndex((l) => /^\s+A\s+admin\.example\.com\s/.test(l)),
66
+ );
67
+ });
68
+
69
+ it("omits an empty group", () => {
70
+ const lines = formatDnsRecordGroups(
71
+ groupDnsRecords([{ hostname: "admin.example.com", type: "A", value: "1.2.3.4" }], domain),
72
+ domain,
73
+ );
74
+ expect(lines.some((l) => l.includes("go-live"))).toBe(false);
75
+ });
76
+ });
@@ -0,0 +1,86 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Which install DNS records are safe to set right away, and which switch the
5
+ * public website and so wait for go-live.
6
+ *
7
+ * An operator installing Caelo usually still runs a live site on the main
8
+ * domain. The admin host, the staging host and verification TXT records are
9
+ * new names nobody serves yet — set them now. An A / AAAA / CNAME record on
10
+ * the main domain or `www.` repoints every visitor to Caelo, which serves an
11
+ * empty site until the first publish: setting it during the install takes
12
+ * the old site down. Those wait for go-live.
13
+ */
14
+
15
+ /** A record as the stacks report it (`dnsRecordsRequiredOut`). */
16
+ export interface InstallDnsRecord {
17
+ readonly hostname: string;
18
+ readonly type: string;
19
+ readonly value: string;
20
+ readonly purpose?: string;
21
+ }
22
+
23
+ export interface DnsRecordGroups<R extends InstallDnsRecord> {
24
+ /** Records for hosts no live site uses — set during the install. */
25
+ readonly setNow: R[];
26
+ /** Records that move the public site to Caelo — set at go-live only. */
27
+ readonly atGoLive: R[];
28
+ }
29
+
30
+ const TRAFFIC_TYPES = new Set(["A", "AAAA", "CNAME"]);
31
+
32
+ function bare(hostname: string): string {
33
+ return hostname.trim().toLowerCase().replace(/\.$/, "");
34
+ }
35
+
36
+ /** Whether `record` repoints the public site on `domain` (apex or www). */
37
+ export function switchesPublicSite(record: InstallDnsRecord, domain: string): boolean {
38
+ const host = bare(record.hostname);
39
+ const apex = bare(domain);
40
+ return TRAFFIC_TYPES.has(record.type.toUpperCase()) && (host === apex || host === `www.${apex}`);
41
+ }
42
+
43
+ /** Split `records` into {@link DnsRecordGroups} for the install's `domain`. */
44
+ export function groupDnsRecords<R extends InstallDnsRecord>(
45
+ records: readonly R[],
46
+ domain: string,
47
+ ): DnsRecordGroups<R> {
48
+ const setNow: R[] = [];
49
+ const atGoLive: R[] = [];
50
+ for (const r of records) (switchesPublicSite(r, domain) ? atGoLive : setNow).push(r);
51
+ return { setNow, atGoLive };
52
+ }
53
+
54
+ /** One printable line per record: `TYPE host → value`. */
55
+ export function dnsRecordLine(r: InstallDnsRecord, hostWidth: number): string {
56
+ return ` ${r.type.padEnd(6)} ${r.hostname.padEnd(hostWidth)} → ${r.value}`;
57
+ }
58
+
59
+ /**
60
+ * The two labelled groups as text lines, for the installer's final summary.
61
+ * An empty group is left out; the go-live group always carries its warning.
62
+ */
63
+ export function formatDnsRecordGroups(
64
+ groups: DnsRecordGroups<InstallDnsRecord>,
65
+ domain: string,
66
+ ): string[] {
67
+ const width = Math.max(
68
+ domain.length,
69
+ ...[...groups.setNow, ...groups.atGoLive].map((r) => r.hostname.length),
70
+ );
71
+ const lines: string[] = [];
72
+ if (groups.setNow.length > 0) {
73
+ lines.push("Set now (new hosts, nothing live uses them):");
74
+ for (const r of groups.setNow) lines.push(dnsRecordLine(r, width));
75
+ }
76
+ if (groups.atGoLive.length > 0) {
77
+ if (lines.length > 0) lines.push("");
78
+ lines.push(`Only at go-live (these move ${domain} to Caelo):`);
79
+ for (const r of groups.atGoLive) lines.push(dnsRecordLine(r, width));
80
+ lines.push(
81
+ ` Warning: setting these now takes a website that is live on ${domain} offline.`,
82
+ " Replace the existing records once you have published the new site.",
83
+ );
84
+ }
85
+ return lines;
86
+ }
@@ -0,0 +1,56 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * DNS adapters — each provider implements the same `DnsAdapter`
5
+ * interface so the wizard can pick by registrar. Per CLAUDE.md §11.C:
6
+ * "DNS records land automatically when the registrar API is supported.
7
+ * Otherwise the CLI prints the records, polls DNS, and continues only
8
+ * when resolution succeeds."
9
+ *
10
+ * v1 adapters:
11
+ * - Cloudflare — zero-touch via the Cloudflare API
12
+ * - Route53 — zero-touch via the AWS SDK
13
+ * - manual — print records + verify-poll via dig
14
+ *
15
+ * Detection order: Cloudflare → Route53 → manual fallback. Detection
16
+ * is best-effort; the wizard always prompts to confirm.
17
+ */
18
+
19
+ import { detectCloudflareAuth, makeCloudflareAdapter } from "./cloudflare.js";
20
+ import { makeManualAdapter } from "./manual.js";
21
+ import type { DnsAdapter } from "./types.js";
22
+
23
+ export type { DnsAdapter } from "./types.js";
24
+
25
+ /**
26
+ * Pick the right DNS adapter based on environment + interactive
27
+ * detection. The CLI's wizard calls this; if a registrar's API is
28
+ * reachable, returns its adapter. Otherwise falls back to manual.
29
+ */
30
+ export async function pickDnsAdapter(opts: {
31
+ domain: string;
32
+ forceManual?: boolean;
33
+ /** No prompts: the manual adapter prints the records and polls (the install job). */
34
+ nonInteractive?: boolean;
35
+ }): Promise<DnsAdapter> {
36
+ const manual = { domain: opts.domain, nonInteractive: opts.nonInteractive ?? false };
37
+ if (opts.forceManual) {
38
+ return makeManualAdapter(manual);
39
+ }
40
+
41
+ // Cloudflare — needs CLOUDFLARE_API_TOKEN (read-only zone scope is
42
+ // enough for detection; create needs Zone:DNS:Edit).
43
+ const cf = await detectCloudflareAuth();
44
+ if (cf.token) {
45
+ return makeCloudflareAdapter({
46
+ domain: opts.domain,
47
+ apiToken: cf.token,
48
+ });
49
+ }
50
+
51
+ // Route53 — would need AWS_PROFILE / AWS_ACCESS_KEY_ID. Not yet
52
+ // implemented; falls through to manual. The interface + adapter
53
+ // skeleton land here so the future PR is one file.
54
+
55
+ return makeManualAdapter(manual);
56
+ }