@caelo-cms/provisioning 0.10.46 → 0.10.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (342) hide show
  1. package/dist/adc-impersonation.d.ts.map +1 -1
  2. package/dist/adc-impersonation.js +23 -1
  3. package/dist/adc-impersonation.js.map +1 -1
  4. package/dist/agent/apply.d.ts +45 -0
  5. package/dist/agent/apply.d.ts.map +1 -0
  6. package/dist/agent/apply.js +83 -0
  7. package/dist/agent/apply.js.map +1 -0
  8. package/dist/agent/cli-provenance.d.ts +42 -0
  9. package/dist/agent/cli-provenance.d.ts.map +1 -0
  10. package/dist/agent/cli-provenance.js +138 -0
  11. package/dist/agent/cli-provenance.js.map +1 -0
  12. package/dist/agent/command.d.ts +79 -0
  13. package/dist/agent/command.d.ts.map +1 -0
  14. package/dist/agent/command.js +454 -0
  15. package/dist/agent/command.js.map +1 -0
  16. package/dist/agent/config.d.ts +30 -0
  17. package/dist/agent/config.d.ts.map +1 -0
  18. package/dist/agent/config.js +60 -0
  19. package/dist/agent/config.js.map +1 -0
  20. package/dist/agent/lock.d.ts +13 -0
  21. package/dist/agent/lock.d.ts.map +1 -0
  22. package/dist/agent/lock.js +100 -0
  23. package/dist/agent/lock.js.map +1 -0
  24. package/dist/agent/queue.d.ts +35 -0
  25. package/dist/agent/queue.d.ts.map +1 -0
  26. package/dist/agent/queue.js +50 -0
  27. package/dist/agent/queue.js.map +1 -0
  28. package/dist/agent/run.d.ts +97 -0
  29. package/dist/agent/run.d.ts.map +1 -0
  30. package/dist/agent/run.js +198 -0
  31. package/dist/agent/run.js.map +1 -0
  32. package/dist/agent/systemd.d.ts +55 -0
  33. package/dist/agent/systemd.d.ts.map +1 -0
  34. package/dist/agent/systemd.js +171 -0
  35. package/dist/agent/systemd.js.map +1 -0
  36. package/dist/agent/units.d.ts +44 -0
  37. package/dist/agent/units.d.ts.map +1 -0
  38. package/dist/agent/units.js +127 -0
  39. package/dist/agent/units.js.map +1 -0
  40. package/dist/agent-db.js +18362 -0
  41. package/dist/cli.js +122 -16
  42. package/dist/cli.js.map +1 -1
  43. package/dist/cloudsql-iam-user.d.ts +39 -0
  44. package/dist/cloudsql-iam-user.d.ts.map +1 -0
  45. package/dist/cloudsql-iam-user.js +119 -0
  46. package/dist/cloudsql-iam-user.js.map +1 -0
  47. package/dist/compose.d.ts +16 -0
  48. package/dist/compose.d.ts.map +1 -1
  49. package/dist/compose.js +26 -1
  50. package/dist/compose.js.map +1 -1
  51. package/dist/dns/index.d.ts +2 -0
  52. package/dist/dns/index.d.ts.map +1 -1
  53. package/dist/dns/index.js +3 -2
  54. package/dist/dns/index.js.map +1 -1
  55. package/dist/dns/manual.d.ts +5 -0
  56. package/dist/dns/manual.d.ts.map +1 -1
  57. package/dist/dns/manual.js +28 -12
  58. package/dist/dns/manual.js.map +1 -1
  59. package/dist/gcloud.d.ts +2 -0
  60. package/dist/gcloud.d.ts.map +1 -1
  61. package/dist/gcloud.js +1 -1
  62. package/dist/gcloud.js.map +1 -1
  63. package/dist/gcp-names.d.ts +15 -0
  64. package/dist/gcp-names.d.ts.map +1 -1
  65. package/dist/gcp-names.js +23 -0
  66. package/dist/gcp-names.js.map +1 -1
  67. package/dist/index.d.ts +6 -0
  68. package/dist/index.d.ts.map +1 -1
  69. package/dist/index.js +4 -0
  70. package/dist/index.js.map +1 -1
  71. package/dist/infra-contract.d.ts +1 -1
  72. package/dist/infra-contract.js +7 -1
  73. package/dist/infra-contract.js.map +1 -1
  74. package/dist/install-job-runner.d.ts +13 -0
  75. package/dist/install-job-runner.d.ts.map +1 -0
  76. package/dist/install-job-runner.js +155 -0
  77. package/dist/install-job-runner.js.map +1 -0
  78. package/dist/install-job.d.ts +210 -0
  79. package/dist/install-job.d.ts.map +1 -0
  80. package/dist/install-job.js +449 -0
  81. package/dist/install-job.js.map +1 -0
  82. package/dist/install-state.d.ts +19 -1
  83. package/dist/install-state.d.ts.map +1 -1
  84. package/dist/install-state.js +31 -3
  85. package/dist/install-state.js.map +1 -1
  86. package/dist/lifecycle.d.ts.map +1 -1
  87. package/dist/lifecycle.js +24 -5
  88. package/dist/lifecycle.js.map +1 -1
  89. package/dist/migration-runner.d.ts +5 -0
  90. package/dist/migration-runner.d.ts.map +1 -1
  91. package/dist/migration-runner.js +1 -1
  92. package/dist/migration-runner.js.map +1 -1
  93. package/dist/operator-access.d.ts +14 -8
  94. package/dist/operator-access.d.ts.map +1 -1
  95. package/dist/operator-access.js +16 -102
  96. package/dist/operator-access.js.map +1 -1
  97. package/dist/run-mode.d.ts +27 -0
  98. package/dist/run-mode.d.ts.map +1 -0
  99. package/dist/run-mode.js +22 -0
  100. package/dist/run-mode.js.map +1 -0
  101. package/dist/runtime-secrets.d.ts +19 -0
  102. package/dist/runtime-secrets.d.ts.map +1 -1
  103. package/dist/runtime-secrets.js +44 -0
  104. package/dist/runtime-secrets.js.map +1 -1
  105. package/dist/self-hosted-roles.d.ts +22 -1
  106. package/dist/self-hosted-roles.d.ts.map +1 -1
  107. package/dist/self-hosted-roles.js +81 -12
  108. package/dist/self-hosted-roles.js.map +1 -1
  109. package/dist/self-hosted-upgrade.d.ts +17 -0
  110. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  111. package/dist/self-hosted-upgrade.js +16 -1
  112. package/dist/self-hosted-upgrade.js.map +1 -1
  113. package/dist/stack-contract.d.ts +6 -0
  114. package/dist/stack-contract.d.ts.map +1 -1
  115. package/dist/stack-contract.js +10 -1
  116. package/dist/stack-contract.js.map +1 -1
  117. package/dist/state-attach.d.ts +23 -0
  118. package/dist/state-attach.d.ts.map +1 -0
  119. package/dist/state-attach.js +112 -0
  120. package/dist/state-attach.js.map +1 -0
  121. package/dist/state-backend.d.ts +11 -0
  122. package/dist/state-backend.d.ts.map +1 -1
  123. package/dist/state-backend.js +20 -0
  124. package/dist/state-backend.js.map +1 -1
  125. package/dist/state-command.d.ts +20 -0
  126. package/dist/state-command.d.ts.map +1 -0
  127. package/dist/state-command.js +237 -0
  128. package/dist/state-command.js.map +1 -0
  129. package/dist/state-migrate.d.ts +121 -0
  130. package/dist/state-migrate.d.ts.map +1 -0
  131. package/dist/state-migrate.js +290 -0
  132. package/dist/state-migrate.js.map +1 -0
  133. package/dist/state-pulumi.d.ts +17 -0
  134. package/dist/state-pulumi.d.ts.map +1 -0
  135. package/dist/state-pulumi.js +218 -0
  136. package/dist/state-pulumi.js.map +1 -0
  137. package/dist/state-store.d.ts +27 -0
  138. package/dist/state-store.d.ts.map +1 -1
  139. package/dist/state-store.js +65 -0
  140. package/dist/state-store.js.map +1 -1
  141. package/dist/upgrade/converge.d.ts +6 -0
  142. package/dist/upgrade/converge.d.ts.map +1 -1
  143. package/dist/upgrade/converge.js +59 -2
  144. package/dist/upgrade/converge.js.map +1 -1
  145. package/dist/upgrade/gcp-lookup.d.ts +6 -0
  146. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  147. package/dist/upgrade/gcp-lookup.js +25 -0
  148. package/dist/upgrade/gcp-lookup.js.map +1 -1
  149. package/dist/upgrade/release-images.d.ts +12 -0
  150. package/dist/upgrade/release-images.d.ts.map +1 -1
  151. package/dist/upgrade/release-images.js +31 -0
  152. package/dist/upgrade/release-images.js.map +1 -1
  153. package/dist/upgrade/upgrade.d.ts +5 -0
  154. package/dist/upgrade/upgrade.d.ts.map +1 -1
  155. package/dist/upgrade/upgrade.js +28 -1
  156. package/dist/upgrade/upgrade.js.map +1 -1
  157. package/dist/upgrader-assess.d.ts +82 -0
  158. package/dist/upgrader-assess.d.ts.map +1 -0
  159. package/dist/upgrader-assess.js +116 -0
  160. package/dist/upgrader-assess.js.map +1 -0
  161. package/dist/upgrader-gcp.d.ts +17 -0
  162. package/dist/upgrader-gcp.d.ts.map +1 -0
  163. package/dist/upgrader-gcp.js +87 -0
  164. package/dist/upgrader-gcp.js.map +1 -0
  165. package/dist/upgrader-job.d.ts +131 -0
  166. package/dist/upgrader-job.d.ts.map +1 -0
  167. package/dist/upgrader-job.js +259 -0
  168. package/dist/upgrader-job.js.map +1 -0
  169. package/dist/upgrader-reporter.d.ts +35 -0
  170. package/dist/upgrader-reporter.d.ts.map +1 -0
  171. package/dist/upgrader-reporter.js +79 -0
  172. package/dist/upgrader-reporter.js.map +1 -0
  173. package/dist/upgrader-setup.d.ts +59 -0
  174. package/dist/upgrader-setup.d.ts.map +1 -0
  175. package/dist/upgrader-setup.js +308 -0
  176. package/dist/upgrader-setup.js.map +1 -0
  177. package/dist/wizard.d.ts +6 -0
  178. package/dist/wizard.d.ts.map +1 -1
  179. package/dist/wizard.js +32 -4
  180. package/dist/wizard.js.map +1 -1
  181. package/dist/wizards/gcp-firebase.d.ts +3 -0
  182. package/dist/wizards/gcp-firebase.d.ts.map +1 -1
  183. package/dist/wizards/gcp-firebase.js +1 -0
  184. package/dist/wizards/gcp-firebase.js.map +1 -1
  185. package/dist/wizards/gcp-install-job.d.ts +74 -0
  186. package/dist/wizards/gcp-install-job.d.ts.map +1 -0
  187. package/dist/wizards/gcp-install-job.js +398 -0
  188. package/dist/wizards/gcp-install-job.js.map +1 -0
  189. package/dist/wizards/gcp-prompts.d.ts +3 -0
  190. package/dist/wizards/gcp-prompts.d.ts.map +1 -0
  191. package/dist/wizards/gcp-prompts.js +25 -0
  192. package/dist/wizards/gcp-prompts.js.map +1 -0
  193. package/dist/wizards/gcp-pulumi.d.ts +9 -2
  194. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  195. package/dist/wizards/gcp-pulumi.js +8 -6
  196. package/dist/wizards/gcp-pulumi.js.map +1 -1
  197. package/dist/wizards/gcp.d.ts +40 -0
  198. package/dist/wizards/gcp.d.ts.map +1 -1
  199. package/dist/wizards/gcp.js +291 -83
  200. package/dist/wizards/gcp.js.map +1 -1
  201. package/package.json +6 -4
  202. package/src/adapter.ts +97 -0
  203. package/src/adc-impersonation.ts +176 -0
  204. package/src/admin-domain.test.ts +254 -0
  205. package/src/admin-domain.ts +245 -0
  206. package/src/admin-memory.test.ts +151 -0
  207. package/src/agent/apply.test.ts +185 -0
  208. package/src/agent/apply.ts +124 -0
  209. package/src/agent/cli-provenance.test.ts +159 -0
  210. package/src/agent/cli-provenance.ts +191 -0
  211. package/src/agent/command.test.ts +77 -0
  212. package/src/agent/command.ts +564 -0
  213. package/src/agent/config.ts +86 -0
  214. package/src/agent/lock-config.test.ts +97 -0
  215. package/src/agent/lock.ts +110 -0
  216. package/src/agent/queue.test.ts +10 -0
  217. package/src/agent/queue.ts +91 -0
  218. package/src/agent/run.test.ts +382 -0
  219. package/src/agent/run.ts +295 -0
  220. package/src/agent/systemd.ts +209 -0
  221. package/src/agent/units.test.ts +223 -0
  222. package/src/agent/units.ts +163 -0
  223. package/src/azure-front-door.test.ts +119 -0
  224. package/src/azure-front-door.ts +56 -0
  225. package/src/bootstrap-token.ts +20 -0
  226. package/src/caddy.ts +112 -0
  227. package/src/cdn-copy.ts +91 -0
  228. package/src/cert-wait.test.ts +194 -0
  229. package/src/cert-wait.ts +163 -0
  230. package/src/checkpoint-secrets.test.ts +380 -0
  231. package/src/checkpoint-secrets.ts +283 -0
  232. package/src/cli-routing.test.ts +45 -0
  233. package/src/cli-routing.ts +43 -0
  234. package/src/cli.ts +1491 -0
  235. package/src/cloudsql-iam-user.ts +147 -0
  236. package/src/compose.ts +334 -0
  237. package/src/default-firewall.test.ts +411 -0
  238. package/src/default-firewall.ts +342 -0
  239. package/src/deployed-release.test.ts +231 -0
  240. package/src/deployed-release.ts +220 -0
  241. package/src/dns/cloudflare.ts +182 -0
  242. package/src/dns/groups.test.ts +76 -0
  243. package/src/dns/groups.ts +86 -0
  244. package/src/dns/index.ts +56 -0
  245. package/src/dns/manual.ts +130 -0
  246. package/src/dns/types.ts +27 -0
  247. package/src/gateway-credentials.test.ts +180 -0
  248. package/src/gateway-credentials.ts +144 -0
  249. package/src/gcloud-components.test.ts +76 -0
  250. package/src/gcloud-components.ts +85 -0
  251. package/src/gcloud-retry.ts +41 -0
  252. package/src/gcloud-runner.test.ts +61 -0
  253. package/src/gcloud.test.ts +99 -0
  254. package/src/gcloud.ts +322 -0
  255. package/src/gcp-names.ts +135 -0
  256. package/src/index.test.ts +332 -0
  257. package/src/index.ts +114 -0
  258. package/src/infra-contract.ts +32 -0
  259. package/src/install-job-runner.ts +166 -0
  260. package/src/install-job.test.ts +566 -0
  261. package/src/install-job.ts +542 -0
  262. package/src/install-state.test.ts +239 -0
  263. package/src/install-state.ts +583 -0
  264. package/src/internal-token.ts +35 -0
  265. package/src/lifecycle.ts +900 -0
  266. package/src/mcp-iap-stacks.test.ts +58 -0
  267. package/src/mcp-iap.test.ts +149 -0
  268. package/src/mcp-iap.ts +131 -0
  269. package/src/media-volume.test.ts +296 -0
  270. package/src/migration-runner.ts +365 -0
  271. package/src/operator-access-stacks.test.ts +55 -0
  272. package/src/operator-access.test.ts +332 -0
  273. package/src/operator-access.ts +554 -0
  274. package/src/ops-backend.test.ts +320 -0
  275. package/src/ops-backend.ts +414 -0
  276. package/src/provisioner-identity.test.ts +482 -0
  277. package/src/provisioner-identity.ts +407 -0
  278. package/src/redirects-emit.ts +175 -0
  279. package/src/region-data.ts +428 -0
  280. package/src/regions.test.ts +277 -0
  281. package/src/regions.ts +370 -0
  282. package/src/release-contract.ts +57 -0
  283. package/src/release-manifest.test.ts +293 -0
  284. package/src/release-manifest.ts +430 -0
  285. package/src/run-mode.ts +40 -0
  286. package/src/runtime-secrets.test.ts +627 -0
  287. package/src/runtime-secrets.ts +754 -0
  288. package/src/self-command.test.ts +14 -0
  289. package/src/self-command.ts +21 -0
  290. package/src/self-hosted-compose.test.ts +177 -0
  291. package/src/self-hosted-release.ts +240 -0
  292. package/src/self-hosted-roles.integration.test.ts +161 -0
  293. package/src/self-hosted-roles.test.ts +124 -0
  294. package/src/self-hosted-roles.ts +203 -0
  295. package/src/self-hosted-upgrade.test.ts +394 -0
  296. package/src/self-hosted-upgrade.ts +548 -0
  297. package/src/stack-contract-parity.test.ts +199 -0
  298. package/src/stack-contract.test.ts +391 -0
  299. package/src/stack-contract.ts +888 -0
  300. package/src/stack-converge-secrets.test.ts +421 -0
  301. package/src/stack-converge.test.ts +833 -0
  302. package/src/stack-converge.ts +909 -0
  303. package/src/staging-admin.test.ts +246 -0
  304. package/src/staging-admin.ts +324 -0
  305. package/src/state-attach.test.ts +211 -0
  306. package/src/state-attach.ts +132 -0
  307. package/src/state-backend.test.ts +145 -0
  308. package/src/state-backend.ts +262 -0
  309. package/src/state-command.ts +293 -0
  310. package/src/state-migrate.test.ts +479 -0
  311. package/src/state-migrate.ts +434 -0
  312. package/src/state-pulumi.ts +289 -0
  313. package/src/state-store.test.ts +212 -0
  314. package/src/state-store.ts +243 -0
  315. package/src/upgrade/app-roll.ts +486 -0
  316. package/src/upgrade/backup.ts +109 -0
  317. package/src/upgrade/converge.ts +437 -0
  318. package/src/upgrade/gcp-lookup.ts +167 -0
  319. package/src/upgrade/health.test.ts +279 -0
  320. package/src/upgrade/health.ts +305 -0
  321. package/src/upgrade/release-images.test.ts +119 -0
  322. package/src/upgrade/release-images.ts +391 -0
  323. package/src/upgrade/reporter.ts +46 -0
  324. package/src/upgrade/upgrade.test.ts +626 -0
  325. package/src/upgrade/upgrade.ts +249 -0
  326. package/src/upgrader-assess.test.ts +265 -0
  327. package/src/upgrader-assess.ts +233 -0
  328. package/src/upgrader-gcp.ts +123 -0
  329. package/src/upgrader-job.test.ts +408 -0
  330. package/src/upgrader-job.ts +499 -0
  331. package/src/upgrader-reporter.test.ts +46 -0
  332. package/src/upgrader-reporter.ts +105 -0
  333. package/src/upgrader-setup.test.ts +169 -0
  334. package/src/upgrader-setup.ts +355 -0
  335. package/src/wizard.ts +425 -0
  336. package/src/wizards/gcp-cost.ts +188 -0
  337. package/src/wizards/gcp-firebase.ts +72 -0
  338. package/src/wizards/gcp-install-job.ts +506 -0
  339. package/src/wizards/gcp-prompts.ts +26 -0
  340. package/src/wizards/gcp-pulumi.test.ts +246 -0
  341. package/src/wizards/gcp-pulumi.ts +516 -0
  342. package/src/wizards/gcp.ts +1935 -0
