@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,430 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The signed release manifest (epic #696): one JSON document per release
5
+ * that names the exact image digests plus the facts an upgrader needs to
6
+ * decide whether it may install the release on its own.
7
+ *
8
+ * Trust model. `release.yml` generates the manifest on the release tag,
9
+ * signs it with `cosign sign-blob` (keyless, the workflow's GitHub OIDC
10
+ * identity) and pushes it as an OCI artifact holding two layers — the
11
+ * manifest and its Sigstore bundle. Verification pins the signer to
12
+ * `release.yml` running on the tag `v<version>` of caelo-cms/caelo-cms,
13
+ * so neither a compromised admin nor a registry mirror can mint, alter or
14
+ * replay a manifest: changing a digest, flipping `security`, or serving
15
+ * v0.9's manifest as v0.10's all fail the signature or the version check.
16
+ * The signature rides inside the artifact (not as an OCI referrer), so
17
+ * any plain registry copy keeps it — the failure mode that left mirrored
18
+ * images unsigned (see release-images.yml, "oras copy -r") cannot apply.
19
+ *
20
+ * Errors are values: every failure carries a code plus a message that
21
+ * says what to check next.
22
+ */
23
+
24
+ import { createHash } from "node:crypto";
25
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
26
+ import { tmpdir } from "node:os";
27
+ import { join } from "node:path";
28
+ import { z } from "zod";
29
+ import {
30
+ IMAGE_DIGEST,
31
+ RELEASE_OIDC_ISSUER,
32
+ RELEASE_WORKFLOWS_URL,
33
+ } from "./upgrade/release-images.js";
34
+
35
+ /** Release channels. Pre-release versions name theirs (`0.11.0-rc.1` → `rc`). */
36
+ export const RELEASE_CHANNELS = ["stable", "rc", "beta", "alpha"] as const;
37
+ export type ReleaseChannel = (typeof RELEASE_CHANNELS)[number];
38
+
39
+ const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
40
+ const GHSA_ID = /^GHSA(-[23456789cfghjmpqrvwx]{4}){3}$/;
41
+
42
+ /** The services a release ships images for. */
43
+ export const RELEASE_IMAGES = ["admin", "gateway", "ops"] as const;
44
+ export type ReleaseImage = (typeof RELEASE_IMAGES)[number];
45
+
46
+ const digest = z.string().regex(IMAGE_DIGEST, "expected sha256:<64 hex>");
47
+
48
+ /** Zod schema of `release-manifest.json` (manifestVersion 1). */
49
+ export const ReleaseManifestSchema = z.object({
50
+ manifestVersion: z.literal(1),
51
+ version: z.string().regex(SEMVER, "expected a SemVer version without a leading v"),
52
+ channel: z.enum(RELEASE_CHANNELS),
53
+ digests: z.object({ admin: digest, gateway: digest, ops: digest }),
54
+ minFromVersion: z.string().regex(SEMVER),
55
+ infraContractVersion: z.number().int().positive(),
56
+ migrations: z.object({
57
+ /** True when the release carries destructive (contract) migrations. */
58
+ contract: z.boolean(),
59
+ /**
60
+ * Newest release up to and including this one that carried contract
61
+ * migrations (null: none known). An install below it that jumps
62
+ * straight here would run those destructive steps while its old image
63
+ * still serves — so it is manual too, even when `contract` is false.
64
+ */
65
+ lastContractVersion: z.string().regex(SEMVER).nullable(),
66
+ /** Newest cms_admin migration in the release, e.g. `0262_tool_approval_branch`. */
67
+ head: z.string().min(1),
68
+ }),
69
+ /** Set only via the release-cut input, backed by published GitHub Security Advisories. */
70
+ security: z.boolean(),
71
+ advisories: z.array(z.string().regex(GHSA_ID, "expected a GHSA id")),
72
+ });
73
+ export type ReleaseManifest = z.infer<typeof ReleaseManifestSchema>;
74
+
75
+ /** Registry the upgrader reads manifests from — the public AR mirror Cloud Run pulls from. */
76
+ export const RELEASE_MANIFEST_REGISTRY = "europe-west1-docker.pkg.dev";
77
+ /** Repository path of the manifest artifact inside {@link RELEASE_MANIFEST_REGISTRY}. */
78
+ export const RELEASE_MANIFEST_REPOSITORY = "caelo-website/caelo-cms-images/release-manifest";
79
+
80
+ /** OCI artifact type + layer media types of the pushed manifest. */
81
+ export const RELEASE_MANIFEST_ARTIFACT_TYPE = "application/vnd.caelo.release-manifest.v1";
82
+ export const RELEASE_MANIFEST_MEDIA_TYPE = "application/vnd.caelo.release-manifest.v1+json";
83
+ export const SIGSTORE_BUNDLE_MEDIA_TYPE = "application/vnd.dev.sigstore.bundle.v0.3+json";
84
+
85
+ // The signer constants are shared with the image verification (one source).
86
+ export { RELEASE_OIDC_ISSUER };
87
+
88
+ /**
89
+ * The only identity allowed to sign the manifest of `version`: release.yml
90
+ * of caelo-cms/caelo-cms running on that exact tag. Exact, not a regex —
91
+ * a manifest signed on another ref (a branch, another tag) is refused.
92
+ */
93
+ export function releaseManifestSignerIdentity(version: string): string {
94
+ return `${RELEASE_WORKFLOWS_URL}/release.yml@refs/tags/v${version}`;
95
+ }
96
+
97
+ /**
98
+ * Compare two SemVer strings: negative when `a < b`, 0 when equal,
99
+ * positive when `a > b`. A pre-release sorts below its release; two
100
+ * pre-release tags compare by SemVer identifier rules.
101
+ *
102
+ * @throws Error when either argument is not SemVer.
103
+ */
104
+ export function compareSemver(a: string, b: string): number {
105
+ const ma = SEMVER.exec(a);
106
+ const mb = SEMVER.exec(b);
107
+ if (!ma || !mb) throw new Error(`not a SemVer version: ${ma ? b : a}`);
108
+ for (let i = 1; i <= 3; i++) {
109
+ const d = Number(ma[i]) - Number(mb[i]);
110
+ if (d !== 0) return d;
111
+ }
112
+ const pa = ma[4];
113
+ const pb = mb[4];
114
+ if (pa === undefined || pb === undefined) {
115
+ return pa === pb ? 0 : pa === undefined ? 1 : -1;
116
+ }
117
+ const ia = pa.split(".");
118
+ const ib = pb.split(".");
119
+ for (let i = 0; i < Math.max(ia.length, ib.length); i++) {
120
+ const x = ia[i];
121
+ const y = ib[i];
122
+ if (x === undefined) return -1;
123
+ if (y === undefined) return 1;
124
+ const xn = /^\d+$/.test(x);
125
+ const yn = /^\d+$/.test(y);
126
+ if (xn && yn && Number(x) !== Number(y)) return Number(x) - Number(y);
127
+ if (xn !== yn) return xn ? -1 : 1;
128
+ if (x !== y) return x < y ? -1 : 1;
129
+ }
130
+ return 0;
131
+ }
132
+
133
+ /**
134
+ * The channel a version ships on.
135
+ *
136
+ * @returns null for a pre-release whose identifier names no known channel.
137
+ */
138
+ export function channelForVersion(version: string): ReleaseChannel | null {
139
+ const m = SEMVER.exec(version);
140
+ if (!m) return null;
141
+ if (m[4] === undefined) return "stable";
142
+ const id = m[4].split(".")[0];
143
+ return (RELEASE_CHANNELS as readonly string[]).includes(id ?? "") ? (id as ReleaseChannel) : null;
144
+ }
145
+
146
+ /** Why fetching or verifying a manifest failed. */
147
+ export type ReleaseManifestErrorCode =
148
+ | "registry"
149
+ | "artifact-shape"
150
+ | "blob-digest"
151
+ | "signature"
152
+ | "schema"
153
+ | "version-mismatch";
154
+
155
+ export interface ReleaseManifestError {
156
+ readonly code: ReleaseManifestErrorCode;
157
+ readonly message: string;
158
+ }
159
+
160
+ export type ReleaseManifestResult =
161
+ | { readonly ok: true; readonly manifest: ReleaseManifest }
162
+ | { readonly ok: false; readonly error: ReleaseManifestError };
163
+
164
+ /** Side effects of {@link fetchVerifiedReleaseManifest}, injectable for tests. */
165
+ export interface ReleaseManifestDeps {
166
+ readonly fetch: (url: string, init?: RequestInit) => Promise<Response>;
167
+ /** Runs `cosign` with `args`; resolves with its exit code + stderr. */
168
+ readonly cosign: (args: readonly string[]) => Promise<{ exitCode: number; stderr: string }>;
169
+ /** Registry host + repository; defaults to the fixed public mirror. */
170
+ readonly registry?: string;
171
+ readonly repository?: string;
172
+ }
173
+
174
+ /** `cosign` from PATH. Missing binary → exit code 127 with a hint. */
175
+ async function runCosign(args: readonly string[]): Promise<{ exitCode: number; stderr: string }> {
176
+ try {
177
+ const proc = Bun.spawn(["cosign", ...args], { stdout: "ignore", stderr: "pipe" });
178
+ const stderr = await new Response(proc.stderr).text();
179
+ return { exitCode: await proc.exited, stderr };
180
+ } catch (e) {
181
+ return {
182
+ exitCode: 127,
183
+ stderr: `cosign could not be started (${e instanceof Error ? e.message : String(e)}) — install cosign (it ships in the caelo-ops image)`,
184
+ };
185
+ }
186
+ }
187
+
188
+ const defaultDeps: ReleaseManifestDeps = {
189
+ fetch: (url, init) => fetch(url, init),
190
+ cosign: runCosign,
191
+ };
192
+
193
+ interface OciDescriptor {
194
+ readonly mediaType: string;
195
+ readonly digest: string;
196
+ }
197
+
198
+ const OciManifestSchema = z.object({
199
+ artifactType: z.string().optional(),
200
+ layers: z.array(z.object({ mediaType: z.string(), digest: z.string().regex(IMAGE_DIGEST) })),
201
+ });
202
+
203
+ function fail(code: ReleaseManifestErrorCode, message: string): ReleaseManifestResult {
204
+ return { ok: false, error: { code, message } };
205
+ }
206
+
207
+ async function fetchBlob(
208
+ deps: ReleaseManifestDeps,
209
+ base: string,
210
+ layer: OciDescriptor,
211
+ ): Promise<Uint8Array | ReleaseManifestError> {
212
+ const res = await deps.fetch(`${base}/blobs/${layer.digest}`);
213
+ if (!res.ok) {
214
+ return { code: "registry", message: `blob ${layer.digest}: HTTP ${res.status}` };
215
+ }
216
+ const bytes = new Uint8Array(await res.arrayBuffer());
217
+ const actual = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
218
+ if (actual !== layer.digest) {
219
+ return {
220
+ code: "blob-digest",
221
+ message: `blob content does not match its digest (${layer.digest} expected, ${actual} served) — the registry or a proxy altered it`,
222
+ };
223
+ }
224
+ return bytes;
225
+ }
226
+
227
+ /**
228
+ * Fetch the manifest of `version` from the fixed registry, verify its
229
+ * Sigstore bundle against the release workflow identity for that exact
230
+ * tag, schema-validate it, and check it describes `version`.
231
+ *
232
+ * @param version - SemVer without a leading `v`.
233
+ * @param deps - injected fetch + cosign runner (tests); defaults to the real ones.
234
+ */
235
+ export async function fetchVerifiedReleaseManifest(
236
+ version: string,
237
+ deps: ReleaseManifestDeps = defaultDeps,
238
+ ): Promise<ReleaseManifestResult> {
239
+ if (!SEMVER.test(version)) return fail("schema", `"${version}" is not a SemVer version`);
240
+ const base = `https://${deps.registry ?? RELEASE_MANIFEST_REGISTRY}/v2/${deps.repository ?? RELEASE_MANIFEST_REPOSITORY}`;
241
+
242
+ let artifact: z.infer<typeof OciManifestSchema>;
243
+ try {
244
+ const res = await deps.fetch(`${base}/manifests/${version}`, {
245
+ headers: { Accept: "application/vnd.oci.image.manifest.v1+json" },
246
+ });
247
+ if (!res.ok) {
248
+ return fail(
249
+ "registry",
250
+ `no release manifest for v${version} at ${base} (HTTP ${res.status}). Releases before the signed manifest (epic #696) have none; otherwise check the version exists.`,
251
+ );
252
+ }
253
+ const parsed = OciManifestSchema.safeParse(await res.json());
254
+ if (!parsed.success)
255
+ return fail("artifact-shape", `not an OCI manifest: ${parsed.error.message}`);
256
+ artifact = parsed.data;
257
+ } catch (e) {
258
+ return fail("registry", `registry unreachable: ${e instanceof Error ? e.message : String(e)}`);
259
+ }
260
+
261
+ if (artifact.artifactType !== RELEASE_MANIFEST_ARTIFACT_TYPE) {
262
+ return fail(
263
+ "artifact-shape",
264
+ `artifactType is ${artifact.artifactType ?? "(none)"}, expected ${RELEASE_MANIFEST_ARTIFACT_TYPE}`,
265
+ );
266
+ }
267
+ const manifestLayers = artifact.layers.filter((l) => l.mediaType === RELEASE_MANIFEST_MEDIA_TYPE);
268
+ const bundleLayers = artifact.layers.filter((l) => l.mediaType === SIGSTORE_BUNDLE_MEDIA_TYPE);
269
+ const [manifestLayer] = manifestLayers;
270
+ const [bundleLayer] = bundleLayers;
271
+ if (manifestLayers.length !== 1 || bundleLayers.length !== 1 || !manifestLayer || !bundleLayer) {
272
+ return fail(
273
+ "artifact-shape",
274
+ "the artifact must hold exactly one release-manifest layer and one Sigstore bundle layer",
275
+ );
276
+ }
277
+
278
+ let manifestBytes: Uint8Array;
279
+ let bundleBytes: Uint8Array;
280
+ try {
281
+ const m = await fetchBlob(deps, base, manifestLayer);
282
+ if (!(m instanceof Uint8Array)) return { ok: false, error: m };
283
+ const b = await fetchBlob(deps, base, bundleLayer);
284
+ if (!(b instanceof Uint8Array)) return { ok: false, error: b };
285
+ manifestBytes = m;
286
+ bundleBytes = b;
287
+ } catch (e) {
288
+ return fail("registry", `registry unreachable: ${e instanceof Error ? e.message : String(e)}`);
289
+ }
290
+
291
+ // cosign verifies files, so the exact bytes go to a private temp dir.
292
+ const dir = await mkdtemp(join(tmpdir(), "caelo-release-manifest-"));
293
+ try {
294
+ const manifestPath = join(dir, "release-manifest.json");
295
+ const bundlePath = join(dir, "release-manifest.json.sigstore.json");
296
+ await writeFile(manifestPath, manifestBytes, { mode: 0o600 });
297
+ await writeFile(bundlePath, bundleBytes, { mode: 0o600 });
298
+ const verify = await deps.cosign([
299
+ "verify-blob",
300
+ "--bundle",
301
+ bundlePath,
302
+ "--certificate-identity",
303
+ releaseManifestSignerIdentity(version),
304
+ "--certificate-oidc-issuer",
305
+ RELEASE_OIDC_ISSUER,
306
+ manifestPath,
307
+ ]);
308
+ if (verify.exitCode !== 0) {
309
+ return fail(
310
+ "signature",
311
+ `the manifest of v${version} is not signed by ${releaseManifestSignerIdentity(version)} — refuse to install. cosign: ${verify.stderr.trim().slice(0, 500)}`,
312
+ );
313
+ }
314
+ } finally {
315
+ await rm(dir, { recursive: true, force: true });
316
+ }
317
+
318
+ let json: unknown;
319
+ try {
320
+ json = JSON.parse(new TextDecoder().decode(manifestBytes));
321
+ } catch {
322
+ return fail("schema", "the signed manifest is not valid JSON");
323
+ }
324
+ const parsed = ReleaseManifestSchema.safeParse(json);
325
+ if (!parsed.success) {
326
+ return fail(
327
+ "schema",
328
+ `the signed manifest does not match manifestVersion 1 — this CLI may be too old to read it: ${parsed.error.message}`,
329
+ );
330
+ }
331
+ if (parsed.data.version !== version) {
332
+ return fail(
333
+ "version-mismatch",
334
+ `asked for v${version} but the signed manifest describes v${parsed.data.version}`,
335
+ );
336
+ }
337
+ return { ok: true, manifest: parsed.data };
338
+ }
339
+
340
+ /** One image whose resolved digest differs from the manifest's. */
341
+ export interface DigestMismatch {
342
+ readonly image: ReleaseImage;
343
+ readonly expected: string;
344
+ readonly actual: string | null;
345
+ }
346
+
347
+ /**
348
+ * Compare the digests a registry resolved for the release's tags with the
349
+ * signed manifest. Only images present in `resolved` are compared, except
350
+ * that an explicitly `null` entry (tag did not resolve) is a mismatch.
351
+ *
352
+ * @returns every mismatch; empty means the registry serves the signed images.
353
+ */
354
+ export function compareImageDigests(
355
+ manifest: ReleaseManifest,
356
+ resolved: Partial<Record<ReleaseImage, string | null>>,
357
+ ): DigestMismatch[] {
358
+ const out: DigestMismatch[] = [];
359
+ for (const image of RELEASE_IMAGES) {
360
+ if (!(image in resolved)) continue;
361
+ const actual = resolved[image] ?? null;
362
+ if (actual !== manifest.digests[image]) {
363
+ out.push({ image, expected: manifest.digests[image], actual });
364
+ }
365
+ }
366
+ return out;
367
+ }
368
+
369
+ /** What the installed system looks like, for {@link assessAutoInstall}. */
370
+ export interface InstalledRelease {
371
+ readonly version: string;
372
+ readonly infraContractVersion: number;
373
+ }
374
+
375
+ /**
376
+ * How an upgrader may treat a release:
377
+ * - `auto` — may install unattended (if the Owner's policy allows the kind).
378
+ * - `manual` — installable only with the operator's own identity
379
+ * ("Run in Cloud Shell"); never unattended.
380
+ * - `refused` — not installable onto this install at all.
381
+ */
382
+ export type InstallMode = "auto" | "manual" | "refused";
383
+
384
+ export interface InstallAssessment {
385
+ readonly mode: InstallMode;
386
+ /** `security` only when the manifest says so AND the release is otherwise auto-installable. */
387
+ readonly kind: "security" | "normal";
388
+ readonly reasons: readonly string[];
389
+ }
390
+
391
+ /**
392
+ * Decide whether `manifest` can be installed on `installed`, and how.
393
+ *
394
+ * A security release is NOT exempt: if it carries contract migrations
395
+ * (or skips a release that did) or raises the infra contract, it is `manual` like any other release — the
396
+ * manifest still says `security`, but `kind` drops to `normal` so an
397
+ * "auto-install security fixes" policy never applies it unattended.
398
+ */
399
+ export function assessAutoInstall(
400
+ manifest: ReleaseManifest,
401
+ installed: InstalledRelease,
402
+ ): InstallAssessment {
403
+ const refused: string[] = [];
404
+ if (compareSemver(manifest.version, installed.version) <= 0) {
405
+ refused.push(`v${manifest.version} is not newer than the installed v${installed.version}`);
406
+ }
407
+ if (compareSemver(installed.version, manifest.minFromVersion) < 0) {
408
+ refused.push(
409
+ `v${manifest.version} needs at least v${manifest.minFromVersion} installed first (installed: v${installed.version})`,
410
+ );
411
+ }
412
+ if (refused.length > 0) return { mode: "refused", kind: "normal", reasons: refused };
413
+
414
+ const manual: string[] = [];
415
+ if (manifest.infraContractVersion > installed.infraContractVersion) {
416
+ manual.push(
417
+ `the release changes the infrastructure contract (${installed.infraContractVersion} → ${manifest.infraContractVersion}); it must run with the operator's own identity`,
418
+ );
419
+ }
420
+ const lastContract = manifest.migrations.lastContractVersion;
421
+ if (manifest.migrations.contract) {
422
+ manual.push("the release carries contract (destructive) migrations");
423
+ } else if (lastContract !== null && compareSemver(installed.version, lastContract) < 0) {
424
+ manual.push(
425
+ `the upgrade skips v${lastContract}, whose contract (destructive) migrations would run while v${installed.version}'s image still serves`,
426
+ );
427
+ }
428
+ if (manual.length > 0) return { mode: "manual", kind: "normal", reasons: manual };
429
+ return { mode: "auto", kind: manifest.security ? "security" : "normal", reasons: [] };
430
+ }
@@ -0,0 +1,40 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Where the long half of a GCP install runs (epic #696, Phase 3):
5
+ *
6
+ * - `job` — the operator's terminal does the identity-bound bootstrap
7
+ * (project, billing, APIs, keyless provisioner, state bucket,
8
+ * cost y/N), then a Cloud Run job in the install's project
9
+ * runs `pulumi up`, the DNS + certificate waits and the rest
10
+ * (install-job.ts). The terminal only tails it.
11
+ * - `local` — everything runs in this process, as it always has; Pulumi
12
+ * impersonates the provisioner with the operator's ADC.
13
+ *
14
+ * Default: `job` in Google Cloud Shell, whose session ends after 40 idle
15
+ * minutes and whose home directory the "Open in Cloud Shell" button makes
16
+ * temporary — an install must not depend on that tab. `local` everywhere
17
+ * else, so a laptop / CI run behaves exactly as before. `--local` and
18
+ * `--install-job` override the default either way.
19
+ */
20
+
21
+ export type RunMode = "job" | "local";
22
+
23
+ /** Whether this process runs inside Google Cloud Shell (it sets `CLOUD_SHELL=true`). */
24
+ export function inCloudShell(env: NodeJS.ProcessEnv): boolean {
25
+ return env.CLOUD_SHELL === "true";
26
+ }
27
+
28
+ /**
29
+ * The run mode for `argv` + `env`.
30
+ *
31
+ * @throws when both `--local` and `--install-job` are given
32
+ */
33
+ export function chooseRunMode(argv: readonly string[], env: NodeJS.ProcessEnv): RunMode {
34
+ const local = argv.includes("--local");
35
+ const job = argv.includes("--install-job");
36
+ if (local && job) throw new Error("Pass either --local or --install-job, not both.");
37
+ if (local) return "local";
38
+ if (job) return "job";
39
+ return inCloudShell(env) ? "job" : "local";
40
+ }