@caelo-cms/provisioning 0.10.47 → 0.10.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (321) hide show
  1. package/caddy/Caddyfile.production +1 -1
  2. package/caddy/Caddyfile.staging +1 -1
  3. package/dist/agent/apply.d.ts +45 -0
  4. package/dist/agent/apply.d.ts.map +1 -0
  5. package/dist/agent/apply.js +83 -0
  6. package/dist/agent/apply.js.map +1 -0
  7. package/dist/agent/cli-provenance.d.ts +42 -0
  8. package/dist/agent/cli-provenance.d.ts.map +1 -0
  9. package/dist/agent/cli-provenance.js +138 -0
  10. package/dist/agent/cli-provenance.js.map +1 -0
  11. package/dist/agent/command.d.ts +79 -0
  12. package/dist/agent/command.d.ts.map +1 -0
  13. package/dist/agent/command.js +454 -0
  14. package/dist/agent/command.js.map +1 -0
  15. package/dist/agent/config.d.ts +30 -0
  16. package/dist/agent/config.d.ts.map +1 -0
  17. package/dist/agent/config.js +60 -0
  18. package/dist/agent/config.js.map +1 -0
  19. package/dist/agent/lock.d.ts +13 -0
  20. package/dist/agent/lock.d.ts.map +1 -0
  21. package/dist/agent/lock.js +100 -0
  22. package/dist/agent/lock.js.map +1 -0
  23. package/dist/agent/queue.d.ts +35 -0
  24. package/dist/agent/queue.d.ts.map +1 -0
  25. package/dist/agent/queue.js +50 -0
  26. package/dist/agent/queue.js.map +1 -0
  27. package/dist/agent/run.d.ts +97 -0
  28. package/dist/agent/run.d.ts.map +1 -0
  29. package/dist/agent/run.js +198 -0
  30. package/dist/agent/run.js.map +1 -0
  31. package/dist/agent/systemd.d.ts +55 -0
  32. package/dist/agent/systemd.d.ts.map +1 -0
  33. package/dist/agent/systemd.js +171 -0
  34. package/dist/agent/systemd.js.map +1 -0
  35. package/dist/agent/units.d.ts +44 -0
  36. package/dist/agent/units.d.ts.map +1 -0
  37. package/dist/agent/units.js +127 -0
  38. package/dist/agent/units.js.map +1 -0
  39. package/dist/agent-db.js +18402 -0
  40. package/dist/cli.js +119 -16
  41. package/dist/cli.js.map +1 -1
  42. package/dist/cloudsql-iam-user.d.ts +39 -0
  43. package/dist/cloudsql-iam-user.d.ts.map +1 -0
  44. package/dist/cloudsql-iam-user.js +119 -0
  45. package/dist/cloudsql-iam-user.js.map +1 -0
  46. package/dist/compose.d.ts +16 -0
  47. package/dist/compose.d.ts.map +1 -1
  48. package/dist/compose.js +26 -1
  49. package/dist/compose.js.map +1 -1
  50. package/dist/dns/index.d.ts +2 -0
  51. package/dist/dns/index.d.ts.map +1 -1
  52. package/dist/dns/index.js +3 -2
  53. package/dist/dns/index.js.map +1 -1
  54. package/dist/dns/manual.d.ts +5 -0
  55. package/dist/dns/manual.d.ts.map +1 -1
  56. package/dist/dns/manual.js +28 -12
  57. package/dist/dns/manual.js.map +1 -1
  58. package/dist/gcloud.d.ts +2 -0
  59. package/dist/gcloud.d.ts.map +1 -1
  60. package/dist/gcloud.js +1 -1
  61. package/dist/gcloud.js.map +1 -1
  62. package/dist/gcp-names.d.ts +15 -0
  63. package/dist/gcp-names.d.ts.map +1 -1
  64. package/dist/gcp-names.js +23 -0
  65. package/dist/gcp-names.js.map +1 -1
  66. package/dist/index.d.ts +6 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +4 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/infra-contract.d.ts +1 -1
  71. package/dist/infra-contract.js +7 -1
  72. package/dist/infra-contract.js.map +1 -1
  73. package/dist/install-job-runner.d.ts +13 -0
  74. package/dist/install-job-runner.d.ts.map +1 -0
  75. package/dist/install-job-runner.js +155 -0
  76. package/dist/install-job-runner.js.map +1 -0
  77. package/dist/install-job.d.ts +210 -0
  78. package/dist/install-job.d.ts.map +1 -0
  79. package/dist/install-job.js +449 -0
  80. package/dist/install-job.js.map +1 -0
  81. package/dist/install-state.d.ts +10 -1
  82. package/dist/install-state.d.ts.map +1 -1
  83. package/dist/install-state.js +10 -2
  84. package/dist/install-state.js.map +1 -1
  85. package/dist/lifecycle.d.ts.map +1 -1
  86. package/dist/lifecycle.js +19 -4
  87. package/dist/lifecycle.js.map +1 -1
  88. package/dist/migration-runner.d.ts +5 -0
  89. package/dist/migration-runner.d.ts.map +1 -1
  90. package/dist/migration-runner.js +1 -1
  91. package/dist/migration-runner.js.map +1 -1
  92. package/dist/operator-access.d.ts +14 -8
  93. package/dist/operator-access.d.ts.map +1 -1
  94. package/dist/operator-access.js +16 -102
  95. package/dist/operator-access.js.map +1 -1
  96. package/dist/release-manifest.d.ts +2 -41
  97. package/dist/release-manifest.d.ts.map +1 -1
  98. package/dist/release-manifest.js +18 -121
  99. package/dist/release-manifest.js.map +1 -1
  100. package/dist/run-mode.d.ts +27 -0
  101. package/dist/run-mode.d.ts.map +1 -0
  102. package/dist/run-mode.js +22 -0
  103. package/dist/run-mode.js.map +1 -0
  104. package/dist/runtime-secrets.d.ts +19 -0
  105. package/dist/runtime-secrets.d.ts.map +1 -1
  106. package/dist/runtime-secrets.js +44 -0
  107. package/dist/runtime-secrets.js.map +1 -1
  108. package/dist/self-hosted-roles.d.ts +22 -1
  109. package/dist/self-hosted-roles.d.ts.map +1 -1
  110. package/dist/self-hosted-roles.js +81 -12
  111. package/dist/self-hosted-roles.js.map +1 -1
  112. package/dist/self-hosted-upgrade.d.ts +17 -0
  113. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  114. package/dist/self-hosted-upgrade.js +16 -1
  115. package/dist/self-hosted-upgrade.js.map +1 -1
  116. package/dist/stack-contract.d.ts +6 -0
  117. package/dist/stack-contract.d.ts.map +1 -1
  118. package/dist/stack-contract.js +10 -1
  119. package/dist/stack-contract.js.map +1 -1
  120. package/dist/upgrade/converge.d.ts +6 -0
  121. package/dist/upgrade/converge.d.ts.map +1 -1
  122. package/dist/upgrade/converge.js +59 -2
  123. package/dist/upgrade/converge.js.map +1 -1
  124. package/dist/upgrade/gcp-lookup.d.ts +6 -0
  125. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  126. package/dist/upgrade/gcp-lookup.js +25 -0
  127. package/dist/upgrade/gcp-lookup.js.map +1 -1
  128. package/dist/upgrade/release-images.d.ts +26 -2
  129. package/dist/upgrade/release-images.d.ts.map +1 -1
  130. package/dist/upgrade/release-images.js +35 -2
  131. package/dist/upgrade/release-images.js.map +1 -1
  132. package/dist/upgrade/upgrade.d.ts +5 -0
  133. package/dist/upgrade/upgrade.d.ts.map +1 -1
  134. package/dist/upgrade/upgrade.js +28 -1
  135. package/dist/upgrade/upgrade.js.map +1 -1
  136. package/dist/upgrader-assess.d.ts +85 -0
  137. package/dist/upgrader-assess.d.ts.map +1 -0
  138. package/dist/upgrader-assess.js +122 -0
  139. package/dist/upgrader-assess.js.map +1 -0
  140. package/dist/upgrader-gcp.d.ts +17 -0
  141. package/dist/upgrader-gcp.d.ts.map +1 -0
  142. package/dist/upgrader-gcp.js +87 -0
  143. package/dist/upgrader-gcp.js.map +1 -0
  144. package/dist/upgrader-job.d.ts +133 -0
  145. package/dist/upgrader-job.d.ts.map +1 -0
  146. package/dist/upgrader-job.js +259 -0
  147. package/dist/upgrader-job.js.map +1 -0
  148. package/dist/upgrader-reporter.d.ts +35 -0
  149. package/dist/upgrader-reporter.d.ts.map +1 -0
  150. package/dist/upgrader-reporter.js +79 -0
  151. package/dist/upgrader-reporter.js.map +1 -0
  152. package/dist/upgrader-setup.d.ts +59 -0
  153. package/dist/upgrader-setup.d.ts.map +1 -0
  154. package/dist/upgrader-setup.js +308 -0
  155. package/dist/upgrader-setup.js.map +1 -0
  156. package/dist/wizard.d.ts +6 -0
  157. package/dist/wizard.d.ts.map +1 -1
  158. package/dist/wizard.js +32 -4
  159. package/dist/wizard.js.map +1 -1
  160. package/dist/wizards/gcp-firebase.d.ts +3 -0
  161. package/dist/wizards/gcp-firebase.d.ts.map +1 -1
  162. package/dist/wizards/gcp-firebase.js +1 -0
  163. package/dist/wizards/gcp-firebase.js.map +1 -1
  164. package/dist/wizards/gcp-install-job.d.ts +74 -0
  165. package/dist/wizards/gcp-install-job.d.ts.map +1 -0
  166. package/dist/wizards/gcp-install-job.js +398 -0
  167. package/dist/wizards/gcp-install-job.js.map +1 -0
  168. package/dist/wizards/gcp-prompts.d.ts +3 -0
  169. package/dist/wizards/gcp-prompts.d.ts.map +1 -0
  170. package/dist/wizards/gcp-prompts.js +25 -0
  171. package/dist/wizards/gcp-prompts.js.map +1 -0
  172. package/dist/wizards/gcp-pulumi.d.ts +4 -2
  173. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  174. package/dist/wizards/gcp-pulumi.js +1 -1
  175. package/dist/wizards/gcp-pulumi.js.map +1 -1
  176. package/dist/wizards/gcp.d.ts +40 -0
  177. package/dist/wizards/gcp.d.ts.map +1 -1
  178. package/dist/wizards/gcp.js +223 -59
  179. package/dist/wizards/gcp.js.map +1 -1
  180. package/package.json +6 -4
  181. package/src/adapter.ts +97 -0
  182. package/src/adc-impersonation.ts +176 -0
  183. package/src/admin-domain.test.ts +254 -0
  184. package/src/admin-domain.ts +245 -0
  185. package/src/admin-memory.test.ts +151 -0
  186. package/src/agent/apply.test.ts +185 -0
  187. package/src/agent/apply.ts +124 -0
  188. package/src/agent/cli-provenance.test.ts +159 -0
  189. package/src/agent/cli-provenance.ts +191 -0
  190. package/src/agent/command.test.ts +77 -0
  191. package/src/agent/command.ts +564 -0
  192. package/src/agent/config.ts +86 -0
  193. package/src/agent/lock-config.test.ts +97 -0
  194. package/src/agent/lock.ts +110 -0
  195. package/src/agent/queue.test.ts +10 -0
  196. package/src/agent/queue.ts +91 -0
  197. package/src/agent/run.test.ts +437 -0
  198. package/src/agent/run.ts +295 -0
  199. package/src/agent/systemd.ts +209 -0
  200. package/src/agent/units.test.ts +223 -0
  201. package/src/agent/units.ts +163 -0
  202. package/src/azure-front-door.test.ts +119 -0
  203. package/src/azure-front-door.ts +56 -0
  204. package/src/bootstrap-token.ts +20 -0
  205. package/src/caddy.ts +112 -0
  206. package/src/cdn-copy.ts +91 -0
  207. package/src/cert-wait.test.ts +194 -0
  208. package/src/cert-wait.ts +163 -0
  209. package/src/checkpoint-secrets.test.ts +380 -0
  210. package/src/checkpoint-secrets.ts +283 -0
  211. package/src/cli-routing.test.ts +45 -0
  212. package/src/cli-routing.ts +43 -0
  213. package/src/cli.ts +1491 -0
  214. package/src/cloudsql-iam-user.ts +147 -0
  215. package/src/compose.ts +334 -0
  216. package/src/default-firewall.test.ts +411 -0
  217. package/src/default-firewall.ts +342 -0
  218. package/src/deployed-release.test.ts +231 -0
  219. package/src/deployed-release.ts +220 -0
  220. package/src/dns/cloudflare.ts +182 -0
  221. package/src/dns/groups.test.ts +76 -0
  222. package/src/dns/groups.ts +86 -0
  223. package/src/dns/index.ts +56 -0
  224. package/src/dns/manual.ts +130 -0
  225. package/src/dns/types.ts +27 -0
  226. package/src/gateway-credentials.test.ts +180 -0
  227. package/src/gateway-credentials.ts +144 -0
  228. package/src/gcloud-components.test.ts +76 -0
  229. package/src/gcloud-components.ts +85 -0
  230. package/src/gcloud-retry.ts +41 -0
  231. package/src/gcloud-runner.test.ts +61 -0
  232. package/src/gcloud.test.ts +99 -0
  233. package/src/gcloud.ts +322 -0
  234. package/src/gcp-names.ts +135 -0
  235. package/src/index.test.ts +332 -0
  236. package/src/index.ts +114 -0
  237. package/src/infra-contract.ts +32 -0
  238. package/src/install-job-runner.ts +166 -0
  239. package/src/install-job.test.ts +566 -0
  240. package/src/install-job.ts +542 -0
  241. package/src/install-state.test.ts +239 -0
  242. package/src/install-state.ts +583 -0
  243. package/src/internal-token.ts +35 -0
  244. package/src/lifecycle.ts +900 -0
  245. package/src/mcp-iap-stacks.test.ts +58 -0
  246. package/src/mcp-iap.test.ts +149 -0
  247. package/src/mcp-iap.ts +131 -0
  248. package/src/media-volume.test.ts +296 -0
  249. package/src/migration-runner.ts +365 -0
  250. package/src/operator-access-stacks.test.ts +55 -0
  251. package/src/operator-access.test.ts +332 -0
  252. package/src/operator-access.ts +554 -0
  253. package/src/ops-backend.test.ts +320 -0
  254. package/src/ops-backend.ts +414 -0
  255. package/src/provisioner-identity.test.ts +482 -0
  256. package/src/provisioner-identity.ts +407 -0
  257. package/src/redirects-emit.ts +175 -0
  258. package/src/region-data.ts +428 -0
  259. package/src/regions.test.ts +277 -0
  260. package/src/regions.ts +370 -0
  261. package/src/release-contract.ts +57 -0
  262. package/src/release-manifest.test.ts +300 -0
  263. package/src/release-manifest.ts +310 -0
  264. package/src/run-mode.ts +40 -0
  265. package/src/runtime-secrets.test.ts +627 -0
  266. package/src/runtime-secrets.ts +754 -0
  267. package/src/self-command.test.ts +14 -0
  268. package/src/self-command.ts +21 -0
  269. package/src/self-hosted-compose.test.ts +177 -0
  270. package/src/self-hosted-release.ts +240 -0
  271. package/src/self-hosted-roles.integration.test.ts +161 -0
  272. package/src/self-hosted-roles.test.ts +124 -0
  273. package/src/self-hosted-roles.ts +203 -0
  274. package/src/self-hosted-upgrade.test.ts +394 -0
  275. package/src/self-hosted-upgrade.ts +548 -0
  276. package/src/stack-contract-parity.test.ts +199 -0
  277. package/src/stack-contract.test.ts +391 -0
  278. package/src/stack-contract.ts +888 -0
  279. package/src/stack-converge-secrets.test.ts +421 -0
  280. package/src/stack-converge.test.ts +833 -0
  281. package/src/stack-converge.ts +909 -0
  282. package/src/staging-admin.test.ts +246 -0
  283. package/src/staging-admin.ts +324 -0
  284. package/src/state-attach.test.ts +211 -0
  285. package/src/state-attach.ts +132 -0
  286. package/src/state-backend.test.ts +145 -0
  287. package/src/state-backend.ts +262 -0
  288. package/src/state-command.ts +293 -0
  289. package/src/state-migrate.test.ts +479 -0
  290. package/src/state-migrate.ts +434 -0
  291. package/src/state-pulumi.ts +289 -0
  292. package/src/state-store.test.ts +212 -0
  293. package/src/state-store.ts +243 -0
  294. package/src/upgrade/app-roll.ts +486 -0
  295. package/src/upgrade/backup.ts +109 -0
  296. package/src/upgrade/converge.ts +437 -0
  297. package/src/upgrade/gcp-lookup.ts +167 -0
  298. package/src/upgrade/health.test.ts +279 -0
  299. package/src/upgrade/health.ts +305 -0
  300. package/src/upgrade/release-images.test.ts +119 -0
  301. package/src/upgrade/release-images.ts +393 -0
  302. package/src/upgrade/reporter.ts +46 -0
  303. package/src/upgrade/upgrade.test.ts +626 -0
  304. package/src/upgrade/upgrade.ts +249 -0
  305. package/src/upgrader-assess.test.ts +306 -0
  306. package/src/upgrader-assess.ts +250 -0
  307. package/src/upgrader-gcp.ts +123 -0
  308. package/src/upgrader-job.test.ts +411 -0
  309. package/src/upgrader-job.ts +502 -0
  310. package/src/upgrader-reporter.test.ts +46 -0
  311. package/src/upgrader-reporter.ts +105 -0
  312. package/src/upgrader-setup.test.ts +169 -0
  313. package/src/upgrader-setup.ts +355 -0
  314. package/src/wizard.ts +425 -0
  315. package/src/wizards/gcp-cost.ts +188 -0
  316. package/src/wizards/gcp-firebase.ts +72 -0
  317. package/src/wizards/gcp-install-job.ts +506 -0
  318. package/src/wizards/gcp-prompts.ts +26 -0
  319. package/src/wizards/gcp-pulumi.test.ts +246 -0
  320. package/src/wizards/gcp-pulumi.ts +516 -0
  321. package/src/wizards/gcp.ts +1935 -0
