@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,754 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The runtime identities and secrets of a GCP install that the CLI (not
5
+ * Pulumi) creates and maintains, through gcloud:
6
+ *
7
+ * - {@link ensureGatewayServiceAccount}: the gateway's own run SA, so it
8
+ * can be granted only the secrets the gateway reads.
9
+ * - {@link ensureGeneratedSecrets}: the secrets in
10
+ * `CLI_GENERATED_SECRETS` (stack-contract.ts). The wizard runs it before
11
+ * `pulumi up`, upgrade on every roll, so installs that predate a secret
12
+ * get it without operator action. Values are generated here and go to
13
+ * Secret Manager on stdin — never on argv, never to disk.
14
+ * - {@link ensureDatabaseRolePassword}: a database role's password set
15
+ * from its secret (`DATABASE_ROLE_SECRET`), through the Cloud SQL Admin
16
+ * API — how `gateway_role` (created by migration 0248, without a
17
+ * password) and `public_role` (moved off admin_role's password, #613)
18
+ * get theirs.
19
+ * - {@link rotateRuntimeSecret}: `cms-provision rotate-secret`.
20
+ *
21
+ * Every function reports instead of throwing on a gcloud failure.
22
+ */
23
+
24
+ import { randomBytes } from "node:crypto";
25
+ import { gcloud as defaultGcloud } from "./gcloud.js";
26
+ import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
27
+ import { gatewayServiceAccountEmail, gatewayServiceAccountId, gcpSecretId } from "./gcp-names.js";
28
+ import {
29
+ CLI_GENERATED_SECRETS,
30
+ type CloudRunSlug,
31
+ DATABASE_ROLE_SECRET,
32
+ type DatabaseRole,
33
+ type RuntimeSecret,
34
+ SERVICE_SECRET_ENV,
35
+ serviceSecrets,
36
+ } from "./stack-contract.js";
37
+ import { liveContainerEnv } from "./stack-converge.js";
38
+
39
+ export interface RuntimeDeps {
40
+ readonly run?: GcloudRunner;
41
+ readonly sleep?: Sleep;
42
+ /** Generates a new secret value (default: 32 random bytes, hex). */
43
+ readonly generate?: () => string;
44
+ }
45
+
46
+ /** 32 random bytes as hex — the shape of every secret the stacks generate. */
47
+ export function generateSecretValue(): string {
48
+ return randomBytes(32).toString("hex");
49
+ }
50
+
51
+ export interface EnsureOutcome {
52
+ readonly id: string;
53
+ readonly status: "present" | "applied" | "failed";
54
+ readonly error?: string;
55
+ /** How an `applied` outcome differs from the plain case, for the log. */
56
+ readonly detail?: string;
57
+ }
58
+
59
+ const NOT_FOUND = /NOT_FOUND|not found|does not exist/i;
60
+ const ALREADY_EXISTS = /ALREADY_EXISTS|already exists/i;
61
+
62
+ // ===========================================================================
63
+ // Gateway service account
64
+ // ===========================================================================
65
+
66
+ /** Create the gateway's run SA unless it exists. */
67
+ export async function ensureGatewayServiceAccount(
68
+ install: { readonly projectId: string; readonly env: string },
69
+ deps: RuntimeDeps = {},
70
+ ): Promise<EnsureOutcome> {
71
+ const run = deps.run ?? defaultGcloud;
72
+ const email = gatewayServiceAccountEmail(install.projectId, install.env);
73
+ const id = `service account ${email}`;
74
+ const project = `--project=${install.projectId}`;
75
+ const describe = await run([
76
+ "iam",
77
+ "service-accounts",
78
+ "describe",
79
+ email,
80
+ project,
81
+ "--format=value(email)",
82
+ ]);
83
+ if (describe.ok) return { id, status: "present" };
84
+ if (!NOT_FOUND.test(describe.stderr)) {
85
+ return { id, status: "failed", error: describe.stderr.trim() };
86
+ }
87
+ const create = await run([
88
+ "iam",
89
+ "service-accounts",
90
+ "create",
91
+ gatewayServiceAccountId(install.env),
92
+ project,
93
+ `--display-name=Caelo ${install.env} gateway`,
94
+ "--quiet",
95
+ ]);
96
+ if (create.ok || ALREADY_EXISTS.test(create.stderr)) return { id, status: "applied" };
97
+ return { id, status: "failed", error: create.stderr.trim() };
98
+ }
99
+
100
+ // ===========================================================================
101
+ // Generated secrets
102
+ // ===========================================================================
103
+
104
+ /**
105
+ * Values of CLI-generated secrets a service runs with as plain env vars,
106
+ * for {@link ensureGeneratedSecrets}'s `seed`.
107
+ */
108
+ export function plainGeneratedSecretSeed(
109
+ liveEnv: ReadonlyMap<string, { readonly kind: string; readonly value?: string }>,
110
+ ): Partial<Record<(typeof CLI_GENERATED_SECRETS)[number], string>> {
111
+ const seed: Partial<Record<(typeof CLI_GENERATED_SECRETS)[number], string>> = {};
112
+ for (const [name, secret] of Object.entries(SERVICE_SECRET_ENV.admin)) {
113
+ const generated = CLI_GENERATED_SECRETS.find((g) => g === secret);
114
+ const live = liveEnv.get(name);
115
+ if (generated && live?.kind === "value" && live.value) seed[generated] = live.value;
116
+ }
117
+ return seed;
118
+ }
119
+
120
+ /** Where Secret Manager keeps a secret's payload. */
121
+ export type SecretReplication =
122
+ | { readonly kind: "automatic" }
123
+ | { readonly kind: "user-managed"; readonly locations: readonly string[] };
124
+
125
+ /** The stacks' `secretReplication` config value as a {@link SecretReplication}. */
126
+ export function stackSecretReplication(
127
+ mode: "auto" | "regional",
128
+ region: string,
129
+ ): SecretReplication {
130
+ return mode === "auto" ? { kind: "automatic" } : { kind: "user-managed", locations: [region] };
131
+ }
132
+
133
+ /**
134
+ * The replication of the install's existing `postgres-password` secret, so
135
+ * secrets created later match what the stack pinned (orgs with a
136
+ * `gcp.resourceLocations` policy reject `automatic`).
137
+ */
138
+ export async function readSecretReplication(
139
+ install: { readonly projectId: string; readonly env: string },
140
+ deps: RuntimeDeps = {},
141
+ ): Promise<{ ok: true; replication: SecretReplication } | { ok: false; error: string }> {
142
+ const run = deps.run ?? defaultGcloud;
143
+ const secretId = gcpSecretId(install.env, "postgres-password");
144
+ const r = await run([
145
+ "secrets",
146
+ "describe",
147
+ secretId,
148
+ `--project=${install.projectId}`,
149
+ "--format=json",
150
+ ]);
151
+ if (!r.ok) return { ok: false, error: `describe ${secretId}: ${r.stderr.trim()}` };
152
+ const replication = (
153
+ JSON.parse(r.stdout) as {
154
+ replication?: {
155
+ automatic?: unknown;
156
+ userManaged?: { replicas?: { location?: string }[] };
157
+ };
158
+ }
159
+ ).replication;
160
+ if (replication?.automatic) return { ok: true, replication: { kind: "automatic" } };
161
+ const locations = (replication?.userManaged?.replicas ?? [])
162
+ .map((r) => r.location)
163
+ .filter((l): l is string => Boolean(l));
164
+ if (locations.length > 0) {
165
+ return { ok: true, replication: { kind: "user-managed", locations } };
166
+ }
167
+ return { ok: false, error: `${secretId} has no replication policy gcloud reports` };
168
+ }
169
+
170
+ /**
171
+ * gcloud's rejection when an org policy (`constraints/gcp.resourceLocations`)
172
+ * does not allow the location a secret would be created in — for
173
+ * `automatic` replication that is "global".
174
+ */
175
+ const RESOURCE_LOCATIONS_VIOLATION = /gcp\.resourceLocations/;
176
+
177
+ function replicationArgs(replication: SecretReplication): string[] {
178
+ return replication.kind === "automatic"
179
+ ? ["--replication-policy=automatic"]
180
+ : ["--replication-policy=user-managed", `--locations=${replication.locations.join(",")}`];
181
+ }
182
+
183
+ /**
184
+ * Create an empty secret `secretId` with the install's replication (that of
185
+ * its `postgres-password`, {@link readSecretReplication}, or — before the
186
+ * stack created that — regional, as a new stack's are); regional in
187
+ * `region` when an org policy refuses automatic replication. Used for the
188
+ * install job's Owner setup link (#696, install-job.ts). An existing secret
189
+ * counts as created.
190
+ */
191
+ export async function createInstallSecret(
192
+ install: {
193
+ readonly projectId: string;
194
+ readonly env: string;
195
+ readonly region: string;
196
+ readonly secretId: string;
197
+ },
198
+ deps: RuntimeDeps = {},
199
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
200
+ const run = deps.run ?? defaultGcloud;
201
+ // Before the first `pulumi up` (the launcher storing a DNS token) there is
202
+ // no postgres-password yet; a new stack's secrets are regional in the
203
+ // install region (gcp-pulumi.ts resolveSecretReplication), so that is the
204
+ // install's replication.
205
+ const pg = await run([
206
+ "secrets",
207
+ "describe",
208
+ gcpSecretId(install.env, "postgres-password"),
209
+ `--project=${install.projectId}`,
210
+ "--format=value(name)",
211
+ ]);
212
+ const read: { ok: true; replication: SecretReplication } | { ok: false; error: string } =
213
+ !pg.ok && NOT_FOUND.test(pg.stderr)
214
+ ? { ok: true, replication: { kind: "user-managed", locations: [install.region] } }
215
+ : await readSecretReplication(install, { run });
216
+ if (!read.ok) return read;
217
+ const createWith = (replication: SecretReplication) =>
218
+ run([
219
+ "secrets",
220
+ "create",
221
+ install.secretId,
222
+ `--project=${install.projectId}`,
223
+ ...replicationArgs(replication),
224
+ "--quiet",
225
+ ]);
226
+ let create = await createWith(read.replication);
227
+ if (
228
+ !create.ok &&
229
+ read.replication.kind === "automatic" &&
230
+ RESOURCE_LOCATIONS_VIOLATION.test(create.stderr)
231
+ ) {
232
+ create = await createWith({ kind: "user-managed", locations: [install.region] });
233
+ }
234
+ if (create.ok || ALREADY_EXISTS.test(create.stderr)) return { ok: true };
235
+ return { ok: false, error: `create ${install.secretId}: ${create.stderr.trim()}` };
236
+ }
237
+
238
+ /**
239
+ * Create each secret in `CLI_GENERATED_SECRETS` that is missing, and give
240
+ * one that exists without an enabled version a first version. A secret that
241
+ * already has an enabled version is never touched, so re-runs keep values.
242
+ *
243
+ * `seed` carries values a service already runs with as plain env vars (an
244
+ * operator-set `CAELO_INTERNAL_SECRET`): the new secret starts with that
245
+ * value, so moving the var to Secret Manager migrates the credential
246
+ * instead of silently rotating it under callers that sign with it.
247
+ *
248
+ * Replication: a new secret gets the install's `replication`. Installs from
249
+ * before regional replication have `automatic` secrets, and an org policy
250
+ * on `gcp.resourceLocations` added since (or exempting the old secrets)
251
+ * rejects new automatic ones. Then the secret is created user-managed in
252
+ * the install `region` instead — the location every other resource of the
253
+ * install already lives in — and the outcome says so. Only these
254
+ * CLI-generated secrets can differ that way: the stacks read them by id, so
255
+ * their replication is not Pulumi state that a mismatch would replace.
256
+ */
257
+ export async function ensureGeneratedSecrets(
258
+ install: {
259
+ readonly projectId: string;
260
+ readonly env: string;
261
+ readonly region: string;
262
+ readonly replication: SecretReplication;
263
+ readonly seed?: Readonly<Partial<Record<(typeof CLI_GENERATED_SECRETS)[number], string>>>;
264
+ },
265
+ deps: RuntimeDeps = {},
266
+ ): Promise<EnsureOutcome[]> {
267
+ const run = deps.run ?? defaultGcloud;
268
+ const sleep = deps.sleep ?? realSleep;
269
+ const generate = deps.generate ?? generateSecretValue;
270
+ const project = `--project=${install.projectId}`;
271
+ const outcomes: EnsureOutcome[] = [];
272
+ const regional: SecretReplication = { kind: "user-managed", locations: [install.region] };
273
+ // Switches to `regional` after the first policy rejection, so the
274
+ // remaining secrets are not each refused once first.
275
+ let createReplication = install.replication;
276
+
277
+ for (const name of CLI_GENERATED_SECRETS) {
278
+ const secretId = gcpSecretId(install.env, name);
279
+ const id = `secret ${secretId}`;
280
+ const describe = await run(["secrets", "describe", secretId, project, "--format=value(name)"]);
281
+ if (!describe.ok && !NOT_FOUND.test(describe.stderr)) {
282
+ outcomes.push({ id, status: "failed", error: describe.stderr.trim() });
283
+ continue;
284
+ }
285
+ if (!describe.ok) {
286
+ const value = install.seed?.[name] || generate();
287
+ const createWith = (replication: SecretReplication) =>
288
+ run(
289
+ [
290
+ "secrets",
291
+ "create",
292
+ secretId,
293
+ project,
294
+ ...replicationArgs(replication),
295
+ "--data-file=-",
296
+ "--quiet",
297
+ ],
298
+ { stdin: value },
299
+ );
300
+ let create = await createWith(createReplication);
301
+ if (
302
+ !create.ok &&
303
+ createReplication.kind === "automatic" &&
304
+ RESOURCE_LOCATIONS_VIOLATION.test(create.stderr)
305
+ ) {
306
+ createReplication = regional;
307
+ create = await createWith(createReplication);
308
+ }
309
+ if (create.ok) {
310
+ outcomes.push(
311
+ createReplication === install.replication
312
+ ? { id, status: "applied" }
313
+ : {
314
+ id,
315
+ status: "applied",
316
+ detail: `replicated in ${install.region}: the org policy gcp.resourceLocations does not allow automatic (global) replication`,
317
+ },
318
+ );
319
+ continue;
320
+ }
321
+ if (!ALREADY_EXISTS.test(create.stderr)) {
322
+ outcomes.push({ id, status: "failed", error: create.stderr.trim() });
323
+ continue;
324
+ }
325
+ // Created concurrently — fall through to the version check.
326
+ }
327
+ const versions = await runWithRetry(run, sleep, [
328
+ "secrets",
329
+ "versions",
330
+ "list",
331
+ secretId,
332
+ project,
333
+ "--filter=state:ENABLED",
334
+ "--limit=1",
335
+ "--format=value(name)",
336
+ ]);
337
+ if (!versions.ok) {
338
+ outcomes.push({ id, status: "failed", error: versions.stderr.trim() });
339
+ continue;
340
+ }
341
+ if (versions.stdout.trim()) {
342
+ outcomes.push({ id, status: "present" });
343
+ continue;
344
+ }
345
+ const add = await addVersion(run, secretId, project, install.seed?.[name] || generate());
346
+ outcomes.push(add.ok ? { id, status: "applied" } : { id, status: "failed", error: add.error });
347
+ }
348
+ return outcomes;
349
+ }
350
+
351
+ async function addVersion(
352
+ run: GcloudRunner,
353
+ secretId: string,
354
+ project: string,
355
+ value: string,
356
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
357
+ const r = await run(["secrets", "versions", "add", secretId, project, "--data-file=-"], {
358
+ stdin: value,
359
+ });
360
+ return r.ok ? { ok: true } : { ok: false, error: r.stderr.trim() };
361
+ }
362
+
363
+ // ===========================================================================
364
+ // rotate-secret
365
+ // ===========================================================================
366
+
367
+ /** The secrets `rotate-secret` can rotate. */
368
+ export const ROTATABLE_SECRETS = [
369
+ "postgres-password",
370
+ "public-role-password",
371
+ "gateway-role-password",
372
+ "internal-secret",
373
+ "tool-approval-secret",
374
+ // #650 — rotating it signs every staging session out and replaces the
375
+ // staging crawler credentials (they are derived from it).
376
+ "staging-gate-secret",
377
+ ] as const satisfies readonly RuntimeSecret[];
378
+ export type RotatableSecret = (typeof ROTATABLE_SECRETS)[number];
379
+
380
+ /**
381
+ * Why `name` cannot be rotated, or `null` when it can.
382
+ *
383
+ * The KEK is refused: AI provider keys and other at-rest secrets in
384
+ * cms_admin are encrypted under it, and a new KEK without re-encrypting
385
+ * them would make them unreadable.
386
+ */
387
+ export function rotationRefusal(name: string): string | null {
388
+ if ((ROTATABLE_SECRETS as readonly string[]).includes(name)) return null;
389
+ if (name === "secret-kek") {
390
+ return "secret-kek encrypts the AI provider keys and other at-rest secrets in cms_admin; a new KEK would make them unreadable. Rotating it needs a re-encryption step that does not exist yet.";
391
+ }
392
+ return `${name} is not a runtime secret of this install. Rotatable: ${ROTATABLE_SECRETS.join(", ")}.`;
393
+ }
394
+
395
+ /** The deployed install `rotateRuntimeSecret` works on. */
396
+ export interface RotationTarget {
397
+ readonly projectId: string;
398
+ readonly region: string;
399
+ readonly env: string;
400
+ /** Deployed (Pulumi-suffixed) Cloud Run service names. */
401
+ readonly services: Readonly<Record<CloudRunSlug, string>>;
402
+ /** Cloud SQL instance name — needed for `postgres-password`. */
403
+ readonly sqlInstance?: string;
404
+ }
405
+
406
+ export interface RotationReport {
407
+ readonly ok: boolean;
408
+ /** What happened, in order, for the operator. */
409
+ readonly steps: string[];
410
+ readonly error?: string;
411
+ }
412
+
413
+ /** The database roles whose password `secret` is (none for a non-database secret). */
414
+ export function rolesOfSecret(secret: RuntimeSecret): DatabaseRole[] {
415
+ return (Object.entries(DATABASE_ROLE_SECRET) as [DatabaseRole, RuntimeSecret][])
416
+ .filter(([, s]) => s === secret)
417
+ .map(([role]) => role);
418
+ }
419
+
420
+ /**
421
+ * The env vars each reader of `secret` must take from Secret Manager at
422
+ * `latest` for a new revision to pick up a rotated value. An install
423
+ * `upgrade` hasn't moved over still has the password inline in its URLs (a
424
+ * label-only roll would copy it unchanged), and a reference pinned to an
425
+ * older version would keep the old value — rotating either would report
426
+ * success and leave the services on a value the database no longer accepts.
427
+ */
428
+ async function checkRotationReaders(
429
+ run: GcloudRunner,
430
+ target: RotationTarget,
431
+ secret: RotatableSecret,
432
+ ): Promise<string | null> {
433
+ const secretId = gcpSecretId(target.env, secret);
434
+ const isSecret = (live: { kind: string; secret?: string } | undefined) =>
435
+ live?.kind === "secret" &&
436
+ (live.secret === secretId || (live.secret?.endsWith(`/secrets/${secretId}`) ?? false));
437
+ for (const service of ["admin", "gateway"] as const) {
438
+ const vars = Object.entries(SERVICE_SECRET_ENV[service])
439
+ .filter(([, s]) => s === secret)
440
+ .map(([name]) => name);
441
+ const name = target.services[service];
442
+ const describe = await run([
443
+ "run",
444
+ "services",
445
+ "describe",
446
+ name,
447
+ "--region",
448
+ target.region,
449
+ `--project=${target.projectId}`,
450
+ "--format=json",
451
+ ]);
452
+ if (!describe.ok) return `read the ${service} (${name}): ${describe.stderr.trim()}`;
453
+ const env = liveContainerEnv(describe.stdout);
454
+ // A var the contract no longer gives this service but it still reads
455
+ // from the secret (the gateway's ADMIN_DATABASE_PASSWORD before #613):
456
+ // it would not be rolled, so it would keep a password the database
457
+ // stopped accepting.
458
+ const stale = [...env].filter(([v, live]) => !vars.includes(v) && isSecret(live));
459
+ if (stale.length > 0) {
460
+ return `the ${service} (${name}) still reads ${stale.map(([v]) => v).join(", ")} from ${secretId}, which its env contract no longer gives it. Run \`cms-provision upgrade\` first, then rotate. Nothing was changed.`;
461
+ }
462
+ const wrong = vars.filter((v) => {
463
+ const live = env.get(v);
464
+ return (
465
+ live?.kind !== "secret" ||
466
+ !(live.secret === secretId || live.secret.endsWith(`/secrets/${secretId}`)) ||
467
+ live.version !== "latest"
468
+ );
469
+ });
470
+ if (wrong.length > 0) {
471
+ return `the ${service} (${name}) does not read ${wrong.join(", ")} from ${secretId} at latest, so a rotated value would not reach it. Run \`cms-provision upgrade\` first (it moves the services onto Secret Manager references), then rotate. Nothing was changed.`;
472
+ }
473
+ }
474
+ return null;
475
+ }
476
+
477
+ /** Minimal `fetch` the Cloud SQL Admin API calls need (injectable for tests). */
478
+ export type HttpFetch = (
479
+ url: string,
480
+ init: { method: string; headers: Record<string, string>; body?: string },
481
+ ) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>;
482
+
483
+ const SQL_ADMIN = "https://sqladmin.googleapis.com/v1";
484
+ const SQL_OPERATION_POLLS = 60;
485
+
486
+ /**
487
+ * Set a Cloud SQL user's password through the Cloud SQL Admin API, with the
488
+ * password in the request body. `gcloud sql users set-password` only takes
489
+ * it on argv (visible in process listings and exec logs) or from a tty
490
+ * prompt, so it is not used. The bearer token comes from the operator's
491
+ * gcloud session, like every other call here.
492
+ */
493
+ async function setSqlUserPassword(
494
+ run: GcloudRunner,
495
+ http: HttpFetch,
496
+ sleep: Sleep,
497
+ user: { projectId: string; instance: string; role: string; password: string },
498
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
499
+ const token = await run(["auth", "print-access-token"]);
500
+ const bearer = token.ok ? token.stdout.trim() : "";
501
+ if (!bearer) {
502
+ return {
503
+ ok: false,
504
+ error: `gcloud auth print-access-token: ${token.stderr.trim() || "empty"}`,
505
+ };
506
+ }
507
+ const headers = { Authorization: `Bearer ${bearer}`, "Content-Type": "application/json" };
508
+ const project = `${SQL_ADMIN}/projects/${encodeURIComponent(user.projectId)}`;
509
+ const failed = async (what: string, res: { status: number; text(): Promise<string> }) => ({
510
+ ok: false as const,
511
+ error: `${what}: HTTP ${res.status} ${(await res.text()).slice(0, 500)}`,
512
+ });
513
+ const put = await http(
514
+ `${project}/instances/${encodeURIComponent(user.instance)}/users?name=${encodeURIComponent(user.role)}`,
515
+ { method: "PUT", headers, body: JSON.stringify({ name: user.role, password: user.password }) },
516
+ );
517
+ if (!put.ok) return failed("update the user", put);
518
+ type Operation = {
519
+ name?: string;
520
+ status?: string;
521
+ error?: { errors?: { message?: string }[] };
522
+ };
523
+ let op = JSON.parse(await put.text()) as Operation;
524
+ for (let i = 0; op.status !== "DONE" && i < SQL_OPERATION_POLLS; i++) {
525
+ if (!op.name) return { ok: false, error: "the Cloud SQL API returned no operation name" };
526
+ await sleep(2_000);
527
+ const poll = await http(`${project}/operations/${encodeURIComponent(op.name)}`, {
528
+ method: "GET",
529
+ headers,
530
+ });
531
+ if (!poll.ok) return failed(`poll operation ${op.name}`, poll);
532
+ op = JSON.parse(await poll.text()) as Operation;
533
+ }
534
+ if (op.status !== "DONE") {
535
+ return { ok: false, error: `operation ${op.name ?? "?"} did not finish in time` };
536
+ }
537
+ if (op.error) {
538
+ return {
539
+ ok: false,
540
+ error: (op.error.errors ?? []).map((e) => e.message ?? "?").join("; ") || "operation failed",
541
+ };
542
+ }
543
+ return { ok: true };
544
+ }
545
+
546
+ /** The two gcloud calls that roll `name` onto the `latest` version of its secrets. */
547
+ function rollArgs(name: string, region: string, projectId: string): string[][] {
548
+ const location = ["--region", region, `--project=${projectId}`];
549
+ return [
550
+ // A label change copies to the revision template, so it creates a new
551
+ // revision, which resolves `latest` again.
552
+ [
553
+ "run",
554
+ "services",
555
+ "update",
556
+ name,
557
+ ...location,
558
+ `--update-labels=caelo-secret-rotated=${Date.now()}`,
559
+ "--quiet",
560
+ ],
561
+ ["run", "services", "update-traffic", name, ...location, "--to-latest", "--quiet"],
562
+ ];
563
+ }
564
+
565
+ /**
566
+ * Rotate one runtime secret: store a new value as a new Secret Manager
567
+ * version and roll every service that reads it, so new revisions resolve
568
+ * `latest` to the new value.
569
+ *
570
+ * Nothing changes unless every reader takes the secret from Secret Manager
571
+ * at `latest` ({@link checkRotationReaders}).
572
+ *
573
+ * `postgres-password` also changes the password of both database roles,
574
+ * first, so the new version never holds a password the database rejects.
575
+ * The password goes to the Cloud SQL Admin API in a request body, never on
576
+ * argv. If a step before the new version is stored fails, the roles are
577
+ * set back to the previous value. Between the password change and the
578
+ * roll, instances of the old revision keep their open connections but
579
+ * cannot open new ones — the roll follows immediately, and it is attempted
580
+ * for every reader even when one fails, so one failed service never leaves
581
+ * the other behind. A failed roll is resumed with the printed commands
582
+ * (rolling again picks up the stored value; it does not rotate again).
583
+ */
584
+ export async function rotateRuntimeSecret(
585
+ target: RotationTarget,
586
+ secret: RotatableSecret,
587
+ deps: RuntimeDeps & { readonly http?: HttpFetch } = {},
588
+ ): Promise<RotationReport> {
589
+ const run = deps.run ?? defaultGcloud;
590
+ const sleep = deps.sleep ?? realSleep;
591
+ const http = deps.http ?? (fetch as unknown as HttpFetch);
592
+ const generate = deps.generate ?? generateSecretValue;
593
+ const project = `--project=${target.projectId}`;
594
+ const secretId = gcpSecretId(target.env, secret);
595
+ const steps: string[] = [];
596
+
597
+ const notReady = await checkRotationReaders(run, target, secret);
598
+ if (notReady) return { ok: false, steps, error: notReady };
599
+ const value = generate();
600
+
601
+ if (rolesOfSecret(secret).length > 0) {
602
+ if (!target.sqlInstance) {
603
+ return { ok: false, steps, error: "the Cloud SQL instance was not found" };
604
+ }
605
+ const instance = target.sqlInstance;
606
+ const current = await run([
607
+ "secrets",
608
+ "versions",
609
+ "access",
610
+ "latest",
611
+ `--secret=${secretId}`,
612
+ project,
613
+ ]);
614
+ if (!current.ok) {
615
+ return { ok: false, steps, error: `read the current password: ${current.stderr.trim()}` };
616
+ }
617
+ const previous = current.stdout;
618
+ const setPassword = (role: string, password: string) =>
619
+ setSqlUserPassword(run, http, sleep, {
620
+ projectId: target.projectId,
621
+ instance,
622
+ role,
623
+ password,
624
+ });
625
+ const changed: string[] = [];
626
+ const restore = async (): Promise<string> => {
627
+ const failed: string[] = [];
628
+ for (const role of changed) {
629
+ if (!(await setPassword(role, previous)).ok) failed.push(role);
630
+ }
631
+ return failed.length === 0
632
+ ? "the database roles were set back to the previous password"
633
+ : `could NOT set ${failed.join(", ")} back to the previous password — set it to the latest version of ${secretId} by hand`;
634
+ };
635
+ for (const role of rolesOfSecret(secret)) {
636
+ const r = await setPassword(role, value);
637
+ if (!r.ok) {
638
+ const restored = changed.length > 0 ? `; ${await restore()}` : "";
639
+ return {
640
+ ok: false,
641
+ steps,
642
+ error: `set the ${role} password: ${r.error}${restored}`,
643
+ };
644
+ }
645
+ changed.push(role);
646
+ steps.push(`set a new password on database role ${role}`);
647
+ }
648
+ const add = await addVersion(run, secretId, project, value);
649
+ if (!add.ok) {
650
+ return {
651
+ ok: false,
652
+ steps,
653
+ error: `store the new password in ${secretId}: ${add.error}; ${await restore()}`,
654
+ };
655
+ }
656
+ } else {
657
+ const add = await addVersion(run, secretId, project, value);
658
+ if (!add.ok) return { ok: false, steps, error: `add a version to ${secretId}: ${add.error}` };
659
+ }
660
+ steps.push(`added a new version to ${secretId}`);
661
+
662
+ const failures: string[] = [];
663
+ for (const service of ["admin", "gateway"] as const) {
664
+ if (!serviceSecrets(service).includes(secret)) continue;
665
+ const name = target.services[service];
666
+ const [update, flip] = rollArgs(name, target.region, target.projectId) as [string[], string[]];
667
+ const rolled = await run(update);
668
+ const flipped = rolled.ok ? await run(flip) : rolled;
669
+ if (flipped.ok) {
670
+ steps.push(`rolled the ${service} onto the new value`);
671
+ continue;
672
+ }
673
+ failures.push(
674
+ `roll the ${service} (${name}) onto the new value: ${flipped.stderr.trim()}\n resume with:\n gcloud ${update.join(" ")}\n gcloud ${flip.join(" ")}`,
675
+ );
676
+ }
677
+ if (failures.length > 0) {
678
+ return {
679
+ ok: false,
680
+ steps,
681
+ error: `The new value is stored${rolesOfSecret(secret).length > 0 ? " and the database uses it" : ""}, but not every service runs on it yet. Do not rotate again — roll the remaining services onto it:\n${failures.join("\n")}`,
682
+ };
683
+ }
684
+ return { ok: true, steps };
685
+ }
686
+
687
+ // ===========================================================================
688
+ // Database role passwords
689
+ // ===========================================================================
690
+
691
+ /** The deployed install whose database role passwords are set. */
692
+ export interface DatabaseRoleTarget {
693
+ readonly projectId: string;
694
+ readonly env: string;
695
+ /** Cloud SQL instance name. */
696
+ readonly sqlInstance: string;
697
+ }
698
+
699
+ /**
700
+ * The latest value of a runtime secret. Read through gcloud (the operator's
701
+ * session) into memory only; never logged.
702
+ */
703
+ export async function readSecretValue(
704
+ run: GcloudRunner,
705
+ install: { readonly projectId: string; readonly env: string },
706
+ secret: RuntimeSecret,
707
+ ): Promise<{ ok: true; value: string } | { ok: false; error: string }> {
708
+ const secretId = gcpSecretId(install.env, secret);
709
+ const r = await run([
710
+ "secrets",
711
+ "versions",
712
+ "access",
713
+ "latest",
714
+ `--secret=${secretId}`,
715
+ `--project=${install.projectId}`,
716
+ ]);
717
+ if (!r.ok) return { ok: false, error: `read ${secretId}: ${r.stderr.trim()}` };
718
+ if (!r.stdout) return { ok: false, error: `${secretId} has an empty latest version` };
719
+ return { ok: true, value: r.stdout };
720
+ }
721
+
722
+ /**
723
+ * Set `role`'s password to its secret's latest value
724
+ * ({@link DATABASE_ROLE_SECRET}), or to `password` when given (used to put
725
+ * a role back on the value an older revision still uses). Setting the value
726
+ * the role already has is a no-op for every client, so upgrade runs this on
727
+ * every roll and an install converges whatever state it was left in.
728
+ *
729
+ * The role must exist: `gateway_role` is created by migration 0248, so this
730
+ * runs after migrations.
731
+ */
732
+ export async function ensureDatabaseRolePassword(
733
+ target: DatabaseRoleTarget,
734
+ role: DatabaseRole,
735
+ deps: RuntimeDeps & { readonly http?: HttpFetch; readonly password?: string } = {},
736
+ ): Promise<EnsureOutcome> {
737
+ const run = deps.run ?? defaultGcloud;
738
+ const sleep = deps.sleep ?? realSleep;
739
+ const http = deps.http ?? (fetch as unknown as HttpFetch);
740
+ const id = `database role ${role}`;
741
+ let password = deps.password;
742
+ if (password === undefined) {
743
+ const value = await readSecretValue(run, target, DATABASE_ROLE_SECRET[role]);
744
+ if (!value.ok) return { id, status: "failed", error: value.error };
745
+ password = value.value;
746
+ }
747
+ const set = await setSqlUserPassword(run, http, sleep, {
748
+ projectId: target.projectId,
749
+ instance: target.sqlInstance,
750
+ role,
751
+ password,
752
+ });
753
+ return set.ok ? { id, status: "applied" } : { id, status: "failed", error: set.error };
754
+ }