@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
@@ -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,393 @@
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 { IMAGE_DIGEST } from "@caelo-cms/shared";
17
+ import { confirm, isCancel, log, spinner } from "@clack/prompts";
18
+ import { green, red } from "kleur/colors";
19
+
20
+ /** The two Caelo services shipped as release images. */
21
+ export type ReleaseService = "admin" | "gateway";
22
+
23
+ /** The services every install runs, in roll order (admin first, then gateway). */
24
+ export const RELEASE_SERVICES: readonly ReleaseService[] = ["admin", "gateway"];
25
+
26
+ /** GHCR — the canonical registry (release-images.yml), used by self-hosted Compose. */
27
+ export const GHCR_REGISTRY = "ghcr.io";
28
+ export const GHCR_NAMESPACE = "caelo-cms";
29
+
30
+ /** `ghcr.io/caelo-cms/<service>` — the repository of a service's release image. */
31
+ export function ghcrRepository(service: ReleaseService): string {
32
+ return `${GHCR_REGISTRY}/${GHCR_NAMESPACE}/${service}`;
33
+ }
34
+
35
+ /** Where Caelo's release workflows live; every signer identity starts here. */
36
+ export const RELEASE_WORKFLOWS_URL = "https://github.com/caelo-cms/caelo-cms/.github/workflows";
37
+
38
+ /**
39
+ * The keyless signer cosign must see on every release image: one of the two
40
+ * release workflows of the caelo-cms repo, via GitHub's OIDC issuer. A
41
+ * different identity means a registry compromise, a typosquat, or a fork's
42
+ * image — every caller refuses to run it.
43
+ */
44
+ export const RELEASE_SIGNER_IDENTITY_REGEXP = `${RELEASE_WORKFLOWS_URL}/(release-images|release).yml@.*`;
45
+
46
+ /** OIDC issuer of every Caelo release signature — images and release manifest (GitHub Actions). */
47
+ export const RELEASE_OIDC_ISSUER = "https://token.actions.githubusercontent.com";
48
+
49
+ /**
50
+ * The `cosign verify` argv for one image reference pinned by digest.
51
+ *
52
+ * @example cosignVerifyArgv("ghcr.io/caelo-cms/admin@sha256:…")
53
+ */
54
+ export function cosignVerifyArgv(imageRef: string): string[] {
55
+ return [
56
+ "cosign",
57
+ "verify",
58
+ imageRef,
59
+ "--certificate-identity-regexp",
60
+ RELEASE_SIGNER_IDENTITY_REGEXP,
61
+ "--certificate-oidc-issuer",
62
+ RELEASE_OIDC_ISSUER,
63
+ ];
64
+ }
65
+
66
+ // A `sha256:<64 hex>` image / blob digest — one definition, shared with
67
+ // the release manifest schema.
68
+ export { IMAGE_DIGEST };
69
+
70
+ /** Whether `s` is a well-formed `sha256:<64 hex>` digest. */
71
+ export function isImageDigest(s: string): boolean {
72
+ return IMAGE_DIGEST.test(s);
73
+ }
74
+
75
+ const HEX_DIGITS = "0123456789abcdef";
76
+
77
+ /**
78
+ * `s` rebuilt character by character from a constant alphabet when it is a
79
+ * well-formed digest, else null. The digest comes from a registry's HTTP
80
+ * header and ends up in files (release.json, the Compose file); rebuilding
81
+ * it — rather than passing the checked string on — guarantees that nothing
82
+ * but `sha256:` and 64 hex digits from our own constants is ever written.
83
+ */
84
+ export function canonicalDigest(s: string): string | null {
85
+ if (!isImageDigest(s)) return null;
86
+ const hex = [...s.slice("sha256:".length)].map((c) => HEX_DIGITS[HEX_DIGITS.indexOf(c)]);
87
+ return `sha256:${hex.join("")}`;
88
+ }
89
+
90
+ const TAG_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-v";
91
+ const RELEASE_TAG = /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
92
+
93
+ /**
94
+ * A semver release tag (`v0.10.43`, `0.11.0-rc.1`) rebuilt from a constant
95
+ * alphabet, else null. The tag comes from the GitHub API and is recorded in
96
+ * release.json, so — like {@link canonicalDigest} — only our own characters
97
+ * reach the file.
98
+ */
99
+ export function canonicalReleaseTag(s: string): string | null {
100
+ if (s.length > 64 || !RELEASE_TAG.test(s)) return null;
101
+ return [...s].map((c) => TAG_CHARS[TAG_CHARS.indexOf(c)]).join("");
102
+ }
103
+
104
+ /**
105
+ * The registry tag for an upgrade request: an explicit version (a leading
106
+ * `v` dropped, as release tags are `vX.Y.Z` but image tags `X.Y.Z`), else
107
+ * the channel's moving tag, else `latest`.
108
+ */
109
+ export function releaseTag(opts: {
110
+ readonly version?: string;
111
+ readonly channel?: "stable" | "rc" | "beta";
112
+ }): string {
113
+ if (opts.version) return opts.version.startsWith("v") ? opts.version.slice(1) : opts.version;
114
+ if (opts.channel === "rc") return "rc";
115
+ if (opts.channel === "beta") return "beta";
116
+ return "latest";
117
+ }
118
+
119
+ export type DigestResult = { ok: true; digest: string } | { ok: false; reason: string };
120
+
121
+ /** Manifest media types: single-arch manifests and multi-arch indexes, Docker + OCI. */
122
+ const MANIFEST_ACCEPT = [
123
+ "application/vnd.docker.distribution.manifest.v2+json",
124
+ "application/vnd.oci.image.manifest.v1+json",
125
+ "application/vnd.docker.distribution.manifest.list.v2+json",
126
+ "application/vnd.oci.image.index.v1+json",
127
+ ].join(",");
128
+
129
+ /**
130
+ * Resolve `<registryHost>/<repository>:<tag>` to the digest a `docker pull`
131
+ * of that tag would fetch, through the Docker Registry V2 API anonymously.
132
+ *
133
+ * The images are public, but registries differ in how they say so: Artifact
134
+ * Registry answers the manifest HEAD directly, GHCR answers 401 with a
135
+ * `WWW-Authenticate: Bearer realm=…,service=…,scope=…` challenge whose realm
136
+ * hands out an anonymous pull token. Both paths are handled here, so callers
137
+ * never need registry credentials (end users have none for Caelo's).
138
+ *
139
+ * @param repository - path below the host, e.g. `caelo-cms/admin`.
140
+ */
141
+ export async function resolveRegistryDigest(
142
+ registryHost: string,
143
+ repository: string,
144
+ tag: string,
145
+ fetchImpl: typeof fetch = fetch,
146
+ ): Promise<DigestResult> {
147
+ const url = `https://${registryHost}/v2/${repository}/manifests/${tag}`;
148
+ try {
149
+ let res = await fetchImpl(url, { method: "HEAD", headers: { Accept: MANIFEST_ACCEPT } });
150
+ if (res.status === 401) {
151
+ const token = await anonymousPullToken(res.headers.get("www-authenticate"), fetchImpl);
152
+ if (!token.ok) return token;
153
+ res = await fetchImpl(url, {
154
+ method: "HEAD",
155
+ headers: { Accept: MANIFEST_ACCEPT, Authorization: `Bearer ${token.token}` },
156
+ });
157
+ }
158
+ if (!res.ok) return { ok: false, reason: `HTTP ${res.status} ${res.statusText}` };
159
+ const header = res.headers.get("docker-content-digest");
160
+ const digest = header ? canonicalDigest(header) : null;
161
+ if (!digest) {
162
+ return { ok: false, reason: `unexpected digest header: ${header ?? "(missing)"}` };
163
+ }
164
+ return { ok: true, digest };
165
+ } catch (e) {
166
+ return { ok: false, reason: e instanceof Error ? e.message : String(e) };
167
+ }
168
+ }
169
+
170
+ /** Follow a registry's Bearer challenge to an anonymous pull token. */
171
+ async function anonymousPullToken(
172
+ challenge: string | null,
173
+ fetchImpl: typeof fetch,
174
+ ): Promise<{ ok: true; token: string } | { ok: false; reason: string }> {
175
+ const params = parseBearerChallenge(challenge);
176
+ if (!params?.realm) {
177
+ return {
178
+ ok: false,
179
+ reason: `registry demanded auth without a Bearer challenge (${challenge})`,
180
+ };
181
+ }
182
+ const tokenUrl = new URL(params.realm);
183
+ if (params.service) tokenUrl.searchParams.set("service", params.service);
184
+ if (params.scope) tokenUrl.searchParams.set("scope", params.scope);
185
+ const res = await fetchImpl(tokenUrl.toString());
186
+ if (!res.ok) return { ok: false, reason: `token endpoint HTTP ${res.status} ${res.statusText}` };
187
+ const body = (await res.json()) as { token?: string; access_token?: string };
188
+ const token = body.token ?? body.access_token;
189
+ return token ? { ok: true, token } : { ok: false, reason: "token endpoint returned no token" };
190
+ }
191
+
192
+ /**
193
+ * The key/value pairs of a `Bearer k="v",k2="v2"` challenge, or null when
194
+ * it is not a Bearer challenge.
195
+ *
196
+ * @example parseBearerChallenge('Bearer realm="https://ghcr.io/token",service="ghcr.io"')
197
+ * // → { realm: "https://ghcr.io/token", service: "ghcr.io" }
198
+ */
199
+ export function parseBearerChallenge(header: string | null): Record<string, string> | null {
200
+ if (!header || !/^Bearer\s/i.test(header)) return null;
201
+ const out: Record<string, string> = {};
202
+ for (const m of header.slice(header.indexOf(" ") + 1).matchAll(/(\w+)="([^"]*)"/g)) {
203
+ const [, key, value] = m;
204
+ if (key && value !== undefined) out[key] = value;
205
+ }
206
+ return out;
207
+ }
208
+
209
+ /**
210
+ * The tag of the newest stable Caelo release on GitHub (e.g. `v0.10.43`),
211
+ * or an error reason. Used to turn "upgrade to latest" into an exact
212
+ * version before anything is resolved, so the version an install records is
213
+ * a real release, never the moving `latest` tag.
214
+ */
215
+ export async function fetchLatestReleaseTag(
216
+ fetchImpl: typeof fetch = fetch,
217
+ ): Promise<{ ok: true; tag: string } | { ok: false; reason: string }> {
218
+ try {
219
+ const res = await fetchImpl(
220
+ "https://api.github.com/repos/caelo-cms/caelo-cms/releases/latest",
221
+ { headers: { Accept: "application/vnd.github+json" } },
222
+ );
223
+ if (!res.ok) return { ok: false, reason: `GitHub API HTTP ${res.status} ${res.statusText}` };
224
+ const json = (await res.json()) as { tag_name?: string };
225
+ const tag = json.tag_name ? canonicalReleaseTag(json.tag_name) : null;
226
+ return tag
227
+ ? { ok: true, tag }
228
+ : { ok: false, reason: `GitHub returned no usable release tag (${json.tag_name})` };
229
+ } catch (e) {
230
+ return { ok: false, reason: e instanceof Error ? e.message : String(e) };
231
+ }
232
+ }
233
+
234
+ /** Where Caelo's release images live (public Artifact Registry repo). */
235
+ export const RELEASE_REGISTRY = {
236
+ region: "europe-west1",
237
+ project: "caelo-website",
238
+ repo: "caelo-cms-images",
239
+ } as const;
240
+
241
+ /** The digest-pinned reference of release image `slug@digest` in the Artifact Registry mirror. */
242
+ export function releaseImageRef(slug: string, digest: string): string {
243
+ const r = RELEASE_REGISTRY;
244
+ return `${r.region}-docker.pkg.dev/${r.project}/${r.repo}/${slug}@${digest}`;
245
+ }
246
+
247
+ /**
248
+ * The release version (`0.11.0`) whose image `slug` has `digest` in the
249
+ * public mirror, from its tags (`gcloud artifacts docker tags list`, which
250
+ * needs a signed-in gcloud). Null when no exact-version tag points at it
251
+ * (a `:main` dev image, a pruned tag) — the caller then records none.
252
+ */
253
+ export async function releaseVersionForDigest(
254
+ run: (args: string[]) => Promise<{ ok: boolean; stdout: string }>,
255
+ slug: string,
256
+ digest: string,
257
+ ): Promise<string | null> {
258
+ const r = RELEASE_REGISTRY;
259
+ const list = await run([
260
+ "artifacts",
261
+ "docker",
262
+ "tags",
263
+ "list",
264
+ `${r.region}-docker.pkg.dev/${r.project}/${r.repo}/${slug}`,
265
+ "--format=value(tag,version)",
266
+ ]);
267
+ if (!list.ok) return null;
268
+ for (const line of list.stdout.split("\n")) {
269
+ const [tag, version] = line.trim().split(/\s+/);
270
+ const name = tag?.split("/").pop() ?? "";
271
+ if (version === digest && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(name)) return name;
272
+ }
273
+ return null;
274
+ }
275
+
276
+ /** The digest-pinned caelo-ops image of release tag `tag`, or null when it has none. */
277
+ export async function resolveOpsImageRef(tag: string): Promise<string | null> {
278
+ const res = await resolveTagDigest("ops", tag);
279
+ return res.ok ? releaseImageRef("ops", res.digest) : null;
280
+ }
281
+
282
+ /**
283
+ * Resolve a tag (e.g. `0.2.6` or `latest`) to its digest in the public
284
+ * Artifact Registry mirror, anonymously through the Registry V2 API
285
+ * ({@link resolveRegistryDigest}) — the caller's gcloud session needs no IAM
286
+ * access to the registry's project.
287
+ */
288
+ export function resolveTagDigest(image: string, tag: string): Promise<DigestResult> {
289
+ const { region, project, repo } = RELEASE_REGISTRY;
290
+ return resolveRegistryDigest(`${region}-docker.pkg.dev`, `${project}/${repo}/${image}`, tag);
291
+ }
292
+
293
+ function probeBinary(argv: string[]): boolean {
294
+ try {
295
+ const r = Bun.spawnSync(argv, { stdout: "pipe", stderr: "pipe" });
296
+ return r.exitCode === 0;
297
+ } catch {
298
+ return false;
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Ensure cosign is on PATH. If not — and the operator is on macOS with
304
+ * brew available — offer to `brew install cosign` interactively. Falls
305
+ * back to printing the platform install URL + the --skip-verify
306
+ * escape hatch when auto-install isn't possible.
307
+ *
308
+ * Returns true if cosign is callable after this function returns.
309
+ */
310
+ async function ensureCosignAvailable(): Promise<boolean> {
311
+ if (probeBinary(["cosign", "version"])) return true;
312
+
313
+ log.error(red("cosign not found on PATH — required for image-signature verification."));
314
+
315
+ // macOS auto-install offer. Only when `brew` is itself on PATH —
316
+ // otherwise the operator is on a stripped-down install where
317
+ // homebrew was never set up, and the manual instructions are more
318
+ // useful than a failing brew shell-out.
319
+ if (process.platform === "darwin" && probeBinary(["brew", "--version"])) {
320
+ const proceed = await confirm({
321
+ message: "Install cosign via brew now? (one-shot: brew install cosign)",
322
+ initialValue: true,
323
+ });
324
+ if (!isCancel(proceed) && proceed === true) {
325
+ const s = spinner();
326
+ s.start("brew install cosign");
327
+ const installResult = Bun.spawnSync(["brew", "install", "cosign"], {
328
+ stdout: "pipe",
329
+ stderr: "pipe",
330
+ });
331
+ if (installResult.exitCode === 0 && probeBinary(["cosign", "version"])) {
332
+ s.stop(green("cosign installed ✓"));
333
+ return true;
334
+ }
335
+ const stderr = new TextDecoder().decode(installResult.stderr);
336
+ s.stop(red("brew install cosign failed"));
337
+ log.error(stderr.trim().slice(0, 500));
338
+ }
339
+ }
340
+
341
+ log.warn(
342
+ "Install cosign:\n" +
343
+ " • brew install cosign (macOS)\n" +
344
+ " • https://docs.sigstore.dev/cosign/installation/ (other)\n" +
345
+ "Or pass --skip-verify to roll without signature checks (NOT recommended for production).",
346
+ );
347
+ return false;
348
+ }
349
+
350
+ /**
351
+ * P21 ship 4 — verify cosign keyless signatures on every planned
352
+ * image digest. Sigstore Fulcio + Rekor; the certificate identity
353
+ * must match the Caelo release-images workflow. A mismatch means
354
+ * either a registry compromise, a typosquat, or the operator pointed
355
+ * at a fork's image. Either way: refuse to roll.
356
+ *
357
+ * Returns true on success (or skips with a clear error and returns
358
+ * false if cosign isn't installed / verification fails).
359
+ */
360
+ export async function verifyCosignAll(
361
+ plans: readonly { readonly slug: string; readonly digest: string }[],
362
+ ): Promise<boolean> {
363
+ // Probe cosign. Bun.spawnSync THROWS ENOENT when the binary is
364
+ // missing (instead of returning a non-zero exit), so the probe
365
+ // needs try/catch — relying on `exitCode !== 0` would surface a
366
+ // confusing stack trace to the operator instead of the actionable
367
+ // "install cosign" hint.
368
+ if (!(await ensureCosignAvailable())) return false;
369
+
370
+ for (const plan of plans) {
371
+ const s = spinner();
372
+ s.start(`Verifying cosign signature for ${plan.slug}@${plan.digest.slice(0, 19)}...`);
373
+ const fullRef = releaseImageRef(plan.slug, plan.digest);
374
+ const verify = Bun.spawnSync(cosignVerifyArgv(fullRef), { stdout: "pipe", stderr: "pipe" });
375
+ if (verify.exitCode !== 0) {
376
+ const stderr = new TextDecoder().decode(verify.stderr);
377
+ s.stop(red(`cosign verify FAILED for ${plan.slug}`));
378
+ log.error(
379
+ red(
380
+ `${plan.slug}@${plan.digest.slice(0, 19)} signature does NOT match the Caelo release workflow identity.\n` +
381
+ "Either:\n" +
382
+ " • the registry was compromised, OR\n" +
383
+ " • you're targeting a fork's image (verify your install's registry path), OR\n" +
384
+ " • cosign / sigstore had a transient outage (retry).\n" +
385
+ `cosign stderr: ${stderr.trim().slice(0, 500)}`,
386
+ ),
387
+ );
388
+ return false;
389
+ }
390
+ s.stop(green(`${plan.slug} signature verified ✓`));
391
+ }
392
+ return true;
393
+ }
@@ -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
+ }