@caelo-cms/provisioning 0.10.47 → 0.10.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (315) hide show
  1. package/dist/agent/apply.d.ts +45 -0
  2. package/dist/agent/apply.d.ts.map +1 -0
  3. package/dist/agent/apply.js +83 -0
  4. package/dist/agent/apply.js.map +1 -0
  5. package/dist/agent/cli-provenance.d.ts +42 -0
  6. package/dist/agent/cli-provenance.d.ts.map +1 -0
  7. package/dist/agent/cli-provenance.js +138 -0
  8. package/dist/agent/cli-provenance.js.map +1 -0
  9. package/dist/agent/command.d.ts +79 -0
  10. package/dist/agent/command.d.ts.map +1 -0
  11. package/dist/agent/command.js +454 -0
  12. package/dist/agent/command.js.map +1 -0
  13. package/dist/agent/config.d.ts +30 -0
  14. package/dist/agent/config.d.ts.map +1 -0
  15. package/dist/agent/config.js +60 -0
  16. package/dist/agent/config.js.map +1 -0
  17. package/dist/agent/lock.d.ts +13 -0
  18. package/dist/agent/lock.d.ts.map +1 -0
  19. package/dist/agent/lock.js +100 -0
  20. package/dist/agent/lock.js.map +1 -0
  21. package/dist/agent/queue.d.ts +35 -0
  22. package/dist/agent/queue.d.ts.map +1 -0
  23. package/dist/agent/queue.js +50 -0
  24. package/dist/agent/queue.js.map +1 -0
  25. package/dist/agent/run.d.ts +97 -0
  26. package/dist/agent/run.d.ts.map +1 -0
  27. package/dist/agent/run.js +198 -0
  28. package/dist/agent/run.js.map +1 -0
  29. package/dist/agent/systemd.d.ts +55 -0
  30. package/dist/agent/systemd.d.ts.map +1 -0
  31. package/dist/agent/systemd.js +171 -0
  32. package/dist/agent/systemd.js.map +1 -0
  33. package/dist/agent/units.d.ts +44 -0
  34. package/dist/agent/units.d.ts.map +1 -0
  35. package/dist/agent/units.js +127 -0
  36. package/dist/agent/units.js.map +1 -0
  37. package/dist/agent-db.js +18362 -0
  38. package/dist/cli.js +119 -16
  39. package/dist/cli.js.map +1 -1
  40. package/dist/cloudsql-iam-user.d.ts +39 -0
  41. package/dist/cloudsql-iam-user.d.ts.map +1 -0
  42. package/dist/cloudsql-iam-user.js +119 -0
  43. package/dist/cloudsql-iam-user.js.map +1 -0
  44. package/dist/compose.d.ts +16 -0
  45. package/dist/compose.d.ts.map +1 -1
  46. package/dist/compose.js +26 -1
  47. package/dist/compose.js.map +1 -1
  48. package/dist/dns/index.d.ts +2 -0
  49. package/dist/dns/index.d.ts.map +1 -1
  50. package/dist/dns/index.js +3 -2
  51. package/dist/dns/index.js.map +1 -1
  52. package/dist/dns/manual.d.ts +5 -0
  53. package/dist/dns/manual.d.ts.map +1 -1
  54. package/dist/dns/manual.js +28 -12
  55. package/dist/dns/manual.js.map +1 -1
  56. package/dist/gcloud.d.ts +2 -0
  57. package/dist/gcloud.d.ts.map +1 -1
  58. package/dist/gcloud.js +1 -1
  59. package/dist/gcloud.js.map +1 -1
  60. package/dist/gcp-names.d.ts +15 -0
  61. package/dist/gcp-names.d.ts.map +1 -1
  62. package/dist/gcp-names.js +23 -0
  63. package/dist/gcp-names.js.map +1 -1
  64. package/dist/index.d.ts +6 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +4 -0
  67. package/dist/index.js.map +1 -1
  68. package/dist/infra-contract.d.ts +1 -1
  69. package/dist/infra-contract.js +7 -1
  70. package/dist/infra-contract.js.map +1 -1
  71. package/dist/install-job-runner.d.ts +13 -0
  72. package/dist/install-job-runner.d.ts.map +1 -0
  73. package/dist/install-job-runner.js +155 -0
  74. package/dist/install-job-runner.js.map +1 -0
  75. package/dist/install-job.d.ts +210 -0
  76. package/dist/install-job.d.ts.map +1 -0
  77. package/dist/install-job.js +449 -0
  78. package/dist/install-job.js.map +1 -0
  79. package/dist/install-state.d.ts +10 -1
  80. package/dist/install-state.d.ts.map +1 -1
  81. package/dist/install-state.js +10 -2
  82. package/dist/install-state.js.map +1 -1
  83. package/dist/lifecycle.d.ts.map +1 -1
  84. package/dist/lifecycle.js +19 -4
  85. package/dist/lifecycle.js.map +1 -1
  86. package/dist/migration-runner.d.ts +5 -0
  87. package/dist/migration-runner.d.ts.map +1 -1
  88. package/dist/migration-runner.js +1 -1
  89. package/dist/migration-runner.js.map +1 -1
  90. package/dist/operator-access.d.ts +14 -8
  91. package/dist/operator-access.d.ts.map +1 -1
  92. package/dist/operator-access.js +16 -102
  93. package/dist/operator-access.js.map +1 -1
  94. package/dist/run-mode.d.ts +27 -0
  95. package/dist/run-mode.d.ts.map +1 -0
  96. package/dist/run-mode.js +22 -0
  97. package/dist/run-mode.js.map +1 -0
  98. package/dist/runtime-secrets.d.ts +19 -0
  99. package/dist/runtime-secrets.d.ts.map +1 -1
  100. package/dist/runtime-secrets.js +44 -0
  101. package/dist/runtime-secrets.js.map +1 -1
  102. package/dist/self-hosted-roles.d.ts +22 -1
  103. package/dist/self-hosted-roles.d.ts.map +1 -1
  104. package/dist/self-hosted-roles.js +81 -12
  105. package/dist/self-hosted-roles.js.map +1 -1
  106. package/dist/self-hosted-upgrade.d.ts +17 -0
  107. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  108. package/dist/self-hosted-upgrade.js +16 -1
  109. package/dist/self-hosted-upgrade.js.map +1 -1
  110. package/dist/stack-contract.d.ts +6 -0
  111. package/dist/stack-contract.d.ts.map +1 -1
  112. package/dist/stack-contract.js +10 -1
  113. package/dist/stack-contract.js.map +1 -1
  114. package/dist/upgrade/converge.d.ts +6 -0
  115. package/dist/upgrade/converge.d.ts.map +1 -1
  116. package/dist/upgrade/converge.js +59 -2
  117. package/dist/upgrade/converge.js.map +1 -1
  118. package/dist/upgrade/gcp-lookup.d.ts +6 -0
  119. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  120. package/dist/upgrade/gcp-lookup.js +25 -0
  121. package/dist/upgrade/gcp-lookup.js.map +1 -1
  122. package/dist/upgrade/release-images.d.ts +12 -0
  123. package/dist/upgrade/release-images.d.ts.map +1 -1
  124. package/dist/upgrade/release-images.js +31 -0
  125. package/dist/upgrade/release-images.js.map +1 -1
  126. package/dist/upgrade/upgrade.d.ts +5 -0
  127. package/dist/upgrade/upgrade.d.ts.map +1 -1
  128. package/dist/upgrade/upgrade.js +28 -1
  129. package/dist/upgrade/upgrade.js.map +1 -1
  130. package/dist/upgrader-assess.d.ts +82 -0
  131. package/dist/upgrader-assess.d.ts.map +1 -0
  132. package/dist/upgrader-assess.js +116 -0
  133. package/dist/upgrader-assess.js.map +1 -0
  134. package/dist/upgrader-gcp.d.ts +17 -0
  135. package/dist/upgrader-gcp.d.ts.map +1 -0
  136. package/dist/upgrader-gcp.js +87 -0
  137. package/dist/upgrader-gcp.js.map +1 -0
  138. package/dist/upgrader-job.d.ts +131 -0
  139. package/dist/upgrader-job.d.ts.map +1 -0
  140. package/dist/upgrader-job.js +259 -0
  141. package/dist/upgrader-job.js.map +1 -0
  142. package/dist/upgrader-reporter.d.ts +35 -0
  143. package/dist/upgrader-reporter.d.ts.map +1 -0
  144. package/dist/upgrader-reporter.js +79 -0
  145. package/dist/upgrader-reporter.js.map +1 -0
  146. package/dist/upgrader-setup.d.ts +59 -0
  147. package/dist/upgrader-setup.d.ts.map +1 -0
  148. package/dist/upgrader-setup.js +308 -0
  149. package/dist/upgrader-setup.js.map +1 -0
  150. package/dist/wizard.d.ts +6 -0
  151. package/dist/wizard.d.ts.map +1 -1
  152. package/dist/wizard.js +32 -4
  153. package/dist/wizard.js.map +1 -1
  154. package/dist/wizards/gcp-firebase.d.ts +3 -0
  155. package/dist/wizards/gcp-firebase.d.ts.map +1 -1
  156. package/dist/wizards/gcp-firebase.js +1 -0
  157. package/dist/wizards/gcp-firebase.js.map +1 -1
  158. package/dist/wizards/gcp-install-job.d.ts +74 -0
  159. package/dist/wizards/gcp-install-job.d.ts.map +1 -0
  160. package/dist/wizards/gcp-install-job.js +398 -0
  161. package/dist/wizards/gcp-install-job.js.map +1 -0
  162. package/dist/wizards/gcp-prompts.d.ts +3 -0
  163. package/dist/wizards/gcp-prompts.d.ts.map +1 -0
  164. package/dist/wizards/gcp-prompts.js +25 -0
  165. package/dist/wizards/gcp-prompts.js.map +1 -0
  166. package/dist/wizards/gcp-pulumi.d.ts +4 -2
  167. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  168. package/dist/wizards/gcp-pulumi.js +1 -1
  169. package/dist/wizards/gcp-pulumi.js.map +1 -1
  170. package/dist/wizards/gcp.d.ts +40 -0
  171. package/dist/wizards/gcp.d.ts.map +1 -1
  172. package/dist/wizards/gcp.js +223 -59
  173. package/dist/wizards/gcp.js.map +1 -1
  174. package/package.json +6 -4
  175. package/src/adapter.ts +97 -0
  176. package/src/adc-impersonation.ts +176 -0
  177. package/src/admin-domain.test.ts +254 -0
  178. package/src/admin-domain.ts +245 -0
  179. package/src/admin-memory.test.ts +151 -0
  180. package/src/agent/apply.test.ts +185 -0
  181. package/src/agent/apply.ts +124 -0
  182. package/src/agent/cli-provenance.test.ts +159 -0
  183. package/src/agent/cli-provenance.ts +191 -0
  184. package/src/agent/command.test.ts +77 -0
  185. package/src/agent/command.ts +564 -0
  186. package/src/agent/config.ts +86 -0
  187. package/src/agent/lock-config.test.ts +97 -0
  188. package/src/agent/lock.ts +110 -0
  189. package/src/agent/queue.test.ts +10 -0
  190. package/src/agent/queue.ts +91 -0
  191. package/src/agent/run.test.ts +382 -0
  192. package/src/agent/run.ts +295 -0
  193. package/src/agent/systemd.ts +209 -0
  194. package/src/agent/units.test.ts +223 -0
  195. package/src/agent/units.ts +163 -0
  196. package/src/azure-front-door.test.ts +119 -0
  197. package/src/azure-front-door.ts +56 -0
  198. package/src/bootstrap-token.ts +20 -0
  199. package/src/caddy.ts +112 -0
  200. package/src/cdn-copy.ts +91 -0
  201. package/src/cert-wait.test.ts +194 -0
  202. package/src/cert-wait.ts +163 -0
  203. package/src/checkpoint-secrets.test.ts +380 -0
  204. package/src/checkpoint-secrets.ts +283 -0
  205. package/src/cli-routing.test.ts +45 -0
  206. package/src/cli-routing.ts +43 -0
  207. package/src/cli.ts +1491 -0
  208. package/src/cloudsql-iam-user.ts +147 -0
  209. package/src/compose.ts +334 -0
  210. package/src/default-firewall.test.ts +411 -0
  211. package/src/default-firewall.ts +342 -0
  212. package/src/deployed-release.test.ts +231 -0
  213. package/src/deployed-release.ts +220 -0
  214. package/src/dns/cloudflare.ts +182 -0
  215. package/src/dns/groups.test.ts +76 -0
  216. package/src/dns/groups.ts +86 -0
  217. package/src/dns/index.ts +56 -0
  218. package/src/dns/manual.ts +130 -0
  219. package/src/dns/types.ts +27 -0
  220. package/src/gateway-credentials.test.ts +180 -0
  221. package/src/gateway-credentials.ts +144 -0
  222. package/src/gcloud-components.test.ts +76 -0
  223. package/src/gcloud-components.ts +85 -0
  224. package/src/gcloud-retry.ts +41 -0
  225. package/src/gcloud-runner.test.ts +61 -0
  226. package/src/gcloud.test.ts +99 -0
  227. package/src/gcloud.ts +322 -0
  228. package/src/gcp-names.ts +135 -0
  229. package/src/index.test.ts +332 -0
  230. package/src/index.ts +114 -0
  231. package/src/infra-contract.ts +32 -0
  232. package/src/install-job-runner.ts +166 -0
  233. package/src/install-job.test.ts +566 -0
  234. package/src/install-job.ts +542 -0
  235. package/src/install-state.test.ts +239 -0
  236. package/src/install-state.ts +583 -0
  237. package/src/internal-token.ts +35 -0
  238. package/src/lifecycle.ts +900 -0
  239. package/src/mcp-iap-stacks.test.ts +58 -0
  240. package/src/mcp-iap.test.ts +149 -0
  241. package/src/mcp-iap.ts +131 -0
  242. package/src/media-volume.test.ts +296 -0
  243. package/src/migration-runner.ts +365 -0
  244. package/src/operator-access-stacks.test.ts +55 -0
  245. package/src/operator-access.test.ts +332 -0
  246. package/src/operator-access.ts +554 -0
  247. package/src/ops-backend.test.ts +320 -0
  248. package/src/ops-backend.ts +414 -0
  249. package/src/provisioner-identity.test.ts +482 -0
  250. package/src/provisioner-identity.ts +407 -0
  251. package/src/redirects-emit.ts +175 -0
  252. package/src/region-data.ts +428 -0
  253. package/src/regions.test.ts +277 -0
  254. package/src/regions.ts +370 -0
  255. package/src/release-contract.ts +57 -0
  256. package/src/release-manifest.test.ts +293 -0
  257. package/src/release-manifest.ts +430 -0
  258. package/src/run-mode.ts +40 -0
  259. package/src/runtime-secrets.test.ts +627 -0
  260. package/src/runtime-secrets.ts +754 -0
  261. package/src/self-command.test.ts +14 -0
  262. package/src/self-command.ts +21 -0
  263. package/src/self-hosted-compose.test.ts +177 -0
  264. package/src/self-hosted-release.ts +240 -0
  265. package/src/self-hosted-roles.integration.test.ts +161 -0
  266. package/src/self-hosted-roles.test.ts +124 -0
  267. package/src/self-hosted-roles.ts +203 -0
  268. package/src/self-hosted-upgrade.test.ts +394 -0
  269. package/src/self-hosted-upgrade.ts +548 -0
  270. package/src/stack-contract-parity.test.ts +199 -0
  271. package/src/stack-contract.test.ts +391 -0
  272. package/src/stack-contract.ts +888 -0
  273. package/src/stack-converge-secrets.test.ts +421 -0
  274. package/src/stack-converge.test.ts +833 -0
  275. package/src/stack-converge.ts +909 -0
  276. package/src/staging-admin.test.ts +246 -0
  277. package/src/staging-admin.ts +324 -0
  278. package/src/state-attach.test.ts +211 -0
  279. package/src/state-attach.ts +132 -0
  280. package/src/state-backend.test.ts +145 -0
  281. package/src/state-backend.ts +262 -0
  282. package/src/state-command.ts +293 -0
  283. package/src/state-migrate.test.ts +479 -0
  284. package/src/state-migrate.ts +434 -0
  285. package/src/state-pulumi.ts +289 -0
  286. package/src/state-store.test.ts +212 -0
  287. package/src/state-store.ts +243 -0
  288. package/src/upgrade/app-roll.ts +486 -0
  289. package/src/upgrade/backup.ts +109 -0
  290. package/src/upgrade/converge.ts +437 -0
  291. package/src/upgrade/gcp-lookup.ts +167 -0
  292. package/src/upgrade/health.test.ts +279 -0
  293. package/src/upgrade/health.ts +305 -0
  294. package/src/upgrade/release-images.test.ts +119 -0
  295. package/src/upgrade/release-images.ts +391 -0
  296. package/src/upgrade/reporter.ts +46 -0
  297. package/src/upgrade/upgrade.test.ts +626 -0
  298. package/src/upgrade/upgrade.ts +249 -0
  299. package/src/upgrader-assess.test.ts +265 -0
  300. package/src/upgrader-assess.ts +233 -0
  301. package/src/upgrader-gcp.ts +123 -0
  302. package/src/upgrader-job.test.ts +408 -0
  303. package/src/upgrader-job.ts +499 -0
  304. package/src/upgrader-reporter.test.ts +46 -0
  305. package/src/upgrader-reporter.ts +105 -0
  306. package/src/upgrader-setup.test.ts +169 -0
  307. package/src/upgrader-setup.ts +355 -0
  308. package/src/wizard.ts +425 -0
  309. package/src/wizards/gcp-cost.ts +188 -0
  310. package/src/wizards/gcp-firebase.ts +72 -0
  311. package/src/wizards/gcp-install-job.ts +506 -0
  312. package/src/wizards/gcp-prompts.ts +26 -0
  313. package/src/wizards/gcp-pulumi.test.ts +246 -0
  314. package/src/wizards/gcp-pulumi.ts +516 -0
  315. package/src/wizards/gcp.ts +1935 -0
