@caelo-cms/provisioning 0.11.10 → 0.11.12

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 (228) hide show
  1. package/dist/adapter.d.ts +6 -0
  2. package/dist/adapter.d.ts.map +1 -1
  3. package/dist/adopt-existing.d.ts +6 -2
  4. package/dist/adopt-existing.d.ts.map +1 -1
  5. package/dist/adopt-existing.js +57 -0
  6. package/dist/adopt-existing.js.map +1 -1
  7. package/dist/agent/command.d.ts +5 -2
  8. package/dist/agent/command.d.ts.map +1 -1
  9. package/dist/agent/command.js +1 -1
  10. package/dist/agent/command.js.map +1 -1
  11. package/dist/agent-db.js +196 -23
  12. package/dist/cli-routing.js +1 -1
  13. package/dist/cli-routing.js.map +1 -1
  14. package/dist/cli.js +59 -7
  15. package/dist/cli.js.map +1 -1
  16. package/dist/cloudsql-tls.d.ts +30 -0
  17. package/dist/cloudsql-tls.d.ts.map +1 -1
  18. package/dist/cloudsql-tls.js +7 -1
  19. package/dist/cloudsql-tls.js.map +1 -1
  20. package/dist/compose.d.ts +14 -0
  21. package/dist/compose.d.ts.map +1 -1
  22. package/dist/compose.js +54 -1
  23. package/dist/compose.js.map +1 -1
  24. package/dist/converge-db-trust.d.ts +44 -0
  25. package/dist/converge-db-trust.d.ts.map +1 -0
  26. package/dist/converge-db-trust.js +158 -0
  27. package/dist/converge-db-trust.js.map +1 -0
  28. package/dist/converge-job-gcp.d.ts +12 -2
  29. package/dist/converge-job-gcp.d.ts.map +1 -1
  30. package/dist/converge-job-gcp.js +42 -3
  31. package/dist/converge-job-gcp.js.map +1 -1
  32. package/dist/converge-job-preflight.d.ts +34 -2
  33. package/dist/converge-job-preflight.d.ts.map +1 -1
  34. package/dist/converge-job-preflight.js +76 -1
  35. package/dist/converge-job-preflight.js.map +1 -1
  36. package/dist/converge-job-setup.d.ts.map +1 -1
  37. package/dist/converge-job-setup.js +4 -1
  38. package/dist/converge-job-setup.js.map +1 -1
  39. package/dist/converge-job.d.ts +44 -3
  40. package/dist/converge-job.d.ts.map +1 -1
  41. package/dist/converge-job.js +105 -11
  42. package/dist/converge-job.js.map +1 -1
  43. package/dist/gcp-names.d.ts +13 -0
  44. package/dist/gcp-names.d.ts.map +1 -1
  45. package/dist/gcp-names.js +21 -0
  46. package/dist/gcp-names.js.map +1 -1
  47. package/dist/index.d.ts +2 -1
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +1 -0
  50. package/dist/index.js.map +1 -1
  51. package/dist/infra-contract.d.ts +1 -1
  52. package/dist/infra-contract.d.ts.map +1 -1
  53. package/dist/infra-contract.js +22 -1
  54. package/dist/infra-contract.js.map +1 -1
  55. package/dist/install-job.d.ts +10 -2
  56. package/dist/install-job.d.ts.map +1 -1
  57. package/dist/install-job.js +10 -1
  58. package/dist/install-job.js.map +1 -1
  59. package/dist/install-state.d.ts +7 -0
  60. package/dist/install-state.d.ts.map +1 -1
  61. package/dist/install-state.js.map +1 -1
  62. package/dist/lifecycle.d.ts.map +1 -1
  63. package/dist/lifecycle.js +3 -0
  64. package/dist/lifecycle.js.map +1 -1
  65. package/dist/mcp-auth-proxy-setup.d.ts +3 -1
  66. package/dist/mcp-auth-proxy-setup.d.ts.map +1 -1
  67. package/dist/mcp-auth-proxy-setup.js +9 -4
  68. package/dist/mcp-auth-proxy-setup.js.map +1 -1
  69. package/dist/migration-runner.d.ts.map +1 -1
  70. package/dist/migration-runner.js +8 -3
  71. package/dist/migration-runner.js.map +1 -1
  72. package/dist/migration-targets.d.ts +11 -0
  73. package/dist/migration-targets.d.ts.map +1 -0
  74. package/dist/migration-targets.js +12 -0
  75. package/dist/migration-targets.js.map +1 -0
  76. package/dist/operator-access.d.ts +10 -0
  77. package/dist/operator-access.d.ts.map +1 -1
  78. package/dist/operator-access.js +5 -0
  79. package/dist/operator-access.js.map +1 -1
  80. package/dist/pool-budget-stack.d.ts +13 -1
  81. package/dist/pool-budget-stack.d.ts.map +1 -1
  82. package/dist/pool-budget-stack.js +11 -1
  83. package/dist/pool-budget-stack.js.map +1 -1
  84. package/dist/public-role-isolation.d.ts +7 -3
  85. package/dist/public-role-isolation.d.ts.map +1 -1
  86. package/dist/public-role-isolation.js +25 -13
  87. package/dist/public-role-isolation.js.map +1 -1
  88. package/dist/release-contract.d.ts.map +1 -1
  89. package/dist/release-contract.js +3 -0
  90. package/dist/release-contract.js.map +1 -1
  91. package/dist/runtime-secrets.d.ts +2 -0
  92. package/dist/runtime-secrets.d.ts.map +1 -1
  93. package/dist/runtime-secrets.js +19 -15
  94. package/dist/runtime-secrets.js.map +1 -1
  95. package/dist/scheduler-setup.d.ts +75 -0
  96. package/dist/scheduler-setup.d.ts.map +1 -0
  97. package/dist/scheduler-setup.js +331 -0
  98. package/dist/scheduler-setup.js.map +1 -0
  99. package/dist/self-hosted-roles.d.ts +16 -9
  100. package/dist/self-hosted-roles.d.ts.map +1 -1
  101. package/dist/self-hosted-roles.js +49 -15
  102. package/dist/self-hosted-roles.js.map +1 -1
  103. package/dist/self-hosted-scheduler-interval.d.ts +21 -0
  104. package/dist/self-hosted-scheduler-interval.d.ts.map +1 -0
  105. package/dist/self-hosted-scheduler-interval.js +68 -0
  106. package/dist/self-hosted-scheduler-interval.js.map +1 -0
  107. package/dist/self-hosted-upgrade.d.ts +7 -4
  108. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  109. package/dist/self-hosted-upgrade.js +9 -5
  110. package/dist/self-hosted-upgrade.js.map +1 -1
  111. package/dist/stack-contract.d.ts +67 -6
  112. package/dist/stack-contract.d.ts.map +1 -1
  113. package/dist/stack-contract.js +87 -6
  114. package/dist/stack-contract.js.map +1 -1
  115. package/dist/staging-database.d.ts +22 -0
  116. package/dist/staging-database.d.ts.map +1 -0
  117. package/dist/staging-database.js +117 -0
  118. package/dist/staging-database.js.map +1 -0
  119. package/dist/upgrade/converge.d.ts +5 -0
  120. package/dist/upgrade/converge.d.ts.map +1 -1
  121. package/dist/upgrade/converge.js +110 -10
  122. package/dist/upgrade/converge.js.map +1 -1
  123. package/dist/upgrade/upgrade.d.ts +6 -0
  124. package/dist/upgrade/upgrade.d.ts.map +1 -1
  125. package/dist/upgrade/upgrade.js +3 -0
  126. package/dist/upgrade/upgrade.js.map +1 -1
  127. package/dist/upgrader-gcp.d.ts +5 -4
  128. package/dist/upgrader-gcp.d.ts.map +1 -1
  129. package/dist/upgrader-gcp.js +5 -4
  130. package/dist/upgrader-gcp.js.map +1 -1
  131. package/dist/upgrader-iam-preflight.d.ts +8 -6
  132. package/dist/upgrader-iam-preflight.d.ts.map +1 -1
  133. package/dist/upgrader-iam-preflight.js +5 -1
  134. package/dist/upgrader-iam-preflight.js.map +1 -1
  135. package/dist/wizard.d.ts +7 -0
  136. package/dist/wizard.d.ts.map +1 -1
  137. package/dist/wizard.js +11 -0
  138. package/dist/wizard.js.map +1 -1
  139. package/dist/wizards/gcp-cost.d.ts +31 -0
  140. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  141. package/dist/wizards/gcp-cost.js +82 -12
  142. package/dist/wizards/gcp-cost.js.map +1 -1
  143. package/dist/wizards/gcp.d.ts.map +1 -1
  144. package/dist/wizards/gcp.js +87 -11
  145. package/dist/wizards/gcp.js.map +1 -1
  146. package/package.json +2 -2
  147. package/src/adapter.ts +6 -0
  148. package/src/admin-memory.test.ts +1 -1
  149. package/src/adopt-existing.test.ts +46 -0
  150. package/src/adopt-existing.ts +58 -2
  151. package/src/agent/command.ts +6 -3
  152. package/src/caddy-redirects.test.ts +1 -1
  153. package/src/cli-routing.test.ts +1 -1
  154. package/src/cli-routing.ts +1 -1
  155. package/src/cli.ts +63 -8
  156. package/src/cloudsql-tls.test.ts +30 -0
  157. package/src/cloudsql-tls.ts +10 -3
  158. package/src/compose.ts +68 -0
  159. package/src/converge-db-trust.test.ts +237 -0
  160. package/src/converge-db-trust.ts +212 -0
  161. package/src/converge-job-gcp.ts +42 -2
  162. package/src/converge-job-preflight.test.ts +106 -0
  163. package/src/converge-job-preflight.ts +97 -3
  164. package/src/converge-job-setup.ts +4 -1
  165. package/src/converge-job.test.ts +213 -0
  166. package/src/converge-job.ts +175 -19
  167. package/src/database-tls-builders.test.ts +3 -2
  168. package/src/deployed-release.test.ts +3 -0
  169. package/src/gateway-env-isolation.test.ts +40 -7
  170. package/src/gcp-names.ts +25 -0
  171. package/src/index.test.ts +3 -3
  172. package/src/index.ts +6 -0
  173. package/src/infra-contract.test.ts +5 -3
  174. package/src/infra-contract.ts +22 -1
  175. package/src/install-job.ts +10 -1
  176. package/src/install-state.ts +7 -0
  177. package/src/lifecycle.ts +3 -0
  178. package/src/mcp-auth-proxy-setup.test.ts +33 -1
  179. package/src/mcp-auth-proxy-setup.ts +11 -4
  180. package/src/migration-runner.ts +14 -9
  181. package/src/migration-targets.ts +12 -0
  182. package/src/operator-access.test.ts +15 -0
  183. package/src/operator-access.ts +14 -0
  184. package/src/pool-budget-stack.ts +15 -0
  185. package/src/pool-budget-stacks.test.ts +38 -13
  186. package/src/public-role-isolation.test.ts +18 -8
  187. package/src/public-role-isolation.ts +31 -17
  188. package/src/release-contract.ts +3 -0
  189. package/src/runtime-secrets.test.ts +18 -8
  190. package/src/runtime-secrets.ts +37 -18
  191. package/src/scheduler-setup.test.ts +201 -0
  192. package/src/scheduler-setup.ts +375 -0
  193. package/src/self-hosted-backup.test.ts +1 -0
  194. package/src/self-hosted-compose.test.ts +72 -1
  195. package/src/self-hosted-roles.integration.test.ts +2 -0
  196. package/src/self-hosted-roles.test.ts +49 -19
  197. package/src/self-hosted-roles.ts +60 -20
  198. package/src/self-hosted-scheduler-interval.test.ts +66 -0
  199. package/src/self-hosted-scheduler-interval.ts +79 -0
  200. package/src/self-hosted-start.test.ts +1 -0
  201. package/src/self-hosted-upgrade.test.ts +2 -1
  202. package/src/self-hosted-upgrade.ts +14 -8
  203. package/src/stack-contract.test.ts +31 -3
  204. package/src/stack-contract.ts +113 -6
  205. package/src/stack-converge-secrets.test.ts +2 -0
  206. package/src/stack-converge.test.ts +6 -4
  207. package/src/staging-database.test.ts +114 -0
  208. package/src/staging-database.ts +148 -0
  209. package/src/upgrade/converge.ts +130 -9
  210. package/src/upgrade/upgrade.test.ts +85 -2
  211. package/src/upgrade/upgrade.ts +9 -0
  212. package/src/upgrader-gcp.ts +5 -4
  213. package/src/upgrader-iam-preflight.ts +15 -10
  214. package/src/wizard.ts +19 -0
  215. package/src/wizards/gcp-cost.test.ts +54 -1
  216. package/src/wizards/gcp-cost.ts +102 -12
  217. package/src/wizards/gcp.ts +109 -8
  218. package/stacks/aws/Pulumi.yaml +1 -1
  219. package/stacks/aws/index.ts +41 -1
  220. package/stacks/azure/index.ts +51 -3
  221. package/stacks/gcp/Pulumi.yaml +1 -1
  222. package/stacks/gcp/README.md +1 -0
  223. package/stacks/gcp/index.ts +50 -1
  224. package/stacks/gcp-firebase/Pulumi.yaml +1 -1
  225. package/stacks/gcp-firebase/index.ts +50 -1
  226. package/stacks/self-hosted/Pulumi.yaml +3 -0
  227. package/stacks/self-hosted/README.md +4 -0
  228. package/stacks/self-hosted/index.ts +29 -4
