@caelo-cms/provisioning 0.11.10 → 0.11.11

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 (212) 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 +25 -8
  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 +5 -2
  29. package/dist/converge-job-gcp.d.ts.map +1 -1
  30. package/dist/converge-job-gcp.js +9 -2
  31. package/dist/converge-job-gcp.js.map +1 -1
  32. package/dist/converge-job-preflight.d.ts.map +1 -1
  33. package/dist/converge-job-preflight.js +8 -0
  34. package/dist/converge-job-preflight.js.map +1 -1
  35. package/dist/converge-job.d.ts +17 -0
  36. package/dist/converge-job.d.ts.map +1 -1
  37. package/dist/converge-job.js +12 -0
  38. package/dist/converge-job.js.map +1 -1
  39. package/dist/gcp-names.d.ts +13 -0
  40. package/dist/gcp-names.d.ts.map +1 -1
  41. package/dist/gcp-names.js +21 -0
  42. package/dist/gcp-names.js.map +1 -1
  43. package/dist/index.d.ts +2 -1
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +1 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/infra-contract.d.ts +1 -1
  48. package/dist/infra-contract.d.ts.map +1 -1
  49. package/dist/infra-contract.js +22 -1
  50. package/dist/infra-contract.js.map +1 -1
  51. package/dist/install-job.d.ts +4 -2
  52. package/dist/install-job.d.ts.map +1 -1
  53. package/dist/install-job.js +4 -1
  54. package/dist/install-job.js.map +1 -1
  55. package/dist/install-state.d.ts +7 -0
  56. package/dist/install-state.d.ts.map +1 -1
  57. package/dist/install-state.js.map +1 -1
  58. package/dist/lifecycle.d.ts.map +1 -1
  59. package/dist/lifecycle.js +3 -0
  60. package/dist/lifecycle.js.map +1 -1
  61. package/dist/migration-runner.d.ts.map +1 -1
  62. package/dist/migration-runner.js +8 -3
  63. package/dist/migration-runner.js.map +1 -1
  64. package/dist/migration-targets.d.ts +11 -0
  65. package/dist/migration-targets.d.ts.map +1 -0
  66. package/dist/migration-targets.js +12 -0
  67. package/dist/migration-targets.js.map +1 -0
  68. package/dist/operator-access.d.ts +10 -0
  69. package/dist/operator-access.d.ts.map +1 -1
  70. package/dist/operator-access.js +5 -0
  71. package/dist/operator-access.js.map +1 -1
  72. package/dist/pool-budget-stack.d.ts +13 -1
  73. package/dist/pool-budget-stack.d.ts.map +1 -1
  74. package/dist/pool-budget-stack.js +11 -1
  75. package/dist/pool-budget-stack.js.map +1 -1
  76. package/dist/public-role-isolation.d.ts +7 -3
  77. package/dist/public-role-isolation.d.ts.map +1 -1
  78. package/dist/public-role-isolation.js +25 -13
  79. package/dist/public-role-isolation.js.map +1 -1
  80. package/dist/release-contract.d.ts.map +1 -1
  81. package/dist/release-contract.js +3 -0
  82. package/dist/release-contract.js.map +1 -1
  83. package/dist/runtime-secrets.d.ts +2 -0
  84. package/dist/runtime-secrets.d.ts.map +1 -1
  85. package/dist/runtime-secrets.js +19 -15
  86. package/dist/runtime-secrets.js.map +1 -1
  87. package/dist/scheduler-setup.d.ts +75 -0
  88. package/dist/scheduler-setup.d.ts.map +1 -0
  89. package/dist/scheduler-setup.js +331 -0
  90. package/dist/scheduler-setup.js.map +1 -0
  91. package/dist/self-hosted-roles.d.ts +16 -9
  92. package/dist/self-hosted-roles.d.ts.map +1 -1
  93. package/dist/self-hosted-roles.js +49 -15
  94. package/dist/self-hosted-roles.js.map +1 -1
  95. package/dist/self-hosted-scheduler-interval.d.ts +21 -0
  96. package/dist/self-hosted-scheduler-interval.d.ts.map +1 -0
  97. package/dist/self-hosted-scheduler-interval.js +68 -0
  98. package/dist/self-hosted-scheduler-interval.js.map +1 -0
  99. package/dist/self-hosted-upgrade.d.ts +7 -4
  100. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  101. package/dist/self-hosted-upgrade.js +9 -5
  102. package/dist/self-hosted-upgrade.js.map +1 -1
  103. package/dist/stack-contract.d.ts +67 -6
  104. package/dist/stack-contract.d.ts.map +1 -1
  105. package/dist/stack-contract.js +87 -6
  106. package/dist/stack-contract.js.map +1 -1
  107. package/dist/staging-database.d.ts +22 -0
  108. package/dist/staging-database.d.ts.map +1 -0
  109. package/dist/staging-database.js +117 -0
  110. package/dist/staging-database.js.map +1 -0
  111. package/dist/upgrade/converge.d.ts +5 -0
  112. package/dist/upgrade/converge.d.ts.map +1 -1
  113. package/dist/upgrade/converge.js +110 -10
  114. package/dist/upgrade/converge.js.map +1 -1
  115. package/dist/upgrade/upgrade.d.ts +6 -0
  116. package/dist/upgrade/upgrade.d.ts.map +1 -1
  117. package/dist/upgrade/upgrade.js +3 -0
  118. package/dist/upgrade/upgrade.js.map +1 -1
  119. package/dist/upgrader-gcp.d.ts +5 -4
  120. package/dist/upgrader-gcp.d.ts.map +1 -1
  121. package/dist/upgrader-gcp.js +5 -4
  122. package/dist/upgrader-gcp.js.map +1 -1
  123. package/dist/wizard.d.ts +7 -0
  124. package/dist/wizard.d.ts.map +1 -1
  125. package/dist/wizard.js +11 -0
  126. package/dist/wizard.js.map +1 -1
  127. package/dist/wizards/gcp-cost.d.ts +31 -0
  128. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  129. package/dist/wizards/gcp-cost.js +81 -11
  130. package/dist/wizards/gcp-cost.js.map +1 -1
  131. package/dist/wizards/gcp.d.ts.map +1 -1
  132. package/dist/wizards/gcp.js +87 -11
  133. package/dist/wizards/gcp.js.map +1 -1
  134. package/package.json +2 -2
  135. package/src/adapter.ts +6 -0
  136. package/src/admin-memory.test.ts +1 -1
  137. package/src/adopt-existing.test.ts +46 -0
  138. package/src/adopt-existing.ts +58 -2
  139. package/src/agent/command.ts +6 -3
  140. package/src/caddy-redirects.test.ts +1 -1
  141. package/src/cli-routing.test.ts +1 -1
  142. package/src/cli-routing.ts +1 -1
  143. package/src/cli.ts +63 -8
  144. package/src/cloudsql-tls.test.ts +30 -0
  145. package/src/cloudsql-tls.ts +10 -3
  146. package/src/compose.ts +68 -0
  147. package/src/converge-db-trust.test.ts +237 -0
  148. package/src/converge-db-trust.ts +212 -0
  149. package/src/converge-job-gcp.ts +9 -1
  150. package/src/converge-job-preflight.test.ts +5 -0
  151. package/src/converge-job-preflight.ts +8 -0
  152. package/src/converge-job.test.ts +70 -0
  153. package/src/converge-job.ts +32 -0
  154. package/src/database-tls-builders.test.ts +3 -2
  155. package/src/deployed-release.test.ts +3 -0
  156. package/src/gateway-env-isolation.test.ts +40 -7
  157. package/src/gcp-names.ts +25 -0
  158. package/src/index.test.ts +3 -3
  159. package/src/index.ts +6 -0
  160. package/src/infra-contract.test.ts +5 -3
  161. package/src/infra-contract.ts +22 -1
  162. package/src/install-job.ts +4 -1
  163. package/src/install-state.ts +7 -0
  164. package/src/lifecycle.ts +3 -0
  165. package/src/migration-runner.ts +14 -9
  166. package/src/migration-targets.ts +12 -0
  167. package/src/operator-access.test.ts +15 -0
  168. package/src/operator-access.ts +14 -0
  169. package/src/pool-budget-stack.ts +15 -0
  170. package/src/pool-budget-stacks.test.ts +38 -13
  171. package/src/public-role-isolation.test.ts +18 -8
  172. package/src/public-role-isolation.ts +31 -17
  173. package/src/release-contract.ts +3 -0
  174. package/src/runtime-secrets.test.ts +18 -8
  175. package/src/runtime-secrets.ts +37 -18
  176. package/src/scheduler-setup.test.ts +201 -0
  177. package/src/scheduler-setup.ts +375 -0
  178. package/src/self-hosted-backup.test.ts +1 -0
  179. package/src/self-hosted-compose.test.ts +72 -1
  180. package/src/self-hosted-roles.integration.test.ts +2 -0
  181. package/src/self-hosted-roles.test.ts +49 -19
  182. package/src/self-hosted-roles.ts +60 -20
  183. package/src/self-hosted-scheduler-interval.test.ts +66 -0
  184. package/src/self-hosted-scheduler-interval.ts +79 -0
  185. package/src/self-hosted-start.test.ts +1 -0
  186. package/src/self-hosted-upgrade.test.ts +2 -1
  187. package/src/self-hosted-upgrade.ts +14 -8
  188. package/src/stack-contract.test.ts +31 -3
  189. package/src/stack-contract.ts +113 -6
  190. package/src/stack-converge-secrets.test.ts +2 -0
  191. package/src/stack-converge.test.ts +6 -4
  192. package/src/staging-database.test.ts +114 -0
  193. package/src/staging-database.ts +148 -0
  194. package/src/upgrade/converge.ts +130 -9
  195. package/src/upgrade/upgrade.test.ts +85 -2
  196. package/src/upgrade/upgrade.ts +9 -0
  197. package/src/upgrader-gcp.ts +5 -4
  198. package/src/wizard.ts +19 -0
  199. package/src/wizards/gcp-cost.test.ts +54 -1
  200. package/src/wizards/gcp-cost.ts +101 -11
  201. package/src/wizards/gcp.ts +109 -8
  202. package/stacks/aws/Pulumi.yaml +1 -1
  203. package/stacks/aws/index.ts +41 -1
  204. package/stacks/azure/index.ts +51 -3
  205. package/stacks/gcp/Pulumi.yaml +1 -1
  206. package/stacks/gcp/README.md +1 -0
  207. package/stacks/gcp/index.ts +50 -1
  208. package/stacks/gcp-firebase/Pulumi.yaml +1 -1
  209. package/stacks/gcp-firebase/index.ts +50 -1
  210. package/stacks/self-hosted/Pulumi.yaml +3 -0
  211. package/stacks/self-hosted/README.md +4 -0
  212. 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