@@ -0,0 +1,407 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The keyless provisioner identity (#696, CLAUDE.md §11.C: "No long-lived
5
+ * service-account keys downloaded to disk on user machines").
6
+ *
7
+ * Every GCP install has a `caelo-provisioner` service account holding the
8
+ * roles Pulumi needs to converge the stack (gcloud.ts PROVISIONER_ROLES).
9
+ * Installers before #696 minted a JSON key for it into
10
+ * `~/.caelo-<id>/secrets/sa-key.json` and handed Pulumi that file. Now nobody
11
+ * holds a key: the operator's own Google login impersonates the account.
12
+ *
13
+ * - The operator's gcloud account gets `roles/iam.serviceAccountTokenCreator`
14
+ * on `caelo-provisioner` only (never project-wide), so it can mint
15
+ * short-lived tokens for that one account and nothing else
16
+ * ({@link ensureProvisionerImpersonation}).
17
+ * - Pulumi's GCP provider runs with `GOOGLE_IMPERSONATE_SERVICE_ACCOUNT`
18
+ * (wizards/gcp-pulumi.ts `pulumiGcpEnv`). Its source credential is the
19
+ * operator's Application Default Credentials, which refresh themselves,
20
+ * and the provider re-mints the impersonated token as it expires — so a
21
+ * `pulumi up` longer than an hour does not fail on an expired token, as a
22
+ * static `GOOGLE_OAUTH_ACCESS_TOKEN` would.
23
+ * adc-impersonation.ts verifies that chain before Pulumi starts.
24
+ * - An existing install's key is deleted, locally and in IAM, on its next
25
+ * `upgrade` or installer run ({@link removeLegacyProvisionerKey}). Only the
26
+ * IAM key whose id matches the local file is deleted; other user-managed
27
+ * keys of the account are reported, never deleted blindly.
28
+ *
29
+ * Nothing here handles a token or private key material: the key file is
30
+ * read only for its id and account, and the file is then deleted.
31
+ */
32
+
33
+ import { existsSync, readFileSync, rmSync } from "node:fs";
34
+ import { join } from "node:path";
35
+ import { gcloud as defaultGcloud } from "./gcloud.js";
36
+ import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
37
+
38
+ /** Account id of the service account Pulumi converges the stack as. */
39
+ export const PROVISIONER_ACCOUNT_ID = "caelo-provisioner";
40
+
41
+ /** The role that lets the operator impersonate the provisioner. */
42
+ export const TOKEN_CREATOR_ROLE = "roles/iam.serviceAccountTokenCreator";
43
+
44
+ /** File name of the JSON key installers before #696 kept in `secrets/`. */
45
+ export const LEGACY_SA_KEY_FILE = "sa-key.json";
46
+
47
+ /**
48
+ * Environment variables that would make Google's client libraries use a
49
+ * credential other than the operator's Application Default Credentials —
50
+ * a key file (perhaps the deleted `sa-key.json`), a static token that expires
51
+ * after an hour, or another impersonation target. Removed from every
52
+ * infrastructure run so the chain is always ADC → impersonated provisioner.
53
+ */
54
+ export const AMBIENT_GOOGLE_CREDENTIAL_ENV: readonly string[] = [
55
+ "GOOGLE_APPLICATION_CREDENTIALS",
56
+ "GOOGLE_CREDENTIALS",
57
+ "GOOGLE_CLOUD_KEYFILE_JSON",
58
+ "GCLOUD_KEYFILE_JSON",
59
+ "GOOGLE_OAUTH_ACCESS_TOKEN",
60
+ "GOOGLE_IMPERSONATE_SERVICE_ACCOUNT",
61
+ ];
62
+
63
+ /** `caelo-provisioner@<project>.iam.gserviceaccount.com`. */
64
+ export function provisionerServiceAccountEmail(projectId: string): string {
65
+ return `${PROVISIONER_ACCOUNT_ID}@${projectId}.iam.gserviceaccount.com`;
66
+ }
67
+
68
+ /** `base` without the credential variables {@link AMBIENT_GOOGLE_CREDENTIAL_ENV} lists. */
69
+ export function withoutAmbientGoogleCredentials(base: NodeJS.ProcessEnv): Record<string, string> {
70
+ const out: Record<string, string> = {};
71
+ for (const [k, v] of Object.entries(base)) {
72
+ if (v !== undefined && !AMBIENT_GOOGLE_CREDENTIAL_ENV.includes(k)) out[k] = v;
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /** IAM member for a gcloud account: a service account when it is one, else a user. */
78
+ export function operatorMember(account: string): string {
79
+ const email = account.trim().toLowerCase();
80
+ return email.endsWith(".gserviceaccount.com") ? `serviceAccount:${email}` : `user:${email}`;
81
+ }
82
+
83
+ interface IamPolicy {
84
+ bindings?: { role?: string; members?: string[]; condition?: unknown }[];
85
+ }
86
+
87
+ export type ImpersonationGrant =
88
+ | { readonly ok: true; readonly status: "already" | "granted" }
89
+ | { readonly ok: false; readonly error: string };
90
+
91
+ /**
92
+ * Give the operator's gcloud account `roles/iam.serviceAccountTokenCreator`
93
+ * on the provisioner service account (and on nothing else). Reads the
94
+ * account's IAM policy first, so an operator who was granted the role by
95
+ * someone else needs no IAM-write permission. Never throws for a gcloud
96
+ * failure.
97
+ */
98
+ export async function ensureProvisionerImpersonation(
99
+ target: { readonly projectId: string; readonly operatorAccount: string },
100
+ deps: { run?: GcloudRunner; sleep?: Sleep } = {},
101
+ ): Promise<ImpersonationGrant> {
102
+ const run = deps.run ?? defaultGcloud;
103
+ const sleep = deps.sleep ?? realSleep;
104
+ const sa = provisionerServiceAccountEmail(target.projectId);
105
+ const member = operatorMember(target.operatorAccount);
106
+ const project = `--project=${target.projectId}`;
107
+
108
+ const policy = await runWithRetry(run, sleep, [
109
+ "iam",
110
+ "service-accounts",
111
+ "get-iam-policy",
112
+ sa,
113
+ project,
114
+ "--format=json",
115
+ ]);
116
+ if (policy.ok) {
117
+ let parsed: IamPolicy = {};
118
+ try {
119
+ parsed = JSON.parse(policy.stdout || "{}") as IamPolicy;
120
+ } catch {
121
+ parsed = {};
122
+ }
123
+ // Only an unconditional binding counts: a conditional one (e.g. an
124
+ // expired time-limited grant) may not allow impersonation at all.
125
+ const has = (parsed.bindings ?? []).some(
126
+ (b) =>
127
+ b.role === TOKEN_CREATOR_ROLE &&
128
+ b.condition == null &&
129
+ (b.members ?? []).some((m) => m.toLowerCase() === member),
130
+ );
131
+ if (has) return { ok: true, status: "already" };
132
+ }
133
+
134
+ const grant = await runWithRetry(run, sleep, [
135
+ "iam",
136
+ "service-accounts",
137
+ "add-iam-policy-binding",
138
+ sa,
139
+ project,
140
+ `--member=${member}`,
141
+ `--role=${TOKEN_CREATOR_ROLE}`,
142
+ "--condition=None",
143
+ "--quiet",
144
+ ]);
145
+ if (grant.ok) return { ok: true, status: "granted" };
146
+ return { ok: false, error: grantError(target.projectId, sa, member, grant.stderr) };
147
+ }
148
+
149
+ function grantError(projectId: string, sa: string, member: string, stderr: string): string {
150
+ const denied = /PERMISSION_DENIED|permission|403|not authorized/i.test(stderr);
151
+ return [
152
+ `Could not let ${member.replace(/^\w+:/, "")} impersonate ${sa}: ${stderr.trim() || "(no output)"}`,
153
+ denied
154
+ ? "Your gcloud account may not change that service account's IAM policy (it needs iam.serviceAccounts.setIamPolicy, e.g. roles/iam.serviceAccountAdmin or Owner on the project)."
155
+ : "",
156
+ "Ask a project Owner to run this once, then re-run:",
157
+ ` gcloud iam service-accounts add-iam-policy-binding ${sa} --project=${projectId} --member=${member} --role=${TOKEN_CREATOR_ROLE} --condition=None`,
158
+ ]
159
+ .filter(Boolean)
160
+ .join("\n");
161
+ }
162
+
163
+ export type LegacyKeyReport =
164
+ | {
165
+ readonly ok: true;
166
+ /** `none`: no local key file. `removed`: file (and its IAM key, if live) deleted. */
167
+ readonly status: "none" | "removed";
168
+ /** The IAM key id deleted, when the key was still live in IAM. */
169
+ readonly deletedKeyId?: string;
170
+ /** Lines for the operator: what was removed, other keys found. */
171
+ readonly lines: string[];
172
+ }
173
+ | { readonly ok: false; readonly error: string };
174
+
175
+ interface ServiceAccountKeyRow {
176
+ name?: string;
177
+ keyType?: string;
178
+ }
179
+
180
+ /**
181
+ * Delete the provisioner's JSON key an installer before #696 minted: first
182
+ * the IAM key whose id matches the local file's `private_key_id`, then the
183
+ * file. The file is only removed once its key is gone from IAM, so a failed
184
+ * IAM delete can be retried on the next run. Other user-managed keys of the
185
+ * account are listed in `lines` with the command to delete them — never
186
+ * deleted here. Idempotent: no local file means nothing to do. Never throws
187
+ * for a gcloud failure.
188
+ */
189
+ export async function removeLegacyProvisionerKey(
190
+ target: { readonly projectId: string; readonly secretsDir: string },
191
+ deps: { run?: GcloudRunner } = {},
192
+ ): Promise<LegacyKeyReport> {
193
+ const run = deps.run ?? defaultGcloud;
194
+ const sa = provisionerServiceAccountEmail(target.projectId);
195
+ const project = `--project=${target.projectId}`;
196
+ const keyPath = join(target.secretsDir, LEGACY_SA_KEY_FILE);
197
+ if (!existsSync(keyPath)) return { ok: true, status: "none", lines: [] };
198
+
199
+ const key = readKeyIdentity(keyPath);
200
+ if (!key.ok) return { ok: false, error: key.error };
201
+ if (key.clientEmail !== sa) {
202
+ return {
203
+ ok: false,
204
+ error: `${keyPath} is a key for ${key.clientEmail}, not for ${sa}. It was left in place: check where it came from, delete it yourself, then re-run.`,
205
+ };
206
+ }
207
+
208
+ const list = await run([
209
+ "iam",
210
+ "service-accounts",
211
+ "keys",
212
+ "list",
213
+ `--iam-account=${sa}`,
214
+ project,
215
+ "--managed-by=user",
216
+ "--format=json",
217
+ ]);
218
+ if (!list.ok) {
219
+ return {
220
+ ok: false,
221
+ error: `Could not list the keys of ${sa}: ${list.stderr.trim()}\nYour gcloud account needs iam.serviceAccountKeys.list (e.g. roles/iam.serviceAccountKeyAdmin). ${keyPath} was left in place; re-run once fixed.`,
222
+ };
223
+ }
224
+ let ids: string[];
225
+ try {
226
+ ids = (JSON.parse(list.stdout || "[]") as ServiceAccountKeyRow[])
227
+ .filter((k) => k.keyType === undefined || k.keyType === "USER_MANAGED")
228
+ .map((k) => (k.name ?? "").split("/").pop() ?? "")
229
+ .filter((id) => id.length > 0);
230
+ } catch {
231
+ return { ok: false, error: `Could not read the key list of ${sa} (not JSON).` };
232
+ }
233
+
234
+ const lines: string[] = [];
235
+ let deletedKeyId: string | undefined;
236
+ if (ids.includes(key.keyId)) {
237
+ const del = await run([
238
+ "iam",
239
+ "service-accounts",
240
+ "keys",
241
+ "delete",
242
+ key.keyId,
243
+ `--iam-account=${sa}`,
244
+ project,
245
+ "--quiet",
246
+ ]);
247
+ if (!del.ok) {
248
+ return {
249
+ ok: false,
250
+ error: `Could not delete key ${key.keyId} of ${sa}: ${del.stderr.trim()}\nYour gcloud account needs iam.serviceAccountKeys.delete (e.g. roles/iam.serviceAccountKeyAdmin). ${keyPath} was left in place so the next run can match it; re-run once fixed.`,
251
+ };
252
+ }
253
+ deletedKeyId = key.keyId;
254
+ lines.push(`Deleted key ${key.keyId} of ${sa} in IAM.`);
255
+ } else {
256
+ lines.push(`Key ${key.keyId} of ${sa} was already deleted in IAM.`);
257
+ }
258
+
259
+ rmSync(keyPath, { force: true });
260
+ lines.push(`Deleted ${keyPath}.`);
261
+
262
+ const others = ids.filter((id) => id !== key.keyId);
263
+ if (others.length > 0) {
264
+ lines.push(
265
+ `${sa} has ${others.length} other user-managed key(s) Caelo did not record, left in place: ${others.join(", ")}.`,
266
+ "Caelo no longer uses keys for this account. If nothing else does, delete them with:",
267
+ ...others.map(
268
+ (id) => ` gcloud iam service-accounts keys delete ${id} --iam-account=${sa} ${project}`,
269
+ ),
270
+ );
271
+ }
272
+ return { ok: true, status: "removed", ...(deletedKeyId ? { deletedKeyId } : {}), lines };
273
+ }
274
+
275
+ /** The key id + account of a JSON key file, without keeping its private key. */
276
+ function readKeyIdentity(
277
+ keyPath: string,
278
+ ): { ok: true; keyId: string; clientEmail: string } | { ok: false; error: string } {
279
+ let parsed: { private_key_id?: unknown; client_email?: unknown };
280
+ try {
281
+ parsed = JSON.parse(readFileSync(keyPath, "utf8")) as typeof parsed;
282
+ } catch {
283
+ return {
284
+ ok: false,
285
+ error: `${keyPath} is not a readable JSON key, so its IAM key can't be matched. Check the keys with \`gcloud iam service-accounts keys list --iam-account=<caelo-provisioner email> --managed-by=user\`, delete the one that file held, remove the file, then re-run.`,
286
+ };
287
+ }
288
+ const keyId = typeof parsed.private_key_id === "string" ? parsed.private_key_id : "";
289
+ const clientEmail = typeof parsed.client_email === "string" ? parsed.client_email : "";
290
+ if (!/^[0-9a-f]{40}$/i.test(keyId) || clientEmail === "") {
291
+ return {
292
+ ok: false,
293
+ error: `${keyPath} has no service-account key id or account, so its IAM key can't be matched. Check the keys with \`gcloud iam service-accounts keys list --iam-account=<caelo-provisioner email> --managed-by=user\`, delete the one that file held, remove the file, then re-run.`,
294
+ };
295
+ }
296
+ return { ok: true, keyId: keyId.toLowerCase(), clientEmail: clientEmail.toLowerCase() };
297
+ }
298
+
299
+ /** Delays between impersonation probes while a fresh TokenCreator grant propagates. */
300
+ export const PROPAGATION_DELAYS_MS: readonly number[] = [
301
+ 5_000, 10_000, 15_000, 30_000, 60_000, 60_000,
302
+ ];
303
+
304
+ /**
305
+ * Prove the operator's gcloud account can impersonate `sa` by having gcloud
306
+ * mint a short-lived token for it. The token on stdout is dropped unread.
307
+ * After a fresh grant, waits out IAM propagation.
308
+ */
309
+ async function verifyGcloudImpersonation(
310
+ run: GcloudRunner,
311
+ sleep: Sleep,
312
+ sa: string,
313
+ freshGrant: boolean,
314
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
315
+ const delays = freshGrant ? PROPAGATION_DELAYS_MS : [];
316
+ const args = ["auth", "print-access-token", `--impersonate-service-account=${sa}`, "--quiet"];
317
+ let r = await run(args);
318
+ for (const delay of delays) {
319
+ if (r.ok) break;
320
+ await sleep(delay);
321
+ r = await run(args);
322
+ }
323
+ if (r.ok) return { ok: true };
324
+ return {
325
+ ok: false,
326
+ error: `Your gcloud account can't impersonate ${sa} yet, so its key was kept: ${r.stderr.trim() || "(no output)"}\nA grant made moments ago can take a few minutes to apply: wait, then re-run. If it persists, check that your account holds ${TOKEN_CREATOR_ROLE} on ${sa} without a condition.`,
327
+ };
328
+ }
329
+
330
+ export type KeylessConvergence =
331
+ | {
332
+ readonly ok: true;
333
+ /** `no-provisioner`: the project has no caelo-provisioner account; nothing to do. */
334
+ readonly grant: "already" | "granted" | "no-provisioner";
335
+ readonly legacyKey: "none" | "removed";
336
+ readonly lines: string[];
337
+ }
338
+ | { readonly ok: false; readonly error: string };
339
+
340
+ /**
341
+ * Bring an install to the keyless provisioner (#696): the operator's gcloud
342
+ * account may impersonate `caelo-provisioner`, and a key an older installer
343
+ * minted is gone, locally and in IAM. The grant comes first, so a run that
344
+ * cannot set it up stops before the key — today's only working credential
345
+ * for older tooling — is deleted. Shared by the installer and `upgrade`;
346
+ * idempotent; never throws for a gcloud failure.
347
+ */
348
+ export async function convergeKeylessProvisioner(
349
+ target: {
350
+ readonly projectId: string;
351
+ readonly secretsDir: string;
352
+ readonly operatorAccount: string;
353
+ /**
354
+ * The installer just created the account: wait out IAM's propagation
355
+ * delay instead of reading a NOT_FOUND as "no provisioner".
356
+ */
357
+ readonly justCreated?: boolean;
358
+ },
359
+ deps: { run?: GcloudRunner; sleep?: Sleep } = {},
360
+ ): Promise<KeylessConvergence> {
361
+ const run = deps.run ?? defaultGcloud;
362
+ const sleep = deps.sleep ?? realSleep;
363
+ const sa = provisionerServiceAccountEmail(target.projectId);
364
+ const keyOnDisk = existsSync(join(target.secretsDir, LEGACY_SA_KEY_FILE));
365
+ const describeArgs = [
366
+ "iam",
367
+ "service-accounts",
368
+ "describe",
369
+ sa,
370
+ `--project=${target.projectId}`,
371
+ "--format=value(email)",
372
+ ];
373
+ const describe = target.justCreated
374
+ ? await runWithRetry(run, sleep, describeArgs)
375
+ : await run(describeArgs);
376
+ if (!describe.ok && !/NOT_FOUND|does not exist|not found/i.test(describe.stderr)) {
377
+ return { ok: false, error: `Could not read ${sa}: ${describe.stderr.trim()}` };
378
+ }
379
+ const exists = describe.ok && describe.stdout.trim().toLowerCase() === sa;
380
+ if (!exists && !keyOnDisk) {
381
+ return { ok: true, grant: "no-provisioner", legacyKey: "none", lines: [] };
382
+ }
383
+ if (!exists) {
384
+ return {
385
+ ok: false,
386
+ error: `${join(target.secretsDir, LEGACY_SA_KEY_FILE)} holds a key for ${sa}, but that service account does not exist in ${target.projectId}. The key is unusable: delete the file, then re-run.`,
387
+ };
388
+ }
389
+
390
+ const grant = await ensureProvisionerImpersonation(
391
+ { projectId: target.projectId, operatorAccount: target.operatorAccount },
392
+ deps,
393
+ );
394
+ if (!grant.ok) return grant;
395
+ // The key is still the install's working credential: delete it only once
396
+ // impersonation demonstrably works, not merely because a binding exists.
397
+ if (keyOnDisk) {
398
+ const works = await verifyGcloudImpersonation(run, sleep, sa, grant.status === "granted");
399
+ if (!works.ok) return works;
400
+ }
401
+ const legacy = await removeLegacyProvisionerKey(
402
+ { projectId: target.projectId, secretsDir: target.secretsDir },
403
+ { run },
404
+ );
405
+ if (!legacy.ok) return legacy;
406
+ return { ok: true, grant: grant.status, legacyKey: legacy.status, lines: legacy.lines };
407
+ }
@@ -0,0 +1,175 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * P15 — per-provider redirect file generators.
5
+ *
6
+ * P14's self-hosted Caddy consumes a `_redirects.caddy` file. Cloud
7
+ * providers want different formats: Cloudflare Pages reads `_redirects`
8
+ * (also a fine fit for any platform that consumes that format),
9
+ * CloudFront wants a JSON config a Lambda@Edge function reads at
10
+ * startup, and Azure Front Door wants a rules-engine RuleEngineRule[].
11
+ *
12
+ * The deploy step calls the right emitter based on `process.env.CAELO_PROVIDER`
13
+ * and uploads the result to the right place. Each emitter is pure +
14
+ * idempotent — same input → same byte output.
15
+ */
16
+
17
+ import { redirectTargetIssue } from "@caelo-cms/shared";
18
+
19
+ export type RedirectStatusCode = 301 | 302 | 307 | 308;
20
+
21
+ export interface RedirectRow {
22
+ readonly fromPath: string;
23
+ readonly toPath: string;
24
+ readonly statusCode: RedirectStatusCode;
25
+ }
26
+
27
+ /**
28
+ * Reject whitespace + control chars in either path before any emitter
29
+ * touches them. The Cloudflare `_redirects` format is space-delimited;
30
+ * a `fromPath` containing a space ambiguates the line (CF parses
31
+ * `/old foo /new 301` as from=`/old`, to=`foo`, status=`/new`, then
32
+ * fails on `301`). Caelo's redirects table normally rejects these
33
+ * upstream but the emitter shouldn't trust that — pure functions
34
+ * defend their own contract.
35
+ */
36
+ function assertPathClean(label: "fromPath" | "toPath", path: string): void {
37
+ // \p{Cc} matches the Unicode control category (U+0000–U+001F + U+007F–U+009F);
38
+ // the linter rejects literal \x00-\x1f ranges in regex, but the intent — defending
39
+ // emitter contracts against control chars — is the explicit reason for this check.
40
+ if (/[\s\p{Cc}]/u.test(path)) {
41
+ throw new Error(
42
+ `redirects-emit: ${label}=${JSON.stringify(path)} contains whitespace or control chars; reject upstream`,
43
+ );
44
+ }
45
+ }
46
+
47
+ function assertRowsClean(rows: ReadonlyArray<RedirectRow>): void {
48
+ for (const r of rows) {
49
+ assertPathClean("fromPath", r.fromPath);
50
+ assertPathClean("toPath", r.toPath);
51
+ // #657 — a `//host` or `/https://…` target would ship as an open
52
+ // redirect or a broken 301 in every CDN format below.
53
+ const targetIssue = redirectTargetIssue(r.toPath);
54
+ if (targetIssue) throw new Error(`redirects-emit: ${targetIssue}`);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Cloudflare Pages `_redirects` format (also consumed by Netlify, Vercel,
60
+ * Render, etc.). One redirect per line: `<from> <to> <status>`. Wildcards
61
+ * use `:splat`; we only support exact-path redirects in v1 (matches what
62
+ * Caelo's redirects table allows).
63
+ */
64
+ export function emitRedirectsCloudflare(rows: ReadonlyArray<RedirectRow>): string {
65
+ assertRowsClean(rows);
66
+ const header = "# Generated by @caelo-cms/provisioning — do not edit by hand.\n";
67
+ const lines = rows.map((r) => `${r.fromPath} ${r.toPath} ${r.statusCode}`);
68
+ return header + lines.join("\n") + (rows.length > 0 ? "\n" : "");
69
+ }
70
+
71
+ /**
72
+ * CloudFront via Lambda@Edge. Returns a JSON config the L@E function
73
+ * reads at startup + a tiny Lambda source that consumes it. The L@E
74
+ * function is deployed to `us-east-1` (CloudFront constraint); the
75
+ * config blob is bundled into the Lambda deployment artifact.
76
+ *
77
+ * Returning both halves lets the AWS stack ship a single asset.
78
+ */
79
+ export interface CloudFrontRedirectArtifact {
80
+ readonly jsonConfig: string;
81
+ readonly lambdaSource: string;
82
+ }
83
+
84
+ export function emitRedirectsCloudFront(
85
+ rows: ReadonlyArray<RedirectRow>,
86
+ ): CloudFrontRedirectArtifact {
87
+ assertRowsClean(rows);
88
+ const jsonConfig = JSON.stringify(
89
+ { redirects: rows.map((r) => ({ from: r.fromPath, to: r.toPath, status: r.statusCode })) },
90
+ null,
91
+ 2,
92
+ );
93
+ // Tiny Lambda@Edge handler. v1 is exact-path match (matches Caelo's
94
+ // redirects table). Wildcard support lands when the table grows it.
95
+ const lambdaSource = `// SPDX-License-Identifier: MPL-2.0
96
+ // Generated by @caelo-cms/provisioning. Bundled with redirects.json.
97
+ const REDIRECTS = require("./redirects.json").redirects;
98
+ const TABLE = new Map(REDIRECTS.map((r) => [r.from, r]));
99
+ exports.handler = (event, _ctx, callback) => {
100
+ const req = event.Records[0].cf.request;
101
+ const m = TABLE.get(req.uri);
102
+ if (!m) return callback(null, req);
103
+ callback(null, {
104
+ status: String(m.status),
105
+ statusDescription: "Redirect",
106
+ headers: { location: [{ key: "Location", value: m.to }] },
107
+ });
108
+ };`;
109
+ return { jsonConfig, lambdaSource };
110
+ }
111
+
112
+ /**
113
+ * Azure Front Door rules engine. Returns a Pulumi-compatible
114
+ * RuleEngineRule[]-shaped array (untyped here so callers don't need to
115
+ * import @pulumi/azure-native). Each rule fires on a path-equals match
116
+ * + executes a 301/302/etc. response.
117
+ */
118
+ export interface FrontDoorRule {
119
+ readonly name: string;
120
+ readonly order: number;
121
+ readonly conditions: ReadonlyArray<{
122
+ readonly name: "RequestUri";
123
+ readonly parameters: {
124
+ readonly operator: "Equal";
125
+ readonly matchValues: ReadonlyArray<string>;
126
+ };
127
+ }>;
128
+ readonly actions: ReadonlyArray<{
129
+ readonly name: "UrlRedirect";
130
+ readonly parameters: {
131
+ readonly redirectType: "Moved" | "Found" | "TemporaryRedirect" | "PermanentRedirect";
132
+ readonly destinationPath: string;
133
+ };
134
+ }>;
135
+ }
136
+
137
+ export function emitRedirectsAzureFrontDoor(
138
+ rows: ReadonlyArray<RedirectRow>,
139
+ ): ReadonlyArray<FrontDoorRule> {
140
+ assertRowsClean(rows);
141
+ return rows.map((r, i) => ({
142
+ name: `redirect-${i + 1}`,
143
+ order: i + 1,
144
+ conditions: [
145
+ {
146
+ name: "RequestUri" as const,
147
+ parameters: { operator: "Equal" as const, matchValues: [r.fromPath] },
148
+ },
149
+ ],
150
+ actions: [
151
+ {
152
+ name: "UrlRedirect" as const,
153
+ parameters: {
154
+ redirectType: statusToFrontDoor(r.statusCode),
155
+ destinationPath: r.toPath,
156
+ },
157
+ },
158
+ ],
159
+ }));
160
+ }
161
+
162
+ function statusToFrontDoor(
163
+ status: RedirectStatusCode,
164
+ ): FrontDoorRule["actions"][0]["parameters"]["redirectType"] {
165
+ switch (status) {
166
+ case 301:
167
+ return "Moved";
168
+ case 302:
169
+ return "Found";
170
+ case 307:
171
+ return "TemporaryRedirect";
172
+ case 308:
173
+ return "PermanentRedirect";
174
+ }
175
+ }