@@ -0,0 +1,212 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #1373 — where the converge job (#819) opens its database queue, and whom
5
+ * it trusts there, BEFORE the install's database TLS step (#1338) ran.
6
+ *
7
+ * The job writes the update timeline and the request's end through that
8
+ * queue. Its code connects with verify-full only, which needs the
9
+ * instance's DNS name, and on an install whose services still dial the
10
+ * instance's IP the DNS name exists only once the TLS step has run. When
11
+ * the job ran that step before opening the queue, a failure of the step
12
+ * reached only the job's log: Settings → Updates showed "Interrupted" and
13
+ * nothing else. So the job first opens the queue on the host the install
14
+ * dials today and runs the TLS step as a timeline step of its own
15
+ * (converge-job.ts `secureDatabase`).
16
+ *
17
+ * Dialling an IP, the host-name half of verify-full cannot hold (the
18
+ * certificate names no IP). The connection must still authenticate the
19
+ * server: the job's password is the provisioner's access token, worth far
20
+ * more than a database password, so `sslmode=require` (encrypted, server
21
+ * unchecked) is not an option. The trust comes from the Cloud SQL Admin
22
+ * API, over an authenticated channel, about this one instance:
23
+ *
24
+ * - per-instance CA (`GOOGLE_MANAGED_INTERNAL_CA`, the default before
25
+ * #1338): the instance's own server CA (`serverCaCert`). Nobody else
26
+ * holds a certificate from it, so a verified chain is this instance.
27
+ * - shared CA (`GOOGLE_MANAGED_CAS_CA`): always pinned, because the
28
+ * shared CA signs other customers' instances too. With its certificate
29
+ * active: Google's shared-CA bundle (the one every image ships) and the
30
+ * fingerprints `listServerCertificates` names; not rotated in yet: the
31
+ * one certificate the API names active. A listing that does not name
32
+ * the active certificate with a well-formed fingerprint is refused —
33
+ * fail closed, never a guess.
34
+ * - any other CA mode: refused.
35
+ *
36
+ * No input but the metadata server's placement and the APIs: the job's
37
+ * scrubbed-environment contract (§11.C) is unchanged.
38
+ */
39
+
40
+ import { readFileSync } from "node:fs";
41
+ import { CLOUD_SQL_CA_BUNDLE, isIpLiteral } from "@caelo-cms/shared";
42
+ import { z } from "zod";
43
+ import { describeInstance } from "./cloudsql-tls.js";
44
+ import { findSqlInstance } from "./gateway-credentials.js";
45
+ import { gcloud } from "./gcloud.js";
46
+ import type { GcloudRunner } from "./gcloud-retry.js";
47
+ import { GCP_STACK_ENV } from "./gcp-names.js";
48
+ import type { HttpFetch } from "./runtime-secrets.js";
49
+ import { CLOUD_SQL_SERVER_CA_MODE } from "./stack-contract.js";
50
+ import { type UpgraderPlacement, upgraderDatabaseHost } from "./upgrader-gcp.js";
51
+
52
+ /** How the converge job reaches the database. */
53
+ export type ConvergeDatabaseTarget =
54
+ /** The services dial the DNS name already: verify-full, nothing to set up. */
55
+ | { readonly kind: "dns"; readonly host: string }
56
+ /**
57
+ * The services dial the instance's IP: connect there, verify the chain
58
+ * against `caPem` and, when `pinnedSha1` is set, the certificate's
59
+ * fingerprint (lower-case hex, no colons) against it.
60
+ */
61
+ | {
62
+ readonly kind: "ip";
63
+ readonly host: string;
64
+ readonly caPem: string;
65
+ readonly pinnedSha1: readonly string[] | null;
66
+ };
67
+
68
+ export interface ConvergeDatabaseTargetDeps {
69
+ readonly run?: GcloudRunner;
70
+ readonly http?: HttpFetch;
71
+ /** The shipped shared-CA bundle (tests pass their own). */
72
+ readonly readSharedCaBundle?: () => string;
73
+ }
74
+
75
+ /** A SHA-1 fingerprint as hex without separators, lower case (`AB:CD…` and `abcd…` compare equal). */
76
+ export function normalizedSha1(fingerprint: string): string {
77
+ return fingerprint.replace(/[^0-9a-f]/gi, "").toLowerCase();
78
+ }
79
+
80
+ /**
81
+ * The host the install dials today and, when it is the instance's IP, the
82
+ * server identity the converge job's connection must verify there. Throws
83
+ * with what could not be read: without it the job must not connect.
84
+ */
85
+ export async function convergeDatabaseTarget(
86
+ placement: Pick<UpgraderPlacement, "projectId" | "region">,
87
+ deps: ConvergeDatabaseTargetDeps = {},
88
+ ): Promise<ConvergeDatabaseTarget> {
89
+ const run = deps.run ?? gcloud;
90
+ const host = await upgraderDatabaseHost(placement, run);
91
+ if (!isIpLiteral(host)) return { kind: "dns", host };
92
+ const install = { projectId: placement.projectId, env: GCP_STACK_ENV };
93
+ const lookup = await findSqlInstance(install, { run });
94
+ if (lookup.status !== "found") {
95
+ throw new Error(`could not find the install's Cloud SQL instance (${lookup.status})`);
96
+ }
97
+ const target = { ...install, sqlInstance: lookup.name };
98
+ const described = await describeInstance(target, run);
99
+ if (!described.ok) throw new Error(described.error);
100
+ const d = described.d;
101
+ const mode = d.settings?.ipConfiguration?.serverCaMode;
102
+ const ownCa = d.serverCaCert?.cert?.trim() || null;
103
+ if (mode === CLOUD_SQL_SERVER_CA_MODE) {
104
+ const listing = await serverCertificateListing(
105
+ target,
106
+ run,
107
+ deps.http ?? (fetch as unknown as HttpFetch),
108
+ );
109
+ // Fail closed: once the shared CA may be active, every connection is
110
+ // pinned to a certificate the API names — the shared CA alone would
111
+ // also vouch for other customers' instances.
112
+ const sharedBundle = (
113
+ deps.readSharedCaBundle ?? (() => readFileSync(CLOUD_SQL_CA_BUNDLE, "utf8"))
114
+ )();
115
+ if (listing.fingerprints.includes(listing.active)) {
116
+ return { kind: "ip", host, caPem: sharedBundle, pinnedSha1: listing.fingerprints };
117
+ }
118
+ // Moved to the shared CA, its certificate not rotated in yet: the
119
+ // server still presents the active one from its own CA. Pinned to
120
+ // exactly that certificate, so which CA vouches for it does not widen
121
+ // what is accepted.
122
+ const caPem = [ownCa, sharedBundle].filter((c): c is string => Boolean(c)).join("\n");
123
+ return { kind: "ip", host, caPem, pinnedSha1: [listing.active] };
124
+ }
125
+ // Per-instance CA (the default, which older instances do not name):
126
+ // nobody else holds a certificate from it, so the verified chain is this
127
+ // instance. Any other mode (a customer-managed CA) is not one this job
128
+ // can reason about.
129
+ if (mode !== undefined && mode !== PER_INSTANCE_CA_MODE) {
130
+ throw new Error(
131
+ `${lookup.name} uses the server CA mode ${mode}, which the converge job cannot verify a connection to ${host} for`,
132
+ );
133
+ }
134
+ if (!ownCa) {
135
+ throw new Error(
136
+ `${lookup.name} names no server CA certificate (serverCaCert), so the connection to ${host} could not be verified`,
137
+ );
138
+ }
139
+ return { kind: "ip", host, caPem: ownCa, pinnedSha1: null };
140
+ }
141
+
142
+ /** Cloud SQL's default per-instance server CA mode. */
143
+ const PER_INSTANCE_CA_MODE = "GOOGLE_MANAGED_INTERNAL_CA";
144
+
145
+ const SHA1_HEX = /^[0-9a-f]{40}$/;
146
+
147
+ /**
148
+ * The Admin API's `listServerCertificates` answer, validated: the active
149
+ * certificate's fingerprint and every listed one, normalized. Throws on
150
+ * anything else — a missing `activeVersion`, a certificate without a
151
+ * well-formed fingerprint — since a guessed listing would be a guessed
152
+ * trust decision.
153
+ */
154
+ async function serverCertificateListing(
155
+ t: { readonly projectId: string; readonly sqlInstance: string },
156
+ run: GcloudRunner,
157
+ http: HttpFetch,
158
+ ): Promise<{ readonly active: string; readonly fingerprints: readonly string[] }> {
159
+ const token = await run(["auth", "print-access-token"]);
160
+ const bearer = token.ok ? token.stdout.trim() : "";
161
+ if (!bearer) throw new Error(`gcloud auth print-access-token: ${token.stderr.trim()}`);
162
+ const res = await http(
163
+ `https://sqladmin.googleapis.com/v1/projects/${encodeURIComponent(t.projectId)}/instances/${encodeURIComponent(t.sqlInstance)}/listServerCertificates`,
164
+ { method: "GET", headers: { Authorization: `Bearer ${bearer}` } },
165
+ );
166
+ const text = await res.text();
167
+ if (!res.ok) {
168
+ throw new Error(`list server certificates: HTTP ${res.status} ${text.slice(0, 300)}`);
169
+ }
170
+ let body: unknown;
171
+ try {
172
+ body = JSON.parse(text);
173
+ } catch {
174
+ throw new Error("list server certificates: the Cloud SQL Admin API answered no JSON");
175
+ }
176
+ const parsed = ListingSchema.safeParse(body);
177
+ if (!parsed.success) {
178
+ throw new Error(
179
+ `list server certificates: the answer does not name the active certificate and the listed ones with SHA-1 fingerprints (${parsed.error.issues[0]?.message ?? "invalid"}), so the connection is not trusted`,
180
+ );
181
+ }
182
+ return {
183
+ active: parsed.data.activeVersion,
184
+ fingerprints: parsed.data.serverCerts.map((c) => c.sha1Fingerprint),
185
+ };
186
+ }
187
+
188
+ const Sha1 = z
189
+ .string()
190
+ .transform(normalizedSha1)
191
+ .refine((f) => SHA1_HEX.test(f), "a SHA-1 fingerprint");
192
+
193
+ const ListingSchema = z.object({
194
+ activeVersion: Sha1,
195
+ // Before the first shared-CA certificate exists the API may list none.
196
+ serverCerts: z.array(z.object({ sha1Fingerprint: Sha1 })).default([]),
197
+ });
198
+
199
+ /**
200
+ * Why a server certificate with `fingerprint` (as `node:tls` prints it) is
201
+ * not the instance `target` names, or null when it is. Only for an `ip`
202
+ * target; the chain itself is checked against `caPem` by the TLS layer.
203
+ */
204
+ export function serverIdentityProblem(
205
+ target: Extract<ConvergeDatabaseTarget, { kind: "ip" }>,
206
+ fingerprint: string,
207
+ ): string | null {
208
+ if (target.pinnedSha1 === null) return null;
209
+ return target.pinnedSha1.includes(normalizedSha1(fingerprint))
210
+ ? null
211
+ : `the database at ${target.host} presented a certificate (SHA-1 ${normalizedSha1(fingerprint)}) that the Cloud SQL Admin API does not list for this install's instance`;
212
+ }
@@ -22,9 +22,10 @@ import { join } from "node:path";
22
22
  import { CAELO_VERSION } from "@caelo-cms/shared";
23
23
  import { readConvergeCheckpoint, writeConvergeCheckpoint } from "./converge-checkpoint.js";
24
24
  import type { ConvergeJobDeps, ConvergeQueue, ConvergeUpgradeOutcome } from "./converge-job.js";
25
+ import { isSelfGrantableRole } from "./converge-job-preflight.js";
25
26
  import { CONVERGE_TASK_TIMEOUT_MINUTES } from "./converge-job-setup.js";
26
27
  import { gcloud } from "./gcloud.js";
27
- import { type GcloudRunner, realSleep } from "./gcloud-retry.js";
28
+ import { type GcloudRunner, realSleep, runWithRetry } from "./gcloud-retry.js";
28
29
  import {
29
30
  convergeJobName,
30
31
  GCP_STACK_ENV,
@@ -42,6 +43,7 @@ import {
42
43
  } from "./install-state.js";
43
44
  import { runMigrationsViaCloudRunJob } from "./migration-runner.js";
44
45
  import { installIapAllowlist } from "./operator-access.js";
46
+ import { provisionerServiceAccountEmail } from "./provisioner-identity.js";
45
47
  import { fetchVerifiedReleaseManifest, manifestServiceImages } from "./release-manifest.js";
46
48
  import type { HttpFetch } from "./runtime-secrets.js";
47
49
  import { publicSiteUrl, STAGING_HOST_ENV } from "./stack-contract.js";
@@ -203,10 +205,34 @@ export function startedExecutionName(stdout: string, stderr: string): string | n
203
205
  return /^[a-z0-9-]+$/.test(name) ? name : null;
204
206
  }
205
207
 
206
- /** Every dependency of {@link runConvergeJob} except the database and the log. */
208
+ /**
209
+ * The `gcloud` argv that grants the install's provisioner — this job's
210
+ * own identity, never another principal — `role` on the project, or null
211
+ * when `role` is not on the self-grant allowlist (converge-job-preflight.ts).
212
+ * Exported for the tests that pin the boundary.
213
+ */
214
+ export function selfGrantArgs(projectId: string, role: string): string[] | null {
215
+ if (!isSelfGrantableRole(role)) return null;
216
+ return [
217
+ "projects",
218
+ "add-iam-policy-binding",
219
+ projectId,
220
+ `--member=serviceAccount:${provisionerServiceAccountEmail(projectId)}`,
221
+ `--role=${role}`,
222
+ "--condition=None",
223
+ "--quiet",
224
+ "--format=none",
225
+ ];
226
+ }
227
+
228
+ /**
229
+ * Every dependency of {@link runConvergeJob} except the database (the queue
230
+ * and how it is secured, #1373) and the log.
231
+ */
207
232
  export function gcpConvergeDeps(
208
233
  placement: UpgraderPlacement,
209
234
  queue: ConvergeQueue,
235
+ secureDatabase: ConvergeJobDeps["secureDatabase"],
210
236
  log: JobLog,
211
237
  ): ConvergeJobDeps {
212
238
  const run: GcloudRunner = gcloud;
@@ -300,6 +326,9 @@ export function gcpConvergeDeps(
300
326
  domain: meta.domain,
301
327
  stagingHost: installStagingHost(meta),
302
328
  ownerEmail: meta.ownerEmail,
329
+ ...(meta.schedulerIntervalMinutes !== undefined
330
+ ? { schedulerIntervalMinutes: meta.schedulerIntervalMinutes }
331
+ : {}),
303
332
  targetTag: m.version,
304
333
  // This code is the target release's (stage 2), so its contract is it.
305
334
  infraContractVersion: INFRA_CONTRACT_VERSION,
@@ -355,9 +384,20 @@ export function gcpConvergeDeps(
355
384
 
356
385
  return {
357
386
  queue,
387
+ secureDatabase,
358
388
  projectId,
359
389
  region,
360
390
  iam: gcloudIamTester(run, http),
391
+ grantOwnRole: async (role) => {
392
+ const args = selfGrantArgs(projectId, role);
393
+ if (!args)
394
+ return { ok: false, error: `${role} is not a role the converge job may grant itself` };
395
+ const r = await runWithRetry(run, realSleep, args);
396
+ return r.ok
397
+ ? { ok: true }
398
+ : { ok: false, error: r.stderr.trim() || `gcloud exited ${r.exitCode}` };
399
+ },
400
+ sleep: realSleep,
361
401
  execution: placement.execution,
362
402
  ownVersion: CAELO_VERSION,
363
403
  // The default deps: the fixed registry and signer, never overridden.
@@ -7,10 +7,15 @@
7
7
  */
8
8
 
9
9
  import { describe, expect, it } from "bun:test";
10
+ import { selfGrantArgs } from "./converge-job-gcp.js";
10
11
  import {
11
12
  CONVERGE_PREFLIGHT_VOICE,
13
+ CONVERGE_SELF_GRANTABLE_ROLES,
12
14
  convergeAccessChecks,
13
15
  INSTALL_ROLE_USES,
16
+ isSelfGrantableRole,
17
+ SELF_GRANT_FORBIDDEN_ROLES,
18
+ selfGrantPlan,
14
19
  } from "./converge-job-preflight.js";
15
20
  import { PROVISIONER_ROLE_LIST } from "./gcloud.js";
16
21
  import { INSTALL_JOB_ROLES } from "./install-job.js";
@@ -43,6 +48,11 @@ const PREDEFINED: Record<string, readonly string[]> = {
43
48
  "cloudscheduler.jobs.get",
44
49
  "cloudscheduler.jobs.update",
45
50
  ],
51
+ "roles/workflows.editor": [
52
+ "workflows.workflows.create",
53
+ "workflows.workflows.get",
54
+ "workflows.workflows.update",
55
+ ],
46
56
  "roles/logging.logWriter": ["logging.logEntries.create"],
47
57
  };
48
58
 
@@ -74,6 +84,7 @@ describe("convergeAccessChecks", () => {
74
84
  for (const role of INSTALL_JOB_ROLES) {
75
85
  expect(checks).toContainEqual({
76
86
  resource: { kind: "project", projectId: "acme" },
87
+ role,
77
88
  ...INSTALL_ROLE_USES[role],
78
89
  });
79
90
  }
@@ -106,3 +117,98 @@ describe("convergeAccessChecks", () => {
106
117
  expect(reason).toContain("Cloud Shell or the computer you installed from");
107
118
  });
108
119
  });
120
+
121
+ describe("the converge job's self-grant allowlist (2026-11-03)", () => {
122
+ it("is exactly the roles converge-job-setup.ts grants the provisioner", () => {
123
+ expect([...CONVERGE_SELF_GRANTABLE_ROLES]).toEqual([...INSTALL_JOB_ROLES]);
124
+ expect(CONVERGE_SELF_GRANTABLE_ROLES).toContain("roles/workflows.editor");
125
+ });
126
+
127
+ it("names no ownership or IAM-policy role", () => {
128
+ for (const role of CONVERGE_SELF_GRANTABLE_ROLES) {
129
+ for (const f of SELF_GRANT_FORBIDDEN_ROLES) {
130
+ expect(role === f || (f.endsWith(".") && role.startsWith(f))).toBe(false);
131
+ }
132
+ }
133
+ });
134
+
135
+ it("refuses every role outside the list, escalations first", () => {
136
+ for (const role of [
137
+ "roles/owner",
138
+ "roles/editor",
139
+ "roles/resourcemanager.projectIamAdmin",
140
+ "roles/iam.securityAdmin",
141
+ "roles/iam.serviceAccountKeyAdmin",
142
+ "roles/run.admin",
143
+ "roles/workflows.admin",
144
+ "roles/workflows.editor ",
145
+ "",
146
+ ]) {
147
+ expect(isSelfGrantableRole(role)).toBe(false);
148
+ }
149
+ for (const role of INSTALL_JOB_ROLES) expect(isSelfGrantableRole(role)).toBe(true);
150
+ });
151
+
152
+ it("the grant names the project and the provisioner only, and refuses other roles", () => {
153
+ expect(selfGrantArgs("acme", "roles/workflows.editor")).toEqual([
154
+ "projects",
155
+ "add-iam-policy-binding",
156
+ "acme",
157
+ "--member=serviceAccount:caelo-provisioner@acme.iam.gserviceaccount.com",
158
+ "--role=roles/workflows.editor",
159
+ "--condition=None",
160
+ "--quiet",
161
+ "--format=none",
162
+ ]);
163
+ for (const role of [
164
+ "roles/owner",
165
+ "roles/resourcemanager.projectIamAdmin",
166
+ "roles/run.admin",
167
+ ]) {
168
+ expect(selfGrantArgs("acme", role)).toBeNull();
169
+ }
170
+ });
171
+ });
172
+
173
+ describe("selfGrantPlan", () => {
174
+ it("an install from before 0.11.11 (no workflows.editor) needs one grant and nothing blocks", async () => {
175
+ const r = await runIamPreflight(
176
+ convergeAccessChecks(TARGET),
177
+ provisioner([
178
+ ...PROVISIONER_ROLE_LIST,
179
+ ...INSTALL_JOB_ROLES.filter((role) => role !== "roles/workflows.editor"),
180
+ ]),
181
+ );
182
+ expect(r.kind).toBe("missing");
183
+ if (r.kind !== "missing") return;
184
+ expect(selfGrantPlan(r.missing)).toEqual({ grant: ["roles/workflows.editor"], blocking: [] });
185
+ });
186
+
187
+ it("every install-job role missing: all of them, in list order", async () => {
188
+ const r = await runIamPreflight(
189
+ convergeAccessChecks(TARGET),
190
+ provisioner(PROVISIONER_ROLE_LIST),
191
+ );
192
+ if (r.kind !== "missing") throw new Error("expected missing");
193
+ const plan = selfGrantPlan([...r.missing].reverse());
194
+ expect(plan.grant).toEqual([...INSTALL_JOB_ROLES]);
195
+ expect(plan.blocking).toEqual([]);
196
+ });
197
+
198
+ it("a check without a role, or with a role off the list, blocks", () => {
199
+ const actAs = convergeAccessChecks(TARGET).find((c) =>
200
+ c.permissions.includes("iam.serviceAccounts.actAs"),
201
+ );
202
+ if (!actAs) throw new Error("no actAs check");
203
+ const forged = {
204
+ ...actAs,
205
+ role: "roles/owner" as unknown as (typeof INSTALL_JOB_ROLES)[number],
206
+ };
207
+ const plan = selfGrantPlan([
208
+ { check: actAs, missing: ["iam.serviceAccounts.actAs"] },
209
+ { check: forged, missing: ["iam.serviceAccounts.actAs"] },
210
+ ]);
211
+ expect(plan.grant).toEqual([]);
212
+ expect(plan.blocking).toHaveLength(2);
213
+ });
214
+ });
@@ -26,6 +26,23 @@
26
26
  * - the Cloud SQL IAM login `ensureConvergeJob` creates (member of
27
27
  * {@link UPGRADER_DB_ROLE}): the request row and the timeline.
28
28
  *
29
+ * Self-grant (maintainer decision 2026-11-03). A release may add a role to
30
+ * {@link INSTALL_JOB_ROLES} (0.11.11 added roles/workflows.editor for the
31
+ * scheduler tick, #1140); an install set up before it lacks that role until
32
+ * a converge's `ensureConvergeJob` grants it, and the converge job used to
33
+ * stop here and send the Owner to Cloud Shell for exactly that grant. Now a
34
+ * missing role of {@link CONVERGE_SELF_GRANTABLE_ROLES} — this release's own
35
+ * compiled-in list, nothing a request, a record or the environment names —
36
+ * is not a reason to stop: stage 1 leaves it to stage 2, and stage 2 (the
37
+ * target release's verified ops image, under the state lock) grants it to
38
+ * the provisioner at project level, records each grant on the timeline and
39
+ * asks IAM again (converge-job.ts). Not an escalation: the provisioner holds
40
+ * roles/resourcemanager.projectIamAdmin since install, so it could grant
41
+ * itself any role already; the allowlist only narrows what the job does
42
+ * with that. {@link SELF_GRANT_FORBIDDEN_ROLES} pins that the list never
43
+ * names an ownership or IAM-policy role, and the upgrader job — which holds
44
+ * no IAM rights at all — has no grant step.
45
+ *
29
46
  * Not checked: the permissions `pulumi up` needs for the stack. They are the
30
47
  * provisioner's PROVISIONER_ROLE_LIST (gcloud.ts), granted at install by the
31
48
  * Owner's own account; a converge cannot repair them and Pulumi names what
@@ -37,7 +54,7 @@ import { convergeJobName } from "./gcp-names.js";
37
54
  import { INSTALL_JOB_ROLES, type InstallJobRole } from "./install-job.js";
38
55
  import { provisionerServiceAccountEmail } from "./provisioner-identity.js";
39
56
  import { OPS_INSTALL_PREFIX, opsBucketName } from "./state-backend.js";
40
- import type { AccessCheck, PreflightVoice } from "./upgrader-iam-preflight.js";
57
+ import type { AccessCheck, MissingAccess, PreflightVoice } from "./upgrader-iam-preflight.js";
41
58
  import { UPGRADER_DB_ROLE } from "./upgrader-setup.js";
42
59
 
43
60
  /**
@@ -62,6 +79,14 @@ export const INSTALL_ROLE_USES: Record<
62
79
  "cloudscheduler.jobs.update",
63
80
  ],
64
81
  },
82
+ "roles/workflows.editor": {
83
+ need: "deploy and update the workflow the scheduler tick runs (#1140)",
84
+ permissions: [
85
+ "workflows.workflows.create",
86
+ "workflows.workflows.get",
87
+ "workflows.workflows.update",
88
+ ],
89
+ },
65
90
  "roles/logging.logWriter": {
66
91
  need: "write the update's progress to Cloud Logging",
67
92
  permissions: ["logging.logEntries.create"],
@@ -75,17 +100,86 @@ export const CONVERGE_PREFLIGHT_VOICE: PreflightVoice = {
75
100
  "Run the update once from Cloud Shell or the computer you installed from (Settings → Updates shows the command); it repairs the converge job's access.",
76
101
  };
77
102
 
103
+ /**
104
+ * The roles the converge job may grant its own account when the self-check
105
+ * finds them missing: exactly the roles `ensureConvergeJob` grants it, as
106
+ * compiled into this release (see the file comment).
107
+ */
108
+ export const CONVERGE_SELF_GRANTABLE_ROLES: readonly InstallJobRole[] = INSTALL_JOB_ROLES;
109
+
110
+ /**
111
+ * Roles the converge job must never grant itself, whatever a release's list
112
+ * says — ownership and IAM-policy roles (prefix matches). The tests assert
113
+ * {@link CONVERGE_SELF_GRANTABLE_ROLES} names none of them, and the grant
114
+ * step refuses them again at run time.
115
+ */
116
+ export const SELF_GRANT_FORBIDDEN_ROLES = [
117
+ "roles/owner",
118
+ "roles/editor",
119
+ "roles/resourcemanager.",
120
+ "roles/iam.securityAdmin",
121
+ "roles/iam.organizationRoleAdmin",
122
+ "roles/iam.serviceAccountKeyAdmin",
123
+ "roles/iam.workloadIdentityPoolAdmin",
124
+ "roles/orgpolicy.",
125
+ "roles/billing.",
126
+ ] as const;
127
+
128
+ /** Whether the converge job may grant `role` to its own account (see the file comment). */
129
+ export function isSelfGrantableRole(role: string): role is InstallJobRole {
130
+ return (
131
+ (CONVERGE_SELF_GRANTABLE_ROLES as readonly string[]).includes(role) &&
132
+ !SELF_GRANT_FORBIDDEN_ROLES.some((f) => role === f || (f.endsWith(".") && role.startsWith(f)))
133
+ );
134
+ }
135
+
136
+ /** A converge self-check; `role` marks a check that granting that role repairs. */
137
+ export interface ConvergeAccessCheck extends AccessCheck {
138
+ readonly role?: InstallJobRole;
139
+ }
140
+
141
+ /** What the converge job does about the self-check's missing permissions. */
142
+ export interface SelfGrantPlan {
143
+ /** Allowlisted roles to grant the provisioner, in list order. */
144
+ readonly grant: readonly InstallJobRole[];
145
+ /** What no grant of the job repairs: the run stops on any of these. */
146
+ readonly blocking: readonly MissingAccess<ConvergeAccessCheck>[];
147
+ }
148
+
149
+ /**
150
+ * Split the self-check's missing permissions into roles the job grants
151
+ * itself and the rest. A check counts as repairable only when its whole
152
+ * shortfall is permissions of an allowlisted role it is marked with.
153
+ */
154
+ export function selfGrantPlan(
155
+ missing: readonly MissingAccess<ConvergeAccessCheck>[],
156
+ ): SelfGrantPlan {
157
+ const grant: InstallJobRole[] = [];
158
+ const blocking: MissingAccess<ConvergeAccessCheck>[] = [];
159
+ for (const m of missing) {
160
+ const role = m.check.role;
161
+ if (role && isSelfGrantableRole(role) && !(m.unchecked && m.unchecked.length > 0)) {
162
+ if (!grant.includes(role)) grant.push(role);
163
+ } else {
164
+ blocking.push(m);
165
+ }
166
+ }
167
+ const order = CONVERGE_SELF_GRANTABLE_ROLES as readonly string[];
168
+ grant.sort((a, b) => order.indexOf(a) - order.indexOf(b));
169
+ return { grant, blocking };
170
+ }
171
+
78
172
  /** Every permission a converge run relies on after its decision (see the file comment). */
79
173
  export function convergeAccessChecks(target: {
80
174
  readonly projectId: string;
81
175
  readonly region: string;
82
176
  readonly env: string;
83
- }): AccessCheck[] {
177
+ }): ConvergeAccessCheck[] {
84
178
  const { projectId, region, env } = target;
85
179
  const project = { kind: "project", projectId } as const;
86
180
  const bucket = opsBucketName(projectId);
87
181
  return [
88
- ...INSTALL_JOB_ROLES.map((role) => ({ resource: project, ...INSTALL_ROLE_USES[role] })),
182
+ ...INSTALL_JOB_ROLES.map((role) => ({ resource: project, role, ...INSTALL_ROLE_USES[role] })),
89
183
  {
90
184
  need: `read and write the install's records and state lock (${OPS_INSTALL_PREFIX}/) and the update's checkpoint (${CONVERGE_STATE_PREFIX}/) in the ops bucket`,
91
185
  resource: { kind: "bucket", bucket },
@@ -41,7 +41,10 @@
41
41
  * - {@link INSTALL_JOB_ROLES} (the operator-access step of a converge
42
42
  * creates a custom role and a Cloud Scheduler job, and the job's lines
43
43
  * must reach Cloud Logging) — what an install-job install already
44
- * grants; not an escalation, the provisioner holds projectIamAdmin;
44
+ * grants; not an escalation, the provisioner holds projectIamAdmin. The
45
+ * converge job of a release that adds a role here grants it itself when
46
+ * it finds it missing (converge-job-preflight.ts, the self-grant), so
47
+ * the role reaches an older install without a terminal run;
45
48
  * - a Cloud SQL IAM database login that is a member of `platform_updater`
46
49
  * only (migration 0267): it reads its request and writes the timeline,
47
50
  * nothing else in the database.