@@ -0,0 +1,119 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import {
5
+ canonicalDigest,
6
+ canonicalReleaseTag,
7
+ cosignVerifyArgv,
8
+ parseBearerChallenge,
9
+ RELEASE_SIGNER_IDENTITY_REGEXP,
10
+ releaseTag,
11
+ resolveRegistryDigest,
12
+ } from "./release-images.js";
13
+
14
+ const DIGEST = `sha256:${"d".repeat(64)}`;
15
+
16
+ /** A fetch fake that records requests and answers from `handler`. */
17
+ function fakeFetch(handler: (url: string, init?: RequestInit) => Response) {
18
+ const calls: { url: string; init?: RequestInit }[] = [];
19
+ const impl = (async (input: string | URL | Request, init?: RequestInit) => {
20
+ const url = String(input);
21
+ calls.push({ url, ...(init ? { init } : {}) });
22
+ return handler(url, init);
23
+ }) as typeof fetch;
24
+ return { impl, calls };
25
+ }
26
+
27
+ describe("resolveRegistryDigest", () => {
28
+ it("reads the digest straight from an anonymous registry (Artifact Registry)", async () => {
29
+ const f = fakeFetch(
30
+ () => new Response(null, { status: 200, headers: { "docker-content-digest": DIGEST } }),
31
+ );
32
+ const r = await resolveRegistryDigest(
33
+ "europe-west1-docker.pkg.dev",
34
+ "p/r/admin",
35
+ "1.2.3",
36
+ f.impl,
37
+ );
38
+ expect(r).toEqual({ ok: true, digest: DIGEST });
39
+ expect(f.calls[0]?.url).toBe(
40
+ "https://europe-west1-docker.pkg.dev/v2/p/r/admin/manifests/1.2.3",
41
+ );
42
+ });
43
+
44
+ it("follows GHCR's Bearer challenge to an anonymous pull token", async () => {
45
+ const f = fakeFetch((url, init) => {
46
+ if (url.startsWith("https://ghcr.io/token")) {
47
+ return Response.json({ token: "anon-token" });
48
+ }
49
+ const auth = new Headers(init?.headers).get("authorization");
50
+ if (auth !== "Bearer anon-token") {
51
+ return new Response(null, {
52
+ status: 401,
53
+ headers: {
54
+ "www-authenticate":
55
+ 'Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:caelo-cms/admin:pull"',
56
+ },
57
+ });
58
+ }
59
+ return new Response(null, { status: 200, headers: { "docker-content-digest": DIGEST } });
60
+ });
61
+ const r = await resolveRegistryDigest("ghcr.io", "caelo-cms/admin", "0.10.43", f.impl);
62
+ expect(r).toEqual({ ok: true, digest: DIGEST });
63
+ const tokenUrl = new URL(f.calls[1]?.url ?? "");
64
+ expect(tokenUrl.searchParams.get("scope")).toBe("repository:caelo-cms/admin:pull");
65
+ expect(tokenUrl.searchParams.get("service")).toBe("ghcr.io");
66
+ });
67
+
68
+ it("reports a missing tag and a malformed digest header", async () => {
69
+ const missing = fakeFetch(() => new Response(null, { status: 404, statusText: "Not Found" }));
70
+ expect(
71
+ await resolveRegistryDigest("ghcr.io", "caelo-cms/admin", "9.9.9", missing.impl),
72
+ ).toEqual({ ok: false, reason: "HTTP 404 Not Found" });
73
+ const bad = fakeFetch(
74
+ () => new Response(null, { status: 200, headers: { "docker-content-digest": "md5:x" } }),
75
+ );
76
+ const r = await resolveRegistryDigest("ghcr.io", "caelo-cms/admin", "1", bad.impl);
77
+ expect(r.ok).toBe(false);
78
+ });
79
+ });
80
+
81
+ describe("parseBearerChallenge", () => {
82
+ it("parses the parameters and rejects non-Bearer challenges", () => {
83
+ expect(parseBearerChallenge('Bearer realm="https://r/t",service="s"')).toEqual({
84
+ realm: "https://r/t",
85
+ service: "s",
86
+ });
87
+ expect(parseBearerChallenge('Basic realm="x"')).toBeNull();
88
+ expect(parseBearerChallenge(null)).toBeNull();
89
+ });
90
+ });
91
+
92
+ describe("releaseTag", () => {
93
+ it("strips a leading v and maps channels", () => {
94
+ expect(releaseTag({ version: "v0.10.43" })).toBe("0.10.43");
95
+ expect(releaseTag({ version: "0.10.43" })).toBe("0.10.43");
96
+ expect(releaseTag({ channel: "rc" })).toBe("rc");
97
+ expect(releaseTag({})).toBe("latest");
98
+ });
99
+ });
100
+
101
+ describe("cosignVerifyArgv", () => {
102
+ it("pins the Caelo release workflow identity and GitHub's OIDC issuer", () => {
103
+ const argv = cosignVerifyArgv("ghcr.io/caelo-cms/admin@x");
104
+ expect(argv.slice(0, 3)).toEqual(["cosign", "verify", "ghcr.io/caelo-cms/admin@x"]);
105
+ expect(argv).toContain(RELEASE_SIGNER_IDENTITY_REGEXP);
106
+ expect(argv).toContain("https://token.actions.githubusercontent.com");
107
+ });
108
+ });
109
+
110
+ describe("canonical values from the network", () => {
111
+ it("accepts well-formed digests and release tags only", () => {
112
+ expect(canonicalDigest(`sha256:${"e".repeat(64)}`)).toBe(`sha256:${"e".repeat(64)}`);
113
+ expect(canonicalDigest("sha256:../../etc")).toBeNull();
114
+ expect(canonicalReleaseTag("v0.10.43")).toBe("v0.10.43");
115
+ expect(canonicalReleaseTag("0.11.0-rc.1")).toBe("0.11.0-rc.1");
116
+ expect(canonicalReleaseTag("v1.0.0/../x")).toBeNull();
117
+ expect(canonicalReleaseTag("latest")).toBeNull();
118
+ });
119
+ });
@@ -0,0 +1,391 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Caelo release images — where they live, how a tag becomes a digest, and
5
+ * which signer identity cosign must see. The ONE module for this, shared by
6
+ * every install path that runs the prebuilt images (CLAUDE.md §11.C):
7
+ * - the GCP `upgrade` (Artifact Registry mirror, {@link RELEASE_REGISTRY});
8
+ * - the self-hosted Compose install + upgrade (GHCR, the canonical
9
+ * registry, {@link ghcrRepository}).
10
+ * Both registries carry the SAME digests: release-images.yml pushes to GHCR,
11
+ * signs, then `oras copy -r`s image + signature to the mirror. So one
12
+ * resolver and one cosign identity serve both. The in-cloud upgrader job
13
+ * (epic #696) takes the digests from the signed release manifest instead.
14
+ */
15
+
16
+ import { confirm, isCancel, log, spinner } from "@clack/prompts";
17
+ import { green, red } from "kleur/colors";
18
+
19
+ /** The two Caelo services shipped as release images. */
20
+ export type ReleaseService = "admin" | "gateway";
21
+
22
+ /** The services every install runs, in roll order (admin first, then gateway). */
23
+ export const RELEASE_SERVICES: readonly ReleaseService[] = ["admin", "gateway"];
24
+
25
+ /** GHCR — the canonical registry (release-images.yml), used by self-hosted Compose. */
26
+ export const GHCR_REGISTRY = "ghcr.io";
27
+ export const GHCR_NAMESPACE = "caelo-cms";
28
+
29
+ /** `ghcr.io/caelo-cms/<service>` — the repository of a service's release image. */
30
+ export function ghcrRepository(service: ReleaseService): string {
31
+ return `${GHCR_REGISTRY}/${GHCR_NAMESPACE}/${service}`;
32
+ }
33
+
34
+ /** Where Caelo's release workflows live; every signer identity starts here. */
35
+ export const RELEASE_WORKFLOWS_URL = "https://github.com/caelo-cms/caelo-cms/.github/workflows";
36
+
37
+ /**
38
+ * The keyless signer cosign must see on every release image: one of the two
39
+ * release workflows of the caelo-cms repo, via GitHub's OIDC issuer. A
40
+ * different identity means a registry compromise, a typosquat, or a fork's
41
+ * image — every caller refuses to run it.
42
+ */
43
+ export const RELEASE_SIGNER_IDENTITY_REGEXP = `${RELEASE_WORKFLOWS_URL}/(release-images|release).yml@.*`;
44
+
45
+ /** OIDC issuer of every Caelo release signature — images and release manifest (GitHub Actions). */
46
+ export const RELEASE_OIDC_ISSUER = "https://token.actions.githubusercontent.com";
47
+
48
+ /**
49
+ * The `cosign verify` argv for one image reference pinned by digest.
50
+ *
51
+ * @example cosignVerifyArgv("ghcr.io/caelo-cms/admin@sha256:…")
52
+ */
53
+ export function cosignVerifyArgv(imageRef: string): string[] {
54
+ return [
55
+ "cosign",
56
+ "verify",
57
+ imageRef,
58
+ "--certificate-identity-regexp",
59
+ RELEASE_SIGNER_IDENTITY_REGEXP,
60
+ "--certificate-oidc-issuer",
61
+ RELEASE_OIDC_ISSUER,
62
+ ];
63
+ }
64
+
65
+ /** A `sha256:<64 hex>` image / blob digest. */
66
+ export const IMAGE_DIGEST = /^sha256:[0-9a-f]{64}$/;
67
+
68
+ /** Whether `s` is a well-formed `sha256:<64 hex>` digest. */
69
+ export function isImageDigest(s: string): boolean {
70
+ return IMAGE_DIGEST.test(s);
71
+ }
72
+
73
+ const HEX_DIGITS = "0123456789abcdef";
74
+
75
+ /**
76
+ * `s` rebuilt character by character from a constant alphabet when it is a
77
+ * well-formed digest, else null. The digest comes from a registry's HTTP
78
+ * header and ends up in files (release.json, the Compose file); rebuilding
79
+ * it — rather than passing the checked string on — guarantees that nothing
80
+ * but `sha256:` and 64 hex digits from our own constants is ever written.
81
+ */
82
+ export function canonicalDigest(s: string): string | null {
83
+ if (!isImageDigest(s)) return null;
84
+ const hex = [...s.slice("sha256:".length)].map((c) => HEX_DIGITS[HEX_DIGITS.indexOf(c)]);
85
+ return `sha256:${hex.join("")}`;
86
+ }
87
+
88
+ const TAG_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-v";
89
+ const RELEASE_TAG = /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
90
+
91
+ /**
92
+ * A semver release tag (`v0.10.43`, `0.11.0-rc.1`) rebuilt from a constant
93
+ * alphabet, else null. The tag comes from the GitHub API and is recorded in
94
+ * release.json, so — like {@link canonicalDigest} — only our own characters
95
+ * reach the file.
96
+ */
97
+ export function canonicalReleaseTag(s: string): string | null {
98
+ if (s.length > 64 || !RELEASE_TAG.test(s)) return null;
99
+ return [...s].map((c) => TAG_CHARS[TAG_CHARS.indexOf(c)]).join("");
100
+ }
101
+
102
+ /**
103
+ * The registry tag for an upgrade request: an explicit version (a leading
104
+ * `v` dropped, as release tags are `vX.Y.Z` but image tags `X.Y.Z`), else
105
+ * the channel's moving tag, else `latest`.
106
+ */
107
+ export function releaseTag(opts: {
108
+ readonly version?: string;
109
+ readonly channel?: "stable" | "rc" | "beta";
110
+ }): string {
111
+ if (opts.version) return opts.version.startsWith("v") ? opts.version.slice(1) : opts.version;
112
+ if (opts.channel === "rc") return "rc";
113
+ if (opts.channel === "beta") return "beta";
114
+ return "latest";
115
+ }
116
+
117
+ export type DigestResult = { ok: true; digest: string } | { ok: false; reason: string };
118
+
119
+ /** Manifest media types: single-arch manifests and multi-arch indexes, Docker + OCI. */
120
+ const MANIFEST_ACCEPT = [
121
+ "application/vnd.docker.distribution.manifest.v2+json",
122
+ "application/vnd.oci.image.manifest.v1+json",
123
+ "application/vnd.docker.distribution.manifest.list.v2+json",
124
+ "application/vnd.oci.image.index.v1+json",
125
+ ].join(",");
126
+
127
+ /**
128
+ * Resolve `<registryHost>/<repository>:<tag>` to the digest a `docker pull`
129
+ * of that tag would fetch, through the Docker Registry V2 API anonymously.
130
+ *
131
+ * The images are public, but registries differ in how they say so: Artifact
132
+ * Registry answers the manifest HEAD directly, GHCR answers 401 with a
133
+ * `WWW-Authenticate: Bearer realm=…,service=…,scope=…` challenge whose realm
134
+ * hands out an anonymous pull token. Both paths are handled here, so callers
135
+ * never need registry credentials (end users have none for Caelo's).
136
+ *
137
+ * @param repository - path below the host, e.g. `caelo-cms/admin`.
138
+ */
139
+ export async function resolveRegistryDigest(
140
+ registryHost: string,
141
+ repository: string,
142
+ tag: string,
143
+ fetchImpl: typeof fetch = fetch,
144
+ ): Promise<DigestResult> {
145
+ const url = `https://${registryHost}/v2/${repository}/manifests/${tag}`;
146
+ try {
147
+ let res = await fetchImpl(url, { method: "HEAD", headers: { Accept: MANIFEST_ACCEPT } });
148
+ if (res.status === 401) {
149
+ const token = await anonymousPullToken(res.headers.get("www-authenticate"), fetchImpl);
150
+ if (!token.ok) return token;
151
+ res = await fetchImpl(url, {
152
+ method: "HEAD",
153
+ headers: { Accept: MANIFEST_ACCEPT, Authorization: `Bearer ${token.token}` },
154
+ });
155
+ }
156
+ if (!res.ok) return { ok: false, reason: `HTTP ${res.status} ${res.statusText}` };
157
+ const header = res.headers.get("docker-content-digest");
158
+ const digest = header ? canonicalDigest(header) : null;
159
+ if (!digest) {
160
+ return { ok: false, reason: `unexpected digest header: ${header ?? "(missing)"}` };
161
+ }
162
+ return { ok: true, digest };
163
+ } catch (e) {
164
+ return { ok: false, reason: e instanceof Error ? e.message : String(e) };
165
+ }
166
+ }
167
+
168
+ /** Follow a registry's Bearer challenge to an anonymous pull token. */
169
+ async function anonymousPullToken(
170
+ challenge: string | null,
171
+ fetchImpl: typeof fetch,
172
+ ): Promise<{ ok: true; token: string } | { ok: false; reason: string }> {
173
+ const params = parseBearerChallenge(challenge);
174
+ if (!params?.realm) {
175
+ return {
176
+ ok: false,
177
+ reason: `registry demanded auth without a Bearer challenge (${challenge})`,
178
+ };
179
+ }
180
+ const tokenUrl = new URL(params.realm);
181
+ if (params.service) tokenUrl.searchParams.set("service", params.service);
182
+ if (params.scope) tokenUrl.searchParams.set("scope", params.scope);
183
+ const res = await fetchImpl(tokenUrl.toString());
184
+ if (!res.ok) return { ok: false, reason: `token endpoint HTTP ${res.status} ${res.statusText}` };
185
+ const body = (await res.json()) as { token?: string; access_token?: string };
186
+ const token = body.token ?? body.access_token;
187
+ return token ? { ok: true, token } : { ok: false, reason: "token endpoint returned no token" };
188
+ }
189
+
190
+ /**
191
+ * The key/value pairs of a `Bearer k="v",k2="v2"` challenge, or null when
192
+ * it is not a Bearer challenge.
193
+ *
194
+ * @example parseBearerChallenge('Bearer realm="https://ghcr.io/token",service="ghcr.io"')
195
+ * // → { realm: "https://ghcr.io/token", service: "ghcr.io" }
196
+ */
197
+ export function parseBearerChallenge(header: string | null): Record<string, string> | null {
198
+ if (!header || !/^Bearer\s/i.test(header)) return null;
199
+ const out: Record<string, string> = {};
200
+ for (const m of header.slice(header.indexOf(" ") + 1).matchAll(/(\w+)="([^"]*)"/g)) {
201
+ const [, key, value] = m;
202
+ if (key && value !== undefined) out[key] = value;
203
+ }
204
+ return out;
205
+ }
206
+
207
+ /**
208
+ * The tag of the newest stable Caelo release on GitHub (e.g. `v0.10.43`),
209
+ * or an error reason. Used to turn "upgrade to latest" into an exact
210
+ * version before anything is resolved, so the version an install records is
211
+ * a real release, never the moving `latest` tag.
212
+ */
213
+ export async function fetchLatestReleaseTag(
214
+ fetchImpl: typeof fetch = fetch,
215
+ ): Promise<{ ok: true; tag: string } | { ok: false; reason: string }> {
216
+ try {
217
+ const res = await fetchImpl(
218
+ "https://api.github.com/repos/caelo-cms/caelo-cms/releases/latest",
219
+ { headers: { Accept: "application/vnd.github+json" } },
220
+ );
221
+ if (!res.ok) return { ok: false, reason: `GitHub API HTTP ${res.status} ${res.statusText}` };
222
+ const json = (await res.json()) as { tag_name?: string };
223
+ const tag = json.tag_name ? canonicalReleaseTag(json.tag_name) : null;
224
+ return tag
225
+ ? { ok: true, tag }
226
+ : { ok: false, reason: `GitHub returned no usable release tag (${json.tag_name})` };
227
+ } catch (e) {
228
+ return { ok: false, reason: e instanceof Error ? e.message : String(e) };
229
+ }
230
+ }
231
+
232
+ /** Where Caelo's release images live (public Artifact Registry repo). */
233
+ export const RELEASE_REGISTRY = {
234
+ region: "europe-west1",
235
+ project: "caelo-website",
236
+ repo: "caelo-cms-images",
237
+ } as const;
238
+
239
+ /** The digest-pinned reference of release image `slug@digest` in the Artifact Registry mirror. */
240
+ export function releaseImageRef(slug: string, digest: string): string {
241
+ const r = RELEASE_REGISTRY;
242
+ return `${r.region}-docker.pkg.dev/${r.project}/${r.repo}/${slug}@${digest}`;
243
+ }
244
+
245
+ /**
246
+ * The release version (`0.11.0`) whose image `slug` has `digest` in the
247
+ * public mirror, from its tags (`gcloud artifacts docker tags list`, which
248
+ * needs a signed-in gcloud). Null when no exact-version tag points at it
249
+ * (a `:main` dev image, a pruned tag) — the caller then records none.
250
+ */
251
+ export async function releaseVersionForDigest(
252
+ run: (args: string[]) => Promise<{ ok: boolean; stdout: string }>,
253
+ slug: string,
254
+ digest: string,
255
+ ): Promise<string | null> {
256
+ const r = RELEASE_REGISTRY;
257
+ const list = await run([
258
+ "artifacts",
259
+ "docker",
260
+ "tags",
261
+ "list",
262
+ `${r.region}-docker.pkg.dev/${r.project}/${r.repo}/${slug}`,
263
+ "--format=value(tag,version)",
264
+ ]);
265
+ if (!list.ok) return null;
266
+ for (const line of list.stdout.split("\n")) {
267
+ const [tag, version] = line.trim().split(/\s+/);
268
+ const name = tag?.split("/").pop() ?? "";
269
+ if (version === digest && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(name)) return name;
270
+ }
271
+ return null;
272
+ }
273
+
274
+ /** The digest-pinned caelo-ops image of release tag `tag`, or null when it has none. */
275
+ export async function resolveOpsImageRef(tag: string): Promise<string | null> {
276
+ const res = await resolveTagDigest("ops", tag);
277
+ return res.ok ? releaseImageRef("ops", res.digest) : null;
278
+ }
279
+
280
+ /**
281
+ * Resolve a tag (e.g. `0.2.6` or `latest`) to its digest in the public
282
+ * Artifact Registry mirror, anonymously through the Registry V2 API
283
+ * ({@link resolveRegistryDigest}) — the caller's gcloud session needs no IAM
284
+ * access to the registry's project.
285
+ */
286
+ export function resolveTagDigest(image: string, tag: string): Promise<DigestResult> {
287
+ const { region, project, repo } = RELEASE_REGISTRY;
288
+ return resolveRegistryDigest(`${region}-docker.pkg.dev`, `${project}/${repo}/${image}`, tag);
289
+ }
290
+
291
+ function probeBinary(argv: string[]): boolean {
292
+ try {
293
+ const r = Bun.spawnSync(argv, { stdout: "pipe", stderr: "pipe" });
294
+ return r.exitCode === 0;
295
+ } catch {
296
+ return false;
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Ensure cosign is on PATH. If not — and the operator is on macOS with
302
+ * brew available — offer to `brew install cosign` interactively. Falls
303
+ * back to printing the platform install URL + the --skip-verify
304
+ * escape hatch when auto-install isn't possible.
305
+ *
306
+ * Returns true if cosign is callable after this function returns.
307
+ */
308
+ async function ensureCosignAvailable(): Promise<boolean> {
309
+ if (probeBinary(["cosign", "version"])) return true;
310
+
311
+ log.error(red("cosign not found on PATH — required for image-signature verification."));
312
+
313
+ // macOS auto-install offer. Only when `brew` is itself on PATH —
314
+ // otherwise the operator is on a stripped-down install where
315
+ // homebrew was never set up, and the manual instructions are more
316
+ // useful than a failing brew shell-out.
317
+ if (process.platform === "darwin" && probeBinary(["brew", "--version"])) {
318
+ const proceed = await confirm({
319
+ message: "Install cosign via brew now? (one-shot: brew install cosign)",
320
+ initialValue: true,
321
+ });
322
+ if (!isCancel(proceed) && proceed === true) {
323
+ const s = spinner();
324
+ s.start("brew install cosign");
325
+ const installResult = Bun.spawnSync(["brew", "install", "cosign"], {
326
+ stdout: "pipe",
327
+ stderr: "pipe",
328
+ });
329
+ if (installResult.exitCode === 0 && probeBinary(["cosign", "version"])) {
330
+ s.stop(green("cosign installed ✓"));
331
+ return true;
332
+ }
333
+ const stderr = new TextDecoder().decode(installResult.stderr);
334
+ s.stop(red("brew install cosign failed"));
335
+ log.error(stderr.trim().slice(0, 500));
336
+ }
337
+ }
338
+
339
+ log.warn(
340
+ "Install cosign:\n" +
341
+ " • brew install cosign (macOS)\n" +
342
+ " • https://docs.sigstore.dev/cosign/installation/ (other)\n" +
343
+ "Or pass --skip-verify to roll without signature checks (NOT recommended for production).",
344
+ );
345
+ return false;
346
+ }
347
+
348
+ /**
349
+ * P21 ship 4 — verify cosign keyless signatures on every planned
350
+ * image digest. Sigstore Fulcio + Rekor; the certificate identity
351
+ * must match the Caelo release-images workflow. A mismatch means
352
+ * either a registry compromise, a typosquat, or the operator pointed
353
+ * at a fork's image. Either way: refuse to roll.
354
+ *
355
+ * Returns true on success (or skips with a clear error and returns
356
+ * false if cosign isn't installed / verification fails).
357
+ */
358
+ export async function verifyCosignAll(
359
+ plans: readonly { readonly slug: string; readonly digest: string }[],
360
+ ): Promise<boolean> {
361
+ // Probe cosign. Bun.spawnSync THROWS ENOENT when the binary is
362
+ // missing (instead of returning a non-zero exit), so the probe
363
+ // needs try/catch — relying on `exitCode !== 0` would surface a
364
+ // confusing stack trace to the operator instead of the actionable
365
+ // "install cosign" hint.
366
+ if (!(await ensureCosignAvailable())) return false;
367
+
368
+ for (const plan of plans) {
369
+ const s = spinner();
370
+ s.start(`Verifying cosign signature for ${plan.slug}@${plan.digest.slice(0, 19)}...`);
371
+ const fullRef = releaseImageRef(plan.slug, plan.digest);
372
+ const verify = Bun.spawnSync(cosignVerifyArgv(fullRef), { stdout: "pipe", stderr: "pipe" });
373
+ if (verify.exitCode !== 0) {
374
+ const stderr = new TextDecoder().decode(verify.stderr);
375
+ s.stop(red(`cosign verify FAILED for ${plan.slug}`));
376
+ log.error(
377
+ red(
378
+ `${plan.slug}@${plan.digest.slice(0, 19)} signature does NOT match the Caelo release workflow identity.\n` +
379
+ "Either:\n" +
380
+ " • the registry was compromised, OR\n" +
381
+ " • you're targeting a fork's image (verify your install's registry path), OR\n" +
382
+ " • cosign / sigstore had a transient outage (retry).\n" +
383
+ `cosign stderr: ${stderr.trim().slice(0, 500)}`,
384
+ ),
385
+ );
386
+ return false;
387
+ }
388
+ s.stop(green(`${plan.slug} signature verified ✓`));
389
+ }
390
+ return true;
391
+ }
@@ -0,0 +1,46 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * How the upgrade phases (converge.ts, app-roll.ts) tell the operator what
5
+ * they do. The CLI renders it with clack spinners; the in-cloud upgrader job
6
+ * (epic #696) will write the same lines to its run log, and the tests record
7
+ * them. The phases never import a terminal UI themselves.
8
+ */
9
+
10
+ import { log, spinner } from "@clack/prompts";
11
+ import { green, red, yellow } from "kleur/colors";
12
+
13
+ /** One long-running step: started once, ended exactly once. */
14
+ export interface ReportStep {
15
+ done(message: string): void;
16
+ failed(message: string): void;
17
+ /** Ended without success, but the upgrade continues. */
18
+ warned(message: string): void;
19
+ }
20
+
21
+ export interface UpgradeReporter {
22
+ step(label: string): ReportStep;
23
+ info(message: string): void;
24
+ warn(message: string): void;
25
+ error(message: string): void;
26
+ success(message: string): void;
27
+ }
28
+
29
+ /** The CLI's reporter: a clack spinner per step, colored log lines. */
30
+ export function clackReporter(): UpgradeReporter {
31
+ return {
32
+ step(label) {
33
+ const s = spinner();
34
+ s.start(label);
35
+ return {
36
+ done: (m) => s.stop(green(m)),
37
+ failed: (m) => s.stop(red(m)),
38
+ warned: (m) => s.stop(yellow(m)),
39
+ };
40
+ },
41
+ info: (m) => log.info(m),
42
+ warn: (m) => log.warn(yellow(m)),
43
+ error: (m) => log.error(red(m)),
44
+ success: (m) => log.success(m),
45
+ };
46
+ }