package/src/caddy.ts ADDED
@@ -0,0 +1,112 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * P14 — Caddyfile generator.
5
+ *
6
+ * Reads the `domains` table + the deploy targets and emits a
7
+ * deterministic Caddyfile string. The CLI's `regenerate-caddy`
8
+ * sub-command writes this to `/etc/caddy/Caddyfile` and runs
9
+ * `caddy reload`.
10
+ *
11
+ * Per-vhost shape:
12
+ * <hostname> {
13
+ * # admin → reverse_proxy localhost:5173
14
+ * # public → root + try_files (static), with /api/* → gateway
15
+ * tls <ownerEmail>
16
+ * }
17
+ *
18
+ * Staging vhosts force `X-Robots-Tag: noindex`.
19
+ *
20
+ * Public vhosts mark content-hashed build outputs (fonts, plugin
21
+ * bundles — `CONTENT_HASHED_PATH_PATTERN` in @caelo-cms/shared)
22
+ * `immutable` for a year. Everything else keeps Caddy's file_server
23
+ * default (no Cache-Control; ETag + Last-Modified for revalidation),
24
+ * so pages and slug-addressed media are never pinned in caches. The
25
+ * matcher also requires the file to exist (`file`), so a 404 for a
26
+ * hashed URL (an old page requesting an asset the new build dropped)
27
+ * is never cached for a year.
28
+ *
29
+ * `.ico` files (theme favicons) are served as `image/x-icon`, the one
30
+ * icon MIME the media library stores and `<link rel="icon" type>`
31
+ * declares. file_server only fills Content-Type when no earlier handler
32
+ * set it, so the `header` line wins over the host's mime.types name.
33
+ */
34
+
35
+ import { CONTENT_HASHED_PATH_PATTERN, IMMUTABLE_CACHE_CONTROL } from "@caelo-cms/shared";
36
+
37
+ export interface CaddyDomainSpec {
38
+ readonly hostname: string;
39
+ readonly kind: "admin" | "public";
40
+ readonly env: "production" | "staging";
41
+ }
42
+
43
+ export interface CaddyfileSpec {
44
+ readonly ownerEmail: string;
45
+ readonly publicSiteRoot: string; // absolute path on disk
46
+ readonly stagingSiteRoot: string;
47
+ readonly adminPort: number;
48
+ readonly gatewayPort: number;
49
+ readonly domains: ReadonlyArray<CaddyDomainSpec>;
50
+ }
51
+
52
+ export function generateCaddyfile(spec: CaddyfileSpec): string {
53
+ const blocks: string[] = [];
54
+
55
+ // Global options.
56
+ blocks.push(`{
57
+ email ${spec.ownerEmail}
58
+ }
59
+ `);
60
+
61
+ for (const d of spec.domains) {
62
+ blocks.push(vhost(d, spec));
63
+ }
64
+
65
+ // Localhost dev fallback when no domains are configured (so a fresh
66
+ // `cms-provision` produces a working Caddyfile even pre-DNS).
67
+ if (spec.domains.length === 0) {
68
+ blocks.push(`# No domains configured yet — cms-provision regenerate-caddy
69
+ # will overwrite this file when you add one at /settings/domains.
70
+ :8081 {
71
+ reverse_proxy localhost:${spec.adminPort}
72
+ }
73
+ `);
74
+ }
75
+
76
+ return blocks.join("\n");
77
+ }
78
+
79
+ function vhost(d: CaddyDomainSpec, spec: CaddyfileSpec): string {
80
+ const noindex = d.env === "staging" ? `\n header X-Robots-Tag "noindex"` : "";
81
+ if (d.kind === "admin") {
82
+ return `${d.hostname} {${noindex}
83
+ reverse_proxy localhost:${spec.adminPort}
84
+ }
85
+ `;
86
+ }
87
+ // public — API routes go to the gateway,
88
+ // the rest serves static files. Locale variants serve from a
89
+ const root = d.env === "staging" ? spec.stagingSiteRoot : spec.publicSiteRoot;
90
+ return `${d.hostname} {${noindex}
91
+ root * ${root}
92
+ @content_hashed {
93
+ path_regexp content_hashed ${CONTENT_HASHED_PATH_PATTERN}
94
+ file
95
+ }
96
+ header @content_hashed Cache-Control "${IMMUTABLE_CACHE_CONTROL}"
97
+ @ico path *.ico
98
+ header @ico Content-Type "image/x-icon"
99
+ handle /api/* {
100
+ reverse_proxy localhost:${spec.gatewayPort}
101
+ }
102
+ handle /admin/* {
103
+ reverse_proxy localhost:${spec.adminPort}
104
+ }
105
+ handle {
106
+ file_server {
107
+ try_files {path} {path}/index.html /index.html
108
+ }
109
+ }
110
+ }
111
+ `;
112
+ }
@@ -0,0 +1,91 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * P15 — per-provider CDN-copy adapter contract.
5
+ *
6
+ * P7 ships the `media_assets.usage_count` threshold + the `media.set_cdn`
7
+ * op that flips a per-asset boolean. P15 wires the actual edge-pin
8
+ * operation per provider:
9
+ * - AWS → CloudFront cache invalidation + warmer request.
10
+ * - GCP → set Cache-Control metadata on the GCS object so Cloud CDN
11
+ * respects the long TTL.
12
+ * - Azure → Front Door purge + warm endpoint.
13
+ *
14
+ * The redeploy-orchestrator's `cdnCopyTick()` (currently a no-op for
15
+ * cloud installs) drains the per-asset CDN-pin queue using the adapter
16
+ * registered for the active provider.
17
+ *
18
+ * Each provider's stack publishes a `CdnCopyAdapter` instance via its
19
+ * Pulumi outputs (the actual implementation is dynamically imported by
20
+ * the orchestrator at runtime so `@caelo-cms/provisioning` doesn't pull in
21
+ * `@aws-sdk/...`/`@google-cloud/...`/`@azure/...` as static deps). The
22
+ * interface defined here is the only thing both sides agree on.
23
+ */
24
+
25
+ export interface CdnCopyAdapter {
26
+ /**
27
+ * Mark the asset at `assetKey` (object key in primary storage) as
28
+ * CDN-cached. Returns the public CDN URL — admin uses this as the
29
+ * canonical URL for the asset thereafter.
30
+ */
31
+ pin(assetKey: string): Promise<string>;
32
+ /** Reverse — drop from CDN edge cache. */
33
+ unpin(assetKey: string): Promise<void>;
34
+ }
35
+
36
+ /**
37
+ * No-op adapter for self-hosted installs (Caddy serves directly from
38
+ * disk; no separate CDN tier). Returns the input asset key as a relative
39
+ * URL so callers don't have to special-case the no-CDN case.
40
+ */
41
+ export const selfHostedCdnCopy: CdnCopyAdapter = {
42
+ async pin(assetKey) {
43
+ return `/media/${assetKey}`;
44
+ },
45
+ async unpin() {
46
+ // no-op
47
+ },
48
+ };
49
+
50
+ /**
51
+ * Resolves the right adapter per `process.env.CAELO_PROVIDER`. Lazy
52
+ * dynamic-imports the per-provider implementation so a self-hosted
53
+ * install never loads the cloud SDKs.
54
+ *
55
+ * Returns `selfHostedCdnCopy` ONLY for `provider==='self-hosted'` or an
56
+ * unset env (treated as self-hosted). For `gcp`/`aws`/`azure`, the
57
+ * matching `cdn-copy-<gcs|aws|azure>.ts` file MUST exist in this
58
+ * directory — those land with the per-provider PRs. Per CLAUDE.md §2
59
+ * "no fallbacks pre-1.0" we deliberately do NOT silently degrade to
60
+ * the self-hosted no-op when the file is missing: a missing module is
61
+ * a deploy bug + the orchestrator tick should crash loudly so the
62
+ * operator notices instead of silently dropping CDN-pin requests.
63
+ */
64
+ export async function loadCdnCopyAdapter(provider?: string): Promise<CdnCopyAdapter> {
65
+ switch (provider) {
66
+ case "gcp":
67
+ // biome-ignore lint/suspicious/noExplicitAny: opt-in dynamic import
68
+ return ((await import("./cdn-copy-gcs.js" as string)) as any).gcsCloudCdnPin;
69
+ case "gcp-firebase":
70
+ // v0.3.0 — Firebase Hosting manages CDN caching natively (each
71
+ // deploy creates an immutable version + the CDN's freshness is
72
+ // dictated by Firebase Hosting headers). There's no per-asset
73
+ // "pin to edge" API; this adapter is effectively a no-op that
74
+ // returns the same shape as self-hosted.
75
+ return selfHostedCdnCopy;
76
+ case "aws":
77
+ // biome-ignore lint/suspicious/noExplicitAny: opt-in dynamic import
78
+ return ((await import("./cdn-copy-aws.js" as string)) as any).s3CloudfrontPrewarm;
79
+ case "azure":
80
+ // biome-ignore lint/suspicious/noExplicitAny: opt-in dynamic import
81
+ return ((await import("./cdn-copy-azure.js" as string)) as any).azureBlobCdnPrefetch;
82
+ case undefined:
83
+ case "":
84
+ case "self-hosted":
85
+ return selfHostedCdnCopy;
86
+ default:
87
+ throw new Error(
88
+ `loadCdnCopyAdapter: unknown CAELO_PROVIDER='${provider}'. Expected one of: self-hosted | gcp | gcp-firebase | aws | azure.`,
89
+ );
90
+ }
91
+ }
@@ -0,0 +1,194 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import { type CertProbe, domainMappingProbe, lbCertProbe, waitForCert } from "./cert-wait.js";
5
+ import type { GcloudResult } from "./gcloud.js";
6
+
7
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
8
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
9
+
10
+ /** A fake clock the injected sleep advances. */
11
+ function clock() {
12
+ let t = 0;
13
+ return {
14
+ now: () => t,
15
+ sleep: async (ms: number) => {
16
+ t += ms;
17
+ },
18
+ elapsed: () => t,
19
+ };
20
+ }
21
+
22
+ function probes(...seq: CertProbe[]) {
23
+ let i = 0;
24
+ return async () => seq[Math.min(i++, seq.length - 1)] as CertProbe;
25
+ }
26
+
27
+ describe("waitForCert", () => {
28
+ it("returns once the probe reports ready, reporting each new detail once", async () => {
29
+ const c = clock();
30
+ const updates: string[] = [];
31
+ const r = await waitForCert({
32
+ probe: probes(
33
+ { state: "pending", detail: "waiting for DNS" },
34
+ { state: "pending", detail: "waiting for DNS" },
35
+ { state: "pending", detail: "issuing" },
36
+ { state: "ready" },
37
+ ),
38
+ timeoutMs: 60_000,
39
+ intervalMs: 10_000,
40
+ onUpdate: (d) => updates.push(d),
41
+ ...c,
42
+ });
43
+ expect(r).toEqual({ ok: true });
44
+ expect(updates).toEqual(["waiting for DNS", "issuing"]);
45
+ expect(c.elapsed()).toBe(30_000);
46
+ });
47
+
48
+ it("times out with the last detail instead of waiting forever", async () => {
49
+ const c = clock();
50
+ const r = await waitForCert({
51
+ probe: probes({ state: "pending", detail: "CertificateProvisioned: DNS not set" }),
52
+ timeoutMs: 25_000,
53
+ intervalMs: 10_000,
54
+ ...c,
55
+ });
56
+ expect(r).toEqual({
57
+ ok: false,
58
+ reason: "timeout",
59
+ lastDetail: "CertificateProvisioned: DNS not set",
60
+ });
61
+ // Probed at 0, 10s, 20s, 25s (the deadline) — never slept past it.
62
+ expect(c.elapsed()).toBe(25_000);
63
+ });
64
+
65
+ it("stops at once when gcloud fails, since waiting cannot fix that", async () => {
66
+ const c = clock();
67
+ const r = await waitForCert({
68
+ probe: probes({ state: "error", error: "PERMISSION_DENIED" }),
69
+ timeoutMs: 60_000,
70
+ intervalMs: 10_000,
71
+ ...c,
72
+ });
73
+ expect(r).toEqual({ ok: false, reason: "error", error: "PERMISSION_DENIED" });
74
+ expect(c.elapsed()).toBe(0);
75
+ });
76
+ });
77
+
78
+ const target = { hostname: "admin.example.com", region: "europe-west1", projectId: "p" };
79
+
80
+ describe("domainMappingProbe", () => {
81
+ it("is ready when the mapping's Ready condition is True", async () => {
82
+ const calls: string[][] = [];
83
+ const run = async (args: string[]) => {
84
+ calls.push(args);
85
+ return ok(JSON.stringify({ status: { conditions: [{ type: "Ready", status: "True" }] } }));
86
+ };
87
+ expect(await domainMappingProbe(run, target)).toEqual({ state: "ready" });
88
+ expect(calls[0]).toEqual([
89
+ "beta",
90
+ "run",
91
+ "domain-mappings",
92
+ "describe",
93
+ "--domain=admin.example.com",
94
+ "--region=europe-west1",
95
+ "--project=p",
96
+ "--format=json",
97
+ ]);
98
+ });
99
+
100
+ it("reports the certificate condition's message while pending", async () => {
101
+ const run = async () =>
102
+ ok(
103
+ JSON.stringify({
104
+ status: {
105
+ conditions: [
106
+ { type: "Ready", status: "Unknown", message: "not ready" },
107
+ {
108
+ type: "CertificateProvisioned",
109
+ status: "Unknown",
110
+ message: "Certificate issuance pending. Add the DNS record.",
111
+ },
112
+ { type: "DomainRoutable", status: "True" },
113
+ ],
114
+ },
115
+ }),
116
+ );
117
+ expect(await domainMappingProbe(run, target)).toEqual({
118
+ state: "pending",
119
+ detail: "CertificateProvisioned: Certificate issuance pending. Add the DNS record.",
120
+ });
121
+ });
122
+
123
+ it("is pending (not ready) before Google reports any condition", async () => {
124
+ const run = async () => ok(JSON.stringify({ status: {} }));
125
+ expect((await domainMappingProbe(run, target)).state).toBe("pending");
126
+ });
127
+
128
+ it("surfaces a gcloud failure as an error", async () => {
129
+ const run = async () => fail("ERROR: (gcloud) You do not currently have an active account");
130
+ const r = await domainMappingProbe(run, target);
131
+ expect(r.state).toBe("error");
132
+ });
133
+ });
134
+
135
+ describe("lbCertProbe", () => {
136
+ const list = (certs: unknown[]) => async () => ok(JSON.stringify(certs));
137
+ const lb = { projectId: "p", hostname: "admin.example.com" };
138
+
139
+ it("is ready when the admin's own certificate is ACTIVE although the main domain's is not", async () => {
140
+ const r = await lbCertProbe(
141
+ list([
142
+ {
143
+ name: "combined",
144
+ managed: {
145
+ status: "PROVISIONING",
146
+ domainStatus: { "example.com": "FAILED_NOT_VISIBLE", "admin.example.com": "ACTIVE" },
147
+ },
148
+ },
149
+ {
150
+ name: "admin-only",
151
+ managed: { status: "ACTIVE", domainStatus: { "admin.example.com": "ACTIVE" } },
152
+ },
153
+ ]),
154
+ lb,
155
+ );
156
+ expect(r).toEqual({ state: "ready" });
157
+ });
158
+
159
+ it("does not count a domain that is ACTIVE on a certificate that is not served yet", async () => {
160
+ const r = await lbCertProbe(
161
+ list([
162
+ {
163
+ name: "combined",
164
+ managed: {
165
+ status: "PROVISIONING",
166
+ domainStatus: { "example.com": "FAILED_NOT_VISIBLE", "admin.example.com": "ACTIVE" },
167
+ },
168
+ },
169
+ ]),
170
+ lb,
171
+ );
172
+ expect(r).toEqual({ state: "pending", detail: "combined: admin.example.com ACTIVE" });
173
+ });
174
+
175
+ it("is ready for installs that only have the combined certificate, once it is ACTIVE", async () => {
176
+ const r = await lbCertProbe(
177
+ list([
178
+ {
179
+ name: "combined",
180
+ managed: {
181
+ status: "ACTIVE",
182
+ domainStatus: { "example.com": "ACTIVE", "admin.example.com": "ACTIVE" },
183
+ },
184
+ },
185
+ ]),
186
+ lb,
187
+ );
188
+ expect(r).toEqual({ state: "ready" });
189
+ });
190
+
191
+ it("is pending when no certificate lists the host", async () => {
192
+ expect((await lbCertProbe(list([]), lb)).state).toBe("pending");
193
+ });
194
+ });
@@ -0,0 +1,163 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Waiting for a Google-managed TLS certificate (§11.C: the CLI polls certs
5
+ * itself instead of telling the operator to check back later).
6
+ *
7
+ * Two certificate sources, one wait loop:
8
+ * - the `gcp` load balancer's managed SSL certificates
9
+ * ({@link lbCertProbe}) — the installer waits for the admin host;
10
+ * - a Cloud Run domain mapping's certificate ({@link domainMappingProbe})
11
+ * — `admin-domain enable` / `staging-domain enable` on gcp-firebase.
12
+ *
13
+ * {@link waitForCert} is pure apart from its injected clock, sleep and
14
+ * probe, so the timeout and progress behaviour is unit-tested without
15
+ * gcloud or real time.
16
+ */
17
+
18
+ import type { GcloudResult } from "./gcloud.js";
19
+
20
+ type Run = (args: string[]) => Promise<GcloudResult>;
21
+
22
+ /** One look at a certificate. */
23
+ export type CertProbe =
24
+ | { readonly state: "ready" }
25
+ | { readonly state: "pending"; readonly detail: string }
26
+ /** gcloud itself failed (auth, permissions) — waiting will not fix it. */
27
+ | { readonly state: "error"; readonly error: string };
28
+
29
+ export type CertWaitResult =
30
+ | { readonly ok: true }
31
+ | { readonly ok: false; readonly reason: "timeout"; readonly lastDetail: string }
32
+ | { readonly ok: false; readonly reason: "error"; readonly error: string };
33
+
34
+ export interface CertWaitOpts {
35
+ readonly probe: () => Promise<CertProbe>;
36
+ readonly timeoutMs: number;
37
+ readonly intervalMs: number;
38
+ /** Called whenever the probe's detail text changes (spinner message). */
39
+ readonly onUpdate?: (detail: string) => void;
40
+ readonly sleep?: (ms: number) => Promise<void>;
41
+ readonly now?: () => number;
42
+ }
43
+
44
+ /**
45
+ * Poll `probe` until the certificate is ready, gcloud fails, or `timeoutMs`
46
+ * passes. The probe runs once more at the deadline so a certificate that
47
+ * flips during the last interval is not reported as a timeout.
48
+ */
49
+ export async function waitForCert(opts: CertWaitOpts): Promise<CertWaitResult> {
50
+ const now = opts.now ?? Date.now;
51
+ const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
52
+ const deadline = now() + opts.timeoutMs;
53
+ let lastDetail = "";
54
+ for (;;) {
55
+ const p = await opts.probe();
56
+ if (p.state === "ready") return { ok: true };
57
+ if (p.state === "error") return { ok: false, reason: "error", error: p.error };
58
+ if (p.detail !== lastDetail) {
59
+ lastDetail = p.detail;
60
+ opts.onUpdate?.(p.detail);
61
+ }
62
+ const remaining = deadline - now();
63
+ if (remaining <= 0) return { ok: false, reason: "timeout", lastDetail };
64
+ await sleep(Math.min(opts.intervalMs, remaining));
65
+ }
66
+ }
67
+
68
+ interface MappingCondition {
69
+ type?: string;
70
+ status?: string;
71
+ message?: string;
72
+ reason?: string;
73
+ }
74
+
75
+ /**
76
+ * The certificate state of the Cloud Run domain mapping for `hostname`.
77
+ * Ready once the mapping's `Ready` condition is True (Google sets it after
78
+ * `CertificateProvisioned` and `DomainRoutable`); until then the detail is
79
+ * the first non-True condition's message, which says what Google waits for
80
+ * (usually the DNS record).
81
+ */
82
+ export async function domainMappingProbe(
83
+ run: Run,
84
+ target: { readonly hostname: string; readonly region: string; readonly projectId: string },
85
+ ): Promise<CertProbe> {
86
+ const r = await run([
87
+ "beta",
88
+ "run",
89
+ "domain-mappings",
90
+ "describe",
91
+ `--domain=${target.hostname}`,
92
+ `--region=${target.region}`,
93
+ `--project=${target.projectId}`,
94
+ "--format=json",
95
+ ]);
96
+ if (!r.ok) return { state: "error", error: `describe the domain mapping: ${r.stderr.trim()}` };
97
+ const conditions =
98
+ (JSON.parse(r.stdout) as { status?: { conditions?: MappingCondition[] } }).status?.conditions ??
99
+ [];
100
+ const ready = conditions.find((c) => c.type === "Ready");
101
+ if (ready?.status === "True") return { state: "ready" };
102
+ const blocking =
103
+ conditions.find((c) => c.type === "CertificateProvisioned" && c.status !== "True") ??
104
+ conditions.find((c) => c.status !== "True");
105
+ const detail = blocking
106
+ ? `${blocking.type}: ${blocking.message || blocking.reason || blocking.status || "pending"}`
107
+ : "Google has not reported the mapping's status yet";
108
+ return { state: "pending", detail };
109
+ }
110
+
111
+ interface SslCertificateJson {
112
+ name?: string;
113
+ managed?: { status?: string; domainStatus?: Record<string, string> };
114
+ }
115
+
116
+ /**
117
+ * Whether the `gcp` load balancer has an ACTIVE managed certificate that
118
+ * covers `hostname`. Any such certificate counts: installs from before the
119
+ * admin got its own certificate serve the admin from the combined one.
120
+ * A certificate is only served once its overall status is ACTIVE, so a
121
+ * combined certificate whose other domain still fails does not count.
122
+ */
123
+ export async function lbCertProbe(
124
+ run: Run,
125
+ target: { readonly projectId: string; readonly hostname: string },
126
+ ): Promise<CertProbe> {
127
+ const r = await run([
128
+ "compute",
129
+ "ssl-certificates",
130
+ "list",
131
+ "--global",
132
+ `--project=${target.projectId}`,
133
+ "--format=json",
134
+ ]);
135
+ if (!r.ok) return { state: "error", error: `list SSL certificates: ${r.stderr.trim()}` };
136
+ const covering = (JSON.parse(r.stdout) as SslCertificateJson[]).filter(
137
+ (c) => c.managed?.domainStatus?.[target.hostname] !== undefined,
138
+ );
139
+ if (
140
+ covering.some(
141
+ (c) =>
142
+ c.managed?.status === "ACTIVE" && c.managed.domainStatus?.[target.hostname] === "ACTIVE",
143
+ )
144
+ ) {
145
+ return { state: "ready" };
146
+ }
147
+ if (covering.length === 0) {
148
+ return { state: "pending", detail: `no managed certificate lists ${target.hostname} yet` };
149
+ }
150
+ const detail = covering
151
+ .map(
152
+ (c) =>
153
+ `${c.name ?? "certificate"}: ${target.hostname} ${c.managed?.domainStatus?.[target.hostname]}`,
154
+ )
155
+ .join("; ");
156
+ return { state: "pending", detail };
157
+ }
158
+
159
+ /**
160
+ * How long the CLI waits for a certificate before handing the operator the
161
+ * status command. Google documents up to 60 min after DNS resolves.
162
+ */
163
+ export const CERT_WAIT_TIMEOUT_MS = 60 * 60 * 1000;