+ }
@@ -203,10 +203,14 @@ export function startedExecutionName(stdout: string, stderr: string): string | n
203
203
  return /^[a-z0-9-]+$/.test(name) ? name : null;
204
204
  }
205
205
 
206
- /** Every dependency of {@link runConvergeJob} except the database and the log. */
206
+ /**
207
+ * Every dependency of {@link runConvergeJob} except the database (the queue
208
+ * and how it is secured, #1373) and the log.
209
+ */
207
210
  export function gcpConvergeDeps(
208
211
  placement: UpgraderPlacement,
209
212
  queue: ConvergeQueue,
213
+ secureDatabase: ConvergeJobDeps["secureDatabase"],
210
214
  log: JobLog,
211
215
  ): ConvergeJobDeps {
212
216
  const run: GcloudRunner = gcloud;
@@ -300,6 +304,9 @@ export function gcpConvergeDeps(
300
304
  domain: meta.domain,
301
305
  stagingHost: installStagingHost(meta),
302
306
  ownerEmail: meta.ownerEmail,
307
+ ...(meta.schedulerIntervalMinutes !== undefined
308
+ ? { schedulerIntervalMinutes: meta.schedulerIntervalMinutes }
309
+ : {}),
303
310
  targetTag: m.version,
304
311
  // This code is the target release's (stage 2), so its contract is it.
305
312
  infraContractVersion: INFRA_CONTRACT_VERSION,
@@ -355,6 +362,7 @@ export function gcpConvergeDeps(
355
362
 
356
363
  return {
357
364
  queue,
365
+ secureDatabase,
358
366
  projectId,
359
367
  region,
360
368
  iam: gcloudIamTester(run, http),
@@ -43,6 +43,11 @@ const PREDEFINED: Record<string, readonly string[]> = {
43
43
  "cloudscheduler.jobs.get",
44
44
  "cloudscheduler.jobs.update",
45
45
  ],
46
+ "roles/workflows.editor": [
47
+ "workflows.workflows.create",
48
+ "workflows.workflows.get",
49
+ "workflows.workflows.update",
50
+ ],
46
51
  "roles/logging.logWriter": ["logging.logEntries.create"],
47
52
  };
48
53
 
@@ -62,6 +62,14 @@ export const INSTALL_ROLE_USES: Record<
62
62
  "cloudscheduler.jobs.update",
63
63
  ],
64
64
  },
65
+ "roles/workflows.editor": {
66
+ need: "deploy and update the workflow the scheduler tick runs (#1140)",
67
+ permissions: [
68
+ "workflows.workflows.create",
69
+ "workflows.workflows.get",
70
+ "workflows.workflows.update",
71
+ ],
72
+ },
65
73
  "roles/logging.logWriter": {
66
74
  need: "write the update's progress to Cloud Logging",
67
75
  permissions: ["logging.logEntries.create"],
@@ -19,6 +19,7 @@ import {
19
19
  type ConvergeQueue,
20
20
  type ConvergeUpgradeOutcome,
21
21
  runConvergeJob,
22
+ type SecureDatabaseResult,
22
23
  } from "./converge-job.js";
23
24
  import type { ExecutionState } from "./install-job.js";
24
25
  import type { InstallMetadata } from "./install-state.js";
@@ -147,6 +148,8 @@ function harness(
147
148
  imageSigned?: boolean;
148
149
  /** What IAM answers the self-check; every permission asked for by default. */
149
150
  iam?: IamTester;
151
+ /** #1373 — the database TLS step's answer; null: the queue dials the DNS name already. */
152
+ secureDatabase?: SecureDatabaseResult | null;
150
153
  } = {},
151
154
  ): Harness {
152
155
  const calls: string[] = [];
@@ -156,6 +159,13 @@ function harness(
156
159
  let stored = opts.checkpoint ?? null;
157
160
  const deps: ConvergeJobDeps = {
158
161
  queue: q.queue,
162
+ secureDatabase:
163
+ opts.secureDatabase === undefined || opts.secureDatabase === null
164
+ ? null
165
+ : async () => {
166
+ calls.push("secure database");
167
+ return opts.secureDatabase as SecureDatabaseResult;
168
+ },
159
169
  projectId: "acme",
160
170
  region: "europe-west1",
161
171
  iam: opts.iam ?? {
@@ -556,6 +566,66 @@ describe("runConvergeJob — resume after a killed run", () => {
556
566
  });
557
567
  });
558
568
 
569
+ describe("runConvergeJob — the database TLS step (#1373)", () => {
570
+ const DNS = "abc.p-label.europe-west1.sql-psa.goog";
571
+
572
+ it("a failed TLS step reaches the timeline and ends the request, before any roll", async () => {
573
+ const h = harness({
574
+ secureDatabase: {
575
+ ok: false,
576
+ error:
577
+ "database TLS could not be set up: caelo-production-pg-1 reports no PRIVATE_SERVICES_ACCESS DNS name",
578
+ },
579
+ });
580
+ const r = await runConvergeJob(h.deps);
581
+ expect(r.exitCode).toBe(1);
582
+ expect(r.status).toBe("failed");
583
+ expect(h.upgrades).toEqual([]);
584
+ // Under the lock, after the resumable checkpoint, released at the end.
585
+ expect(h.calls).toEqual([
586
+ "manifest 0.11.0",
587
+ "lock",
588
+ "checkpoint running",
589
+ "secure database",
590
+ "checkpoint failed",
591
+ "unlock",
592
+ ]);
593
+ const end = h.q.ends[0];
594
+ expect(end?.codes).toEqual(["database-tls"]);
595
+ expect(end?.reason).toContain("reports no PRIVATE_SERVICES_ACCESS DNS name");
596
+ // The step's start and its error are timeline events of the request.
597
+ const converge = h.q.events.filter((e) => e.phase === "converge");
598
+ expect(converge[0]?.message).toStartWith("Setting up verified encryption");
599
+ const error = h.q.events.find((e) => e.level === "error");
600
+ expect(error?.message).toContain("reports no PRIVATE_SERVICES_ACCESS DNS name");
601
+ });
602
+
603
+ it("a successful TLS step runs right before the upgrade and names the verified host", async () => {
604
+ const h = harness({ secureDatabase: { ok: true, host: DNS } });
605
+ const r = await runConvergeJob(h.deps);
606
+ expect(r.status).toBe("succeeded");
607
+ const at = h.calls.indexOf("secure database");
608
+ expect(at).toBeGreaterThan(h.calls.indexOf("lock"));
609
+ expect(h.calls[at + 1]).toBe("upgrade");
610
+ expect(h.q.events.some((e) => e.level === "success" && e.message.includes(DNS))).toBe(true);
611
+ });
612
+
613
+ it("stage 1 never runs it: the release's own installer changes the database", async () => {
614
+ const h = harness({ ownVersion: "0.10.53", secureDatabase: { ok: true, host: DNS } });
615
+ await runConvergeJob(h.deps);
616
+ expect(h.calls.includes("secure database")).toBe(false);
617
+ });
618
+
619
+ it("a stop before the lock leaves the database alone", async () => {
620
+ const h = harness({
621
+ secureDatabase: { ok: true, host: DNS },
622
+ manifest: { ok: false, error: { code: "signature", message: "no valid signature" } },
623
+ });
624
+ await runConvergeJob(h.deps);
625
+ expect(h.calls.includes("secure database")).toBe(false);
626
+ });
627
+ });
628
+
559
629
  describe("runConvergeJob — nothing to do", () => {
560
630
  it("exits cleanly without a converge request", async () => {
561
631
  const h = harness({ claim: null });
@@ -116,8 +116,22 @@ export type ConvergeUpgradeOutcome =
116
116
  readonly unrecorded: boolean;
117
117
  };
118
118
 
119
+ /** How {@link ConvergeJobDeps.secureDatabase} ended. */
120
+ export type SecureDatabaseResult =
121
+ | { readonly ok: true; readonly host: string }
122
+ | { readonly ok: false; readonly error: string };
123
+
119
124
  export interface ConvergeJobDeps {
120
125
  readonly queue: ConvergeQueue;
126
+ /**
127
+ * #1373 — the install's database TLS step (#1338, cloudsql-tls.ts), and
128
+ * then {@link queue} moved onto the database's DNS name with verify-full.
129
+ * Null when the queue already dials that name. Until it ran the queue
130
+ * dials the instance's IP (converge-db-trust.ts), so a failure of the
131
+ * step reaches the timeline and the request's end like any other step;
132
+ * when it fails the queue is reopened on whatever the install dials then.
133
+ */
134
+ readonly secureDatabase: (() => Promise<SecureDatabaseResult>) | null;
121
135
  /** From the metadata server — never from env or argv. */
122
136
  readonly projectId: string;
123
137
  /** The region this job runs in (metadata server). */
@@ -463,6 +477,24 @@ export async function runConvergeJob(deps: ConvergeJobDeps): Promise<UpgraderRun
463
477
  save({});
464
478
 
465
479
  r.phase("converge");
480
+ // #1373 — stage 2 only, under the lock and after every check: it
481
+ // changes the database instance, its DNS and the services' URLs.
482
+ if (deps.secureDatabase) {
483
+ r.info(
484
+ "Setting up verified encryption for the database connection (the database's certificate and its private DNS name).",
485
+ );
486
+ await r.flush();
487
+ const secured = await deps.secureDatabase();
488
+ if (!secured.ok) {
489
+ save({ state: "failed", phase: "stopped" });
490
+ return stopWith(
491
+ "failed",
492
+ ["database-tls"],
493
+ `The update to ${m.version} stopped at the database's TLS step, before it rolled anything: ${secured.error}. The previous release keeps serving. Fix what it names and start the update again; it continues where it stopped.`,
494
+ );
495
+ }
496
+ r.success(`The database connection is verified (${secured.host}).`);
497
+ }
466
498
  const outcome = await deps.upgrade({
467
499
  manifest: m,
468
500
  reporter: r,
@@ -28,7 +28,7 @@ describe("database URL builders (#1338)", () => {
28
28
  const yaml = generateDockerCompose({
29
29
  domain: "example.com",
30
30
  postgresPassword: "superpw",
31
- rolePasswords: { admin: "adminpw", public: "publicpw" },
31
+ rolePasswords: { admin: "adminpw", public: "publicpw", publicStaging: "ps" },
32
32
  caeloSecretKek: "0".repeat(64),
33
33
  dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
34
34
  internalSecret: "i".repeat(48),
@@ -42,7 +42,8 @@ describe("database URL builders (#1338)", () => {
42
42
  project: "caelo-example-com",
43
43
  });
44
44
  const urls = [...yaml.matchAll(/_DATABASE_URL: "([^"]+)"/g)].map((m) => m[1] as string);
45
- expect(urls).toHaveLength(3);
45
+ // admin: cms_admin, cms_public, cms_public_staging (#1329); gateway: cms_public.
46
+ expect(urls).toHaveLength(4);
46
47
  for (const url of urls) expect(databaseTlsProblem(url)).toBeNull();
47
48
  });
48
49
 
@@ -128,6 +128,9 @@ describe("checkDeployedRuntimeEnv", () => {
128
128
  const adminOk = svc([
129
129
  secret("ADMIN_DATABASE_PASSWORD"),
130
130
  secret("PUBLIC_ADMIN_DATABASE_PASSWORD"),
131
+ // #1329 — the staging database's pool: a release from before it is
132
+ // refused here too and goes through `upgrade` first.
133
+ secret("PUBLIC_STAGING_ADMIN_DATABASE_PASSWORD"),
131
134
  ]);
132
135
  const gatewayOk = svc([secret("PUBLIC_DATABASE_PASSWORD", "public-role-password")]);
133
136
  const proxyOk = svc([secret("CAELO_MCP_OAUTH_SEAL_KEY", "mcp-oauth-seal-key")]);
@@ -16,6 +16,12 @@
16
16
  * self-hosted the generated compose gateway service: env, secrets,
17
17
  * mounts
18
18
  *
19
+ * Issue #1329 — the same crosswise between the two public databases: the
20
+ * live gateway's env names nothing of the staging database
21
+ * (cms_public_staging, public_staging_role, its password secret), and its
22
+ * identity cannot read the staging login's password, on every adapter. The
23
+ * staging gateway (#1330) gets the mirror-image checks with its service.
24
+ *
19
25
  * The gateway itself refuses to boot on such an env
20
26
  * (apps/api-gateway `adminDatabaseAccessInEnv`); this catches it at review
21
27
  * time, before a release can ship it.
@@ -42,6 +48,12 @@ const ADMIN_ACCESS = /cms_admin|admin_role|gateway_role|ADMIN_DATABASE|GATEWAY_D
42
48
  /** Secrets that unlock a cms_admin login. */
43
49
  const ADMIN_SECRETS = ["postgres-password", "gateway-role-password"];
44
50
 
51
+ /** #1329 — anything in the live gateway's env that reaches the staging database. */
52
+ const STAGING_ACCESS = /cms_public_staging|public_staging_role|PUBLIC_STAGING/;
53
+
54
+ /** #1329 — the secret that unlocks the staging gateway's login. */
55
+ const STAGING_SECRETS = ["public-staging-role-password"];
56
+
45
57
  /** The secrets the gateway reads on GCP: its own login and the staging gate key. */
46
58
  const GCP_GATEWAY_SECRETS = [
47
59
  "public-role-password",
@@ -68,11 +80,19 @@ describe("gcp + gcp-firebase: the gateway env contract (#635)", () => {
68
80
  databaseUrls: databaseUrls("db1.p-label.europe-west1.sql-psa.goog"),
69
81
  });
70
82
 
71
- it("carries no cms_admin URL, role or credential name", () => {
83
+ it("carries no cms_admin URL, role or credential name, and nothing of the staging database (#1329)", () => {
72
84
  for (const v of env) {
73
85
  expect(v.name).not.toMatch(ADMIN_ACCESS);
74
- if ("value" in v && typeof v.value === "string") expect(v.value).not.toMatch(ADMIN_ACCESS);
86
+ expect(v.name).not.toMatch(STAGING_ACCESS);
87
+ if ("value" in v && typeof v.value === "string") {
88
+ expect(v.value).not.toMatch(ADMIN_ACCESS);
89
+ expect(v.value).not.toMatch(STAGING_ACCESS);
90
+ }
75
91
  }
92
+ expect(env.find((v) => v.name === "CAELO_GATEWAY_ENVIRONMENT")).toEqual({
93
+ name: "CAELO_GATEWAY_ENVIRONMENT",
94
+ value: "live",
95
+ });
76
96
  expect(env.find((v) => v.name === "PUBLIC_DATABASE_URL")).toEqual({
77
97
  name: "PUBLIC_DATABASE_URL",
78
98
  value:
@@ -80,12 +100,16 @@ describe("gcp + gcp-firebase: the gateway env contract (#635)", () => {
80
100
  });
81
101
  });
82
102
 
83
- it("references no secret that unlocks a cms_admin login", () => {
103
+ it("references no secret that unlocks a cms_admin or the staging login", () => {
84
104
  for (const secret of Object.values(SERVICE_SECRET_ENV.gateway)) {
85
105
  expect(ADMIN_SECRETS).not.toContain(secret);
106
+ expect(STAGING_SECRETS).not.toContain(secret);
86
107
  }
87
108
  const gatewayBindings = runtimeSecretBindings().filter((b) => b.service === "gateway");
88
- for (const b of gatewayBindings) expect(ADMIN_SECRETS).not.toContain(b.secret);
109
+ for (const b of gatewayBindings) {
110
+ expect(ADMIN_SECRETS).not.toContain(b.secret);
111
+ expect(STAGING_SECRETS).not.toContain(b.secret);
112
+ }
89
113
  });
90
114
 
91
115
  for (const provider of ["gcp", "gcp-firebase"] as const) {
@@ -134,7 +158,7 @@ describe("self-hosted: the generated compose gateway service (#635)", () => {
134
158
  const out = generateDockerCompose({
135
159
  domain: "example.com",
136
160
  postgresPassword: "superpw",
137
- rolePasswords: { admin: "adminpw", public: "publicpw" },
161
+ rolePasswords: { admin: "adminpw", public: "publicpw", publicStaging: "stagingpw" },
138
162
  caeloSecretKek: "0".repeat(64),
139
163
  dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
140
164
  internalSecret: "i".repeat(48),
@@ -152,7 +176,9 @@ describe("self-hosted: the generated compose gateway service (#635)", () => {
152
176
  const gateway = out.slice(start, end === -1 ? undefined : end);
153
177
  expect(gateway).toContain("PUBLIC_DATABASE_URL:");
154
178
  expect(gateway).not.toMatch(ADMIN_ACCESS);
155
- expect(gateway).not.toMatch(/superpw|adminpw|gatewaypw/);
179
+ expect(gateway).not.toMatch(STAGING_ACCESS);
180
+ expect(gateway).not.toMatch(/superpw|adminpw|gatewaypw|stagingpw/);
181
+ expect(gateway).toContain('CAELO_GATEWAY_ENVIRONMENT: "live"');
156
182
  // No other way in: no Compose secrets, no env file, and exactly one
157
183
  // mount — the database CA certificate (#1338), public, no credential.
158
184
  expect(gateway).not.toMatch(/^ {4}(secrets|env_file):/m);
@@ -164,6 +190,8 @@ describe("self-hosted: the generated compose gateway service (#635)", () => {
164
190
  expect(keys.sort()).toEqual([
165
191
  // #1254 — its cms_public pool size (pool-budget-stack.ts).
166
192
  "CAELO_DB_PUBLIC_POOL_MAX",
193
+ // #1329 — the live site's gateway.
194
+ "CAELO_GATEWAY_ENVIRONMENT",
167
195
  "CAELO_TRUSTED_PROXY_HOPS",
168
196
  "GATEWAY_PORT",
169
197
  "NODE_ENV",
@@ -181,6 +209,9 @@ describe("azure: the gateway container app (#635)", () => {
181
209
  const block = src.slice(start, src.indexOf("});", start));
182
210
  expect(block).toContain('{ name: "PUBLIC_DATABASE_URL", value: publicDatabaseUrl }');
183
211
  expect(block).not.toMatch(/adminDatabase|gatewayDatabase|ADMIN_DATABASE|GATEWAY_DATABASE/);
212
+ // #1329 — nothing of the staging database, and it says it is the live one.
213
+ expect(block).not.toMatch(/publicStaging|PUBLIC_STAGING/);
214
+ expect(block).toContain('{ name: GATEWAY_ENVIRONMENT_ENV, value: "live" }');
184
215
  });
185
216
 
186
217
  it("builds no cms_admin URL for any role but the admin's", () => {
@@ -206,7 +237,9 @@ describe("aws: the stack (#635)", () => {
206
237
 
207
238
  it("the gateway's task role reads only the public_role password (review finding)", () => {
208
239
  expect(AWS_SERVICE_SECRETS.gateway).toEqual(["public-role-password"]);
209
- for (const secret of ADMIN_SECRETS) expect(AWS_SERVICE_SECRETS.gateway).not.toContain(secret);
240
+ for (const secret of [...ADMIN_SECRETS, ...STAGING_SECRETS]) {
241
+ expect(AWS_SERVICE_SECRETS.gateway).not.toContain(secret);
242
+ }
210
243
  });
211
244
 
212
245
  it("each service gets its own task role whose policy is built from that table", () => {
package/src/gcp-names.ts CHANGED
@@ -221,6 +221,31 @@ export function captureServiceAccountEmail(projectId: string, env: string): stri
221
221
  return `${captureServiceAccountId(env)}@${projectId}.iam.gserviceaccount.com`;
222
222
  }
223
223
 
224
+ /**
225
+ * #1140 — account id of the scheduler tick's service account: signs its own
226
+ * IAP credential and calls the admin's tick endpoint, nothing else
227
+ * (scheduler-setup.ts). `caelo-production-scheduler` is 26 of the 30
228
+ * characters an account id may have.
229
+ */
230
+ export function schedulerServiceAccountId(env: string): string {
231
+ return `${gcpNamePrefix(env)}-scheduler`;
232
+ }
233
+
234
+ /** Email of the scheduler tick's service account. */
235
+ export function schedulerServiceAccountEmail(projectId: string, env: string): string {
236
+ return `${schedulerServiceAccountId(env)}@${projectId}.iam.gserviceaccount.com`;
237
+ }
238
+
239
+ /** #1140 — the Cloud Workflows workflow one scheduler tick runs (scheduler-setup.ts). */
240
+ export function schedulerWorkflowName(env: string): string {
241
+ return `${gcpNamePrefix(env)}-scheduler-tick`;
242
+ }
243
+
244
+ /** #1140 — the Cloud Scheduler job that starts the tick workflow every N minutes. */
245
+ export function schedulerJobName(env: string): string {
246
+ return `${gcpNamePrefix(env)}-scheduler-tick`;
247
+ }
248
+
224
249
  /**
225
250
  * Account id of the upgrader job's own service account: may roll the two
226
251
  * Cloud Run services, run the migration job and back up Cloud SQL, never
package/src/index.test.ts CHANGED
@@ -152,7 +152,7 @@ describe("docker-compose generator", () => {
152
152
  const out = generateDockerCompose({
153
153
  domain: "example.com",
154
154
  postgresPassword: "supersecret",
155
- rolePasswords: { admin: "adminpw", public: "publicpw" },
155
+ rolePasswords: { admin: "adminpw", public: "publicpw", publicStaging: "ps" },
156
156
  caeloSecretKek: "0".repeat(64),
157
157
  dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
158
158
  internalSecret: "i".repeat(48),
@@ -175,7 +175,7 @@ describe("docker-compose generator", () => {
175
175
  const out = generateDockerCompose({
176
176
  domain: "example.com",
177
177
  postgresPassword: "supersecret",
178
- rolePasswords: { admin: "adminpw", public: "publicpw" },
178
+ rolePasswords: { admin: "adminpw", public: "publicpw", publicStaging: "ps" },
179
179
  caeloSecretKek: "0".repeat(64),
180
180
  dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
181
181
  internalSecret: "i".repeat(48),
@@ -201,7 +201,7 @@ describe("docker-compose generator", () => {
201
201
  const out = generateDockerCompose({
202
202
  domain: "example.com",
203
203
  postgresPassword: 'pa"ss',
204
- rolePasswords: { admin: "a", public: "b" },
204
+ rolePasswords: { admin: "a", public: "b", publicStaging: "ps" },
205
205
  caeloSecretKek: "0".repeat(64),
206
206
  dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
207
207
  internalSecret: "i".repeat(48),
package/src/index.ts CHANGED
@@ -46,12 +46,18 @@ export {
46
46
  selfHostedCdnCopy,
47
47
  } from "./cdn-copy.js";
48
48
  export { type ComposeSpec, generateDockerCompose, type RolePasswords } from "./compose.js";
49
+ export {
50
+ type ConvergeDatabaseTarget,
51
+ convergeDatabaseTarget,
52
+ serverIdentityProblem,
53
+ } from "./converge-db-trust.js";
49
54
  export {
50
55
  type ConvergeClaim,
51
56
  type ConvergeJobDeps,
52
57
  type ConvergeQueue,
53
58
  type ConvergeUpgradeOutcome,
54
59
  runConvergeJob,
60
+ type SecureDatabaseResult,
55
61
  } from "./converge-job.js";
56
62
  export { convergeExecutionNameError, gcpConvergeDeps } from "./converge-job-gcp.js";
57
63
  export { INFRA_CONTRACT_VERSION } from "./infra-contract.js";