@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
@@ -28,6 +28,9 @@
28
28
  * `public_role` is still a member of any role. This step therefore runs
29
29
  * BEFORE migrations, on every install pass and every `upgrade`. Idempotent:
30
30
  * revoking from a role that has none changes nothing.
31
+ *
32
+ * Since #1329 the staging gateway's login `public_staging_role` is an
33
+ * Admin API user too and gets the same revoke.
31
34
  */
32
35
 
33
36
  import { findSqlInstance } from "./gateway-credentials.js";
@@ -36,16 +39,20 @@ import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud
36
39
  import type { EnsureOutcome } from "./runtime-secrets.js";
37
40
  import { type GcloudCredentials, lookupFailure } from "./upgrade/gcp-lookup.js";
38
41
 
39
- /** The gcloud call that leaves `public_role` with no database roles. */
42
+ /** The gateway logins the Admin API created, which must hold no database role (#633, #1329). */
43
+ export const GATEWAY_DATABASE_LOGINS = ["public_role", "public_staging_role"] as const;
44
+
45
+ /** The gcloud call that leaves a gateway login with no database roles. */
40
46
  export function revokePublicRoleMembershipsArgs(target: {
41
47
  readonly projectId: string;
42
48
  readonly sqlInstance: string;
49
+ readonly role: (typeof GATEWAY_DATABASE_LOGINS)[number];
43
50
  }): string[] {
44
51
  return [
45
52
  "sql",
46
53
  "users",
47
54
  "assign-roles",
48
- "public_role",
55
+ target.role,
49
56
  `--instance=${target.sqlInstance}`,
50
57
  "--type=BUILT_IN",
51
58
  "--revoke-existing-roles",
@@ -55,8 +62,9 @@ export function revokePublicRoleMembershipsArgs(target: {
55
62
  }
56
63
 
57
64
  /**
58
- * Revoke every database role the Admin API gave `public_role`. Reports
59
- * instead of throwing; `failed` names the next step.
65
+ * Revoke every database role the Admin API gave the gateway logins
66
+ * ({@link GATEWAY_DATABASE_LOGINS}). Reports instead of throwing; `failed`
67
+ * names the next step.
60
68
  */
61
69
  export async function revokePublicRoleMemberships(
62
70
  install: {
@@ -67,7 +75,7 @@ export async function revokePublicRoleMemberships(
67
75
  deps: { readonly run?: GcloudRunner; readonly sleep?: Sleep } = {},
68
76
  ): Promise<EnsureOutcome> {
69
77
  const run = deps.run ?? defaultGcloud;
70
- const id = "public_role database role memberships";
78
+ const id = "gateway logins' database role memberships";
71
79
  const lookup = await findSqlInstance(install, { run });
72
80
  if (lookup.status !== "found") {
73
81
  return {
@@ -77,17 +85,23 @@ export async function revokePublicRoleMemberships(
77
85
  };
78
86
  }
79
87
  const sqlInstance = lookup.name;
80
- const r = await runWithRetry(
81
- run,
82
- deps.sleep ?? realSleep,
83
- revokePublicRoleMembershipsArgs({ projectId: install.projectId, sqlInstance }),
84
- );
85
- if (!r.ok) {
86
- return {
87
- id,
88
- status: "failed",
89
- error: `gcloud sql users assign-roles public_role --revoke-existing-roles on ${sqlInstance}: ${r.stderr.trim()}. The account running this needs cloudsql.users.update on the project; re-run once it has.`,
90
- };
88
+ for (const role of GATEWAY_DATABASE_LOGINS) {
89
+ const r = await runWithRetry(
90
+ run,
91
+ deps.sleep ?? realSleep,
92
+ revokePublicRoleMembershipsArgs({ projectId: install.projectId, sqlInstance, role }),
93
+ );
94
+ if (!r.ok) {
95
+ return {
96
+ id,
97
+ status: "failed",
98
+ error: `gcloud sql users assign-roles ${role} --revoke-existing-roles on ${sqlInstance}: ${r.stderr.trim()}. The account running this needs cloudsql.users.update on the project; re-run once it has.`,
99
+ };
100
+ }
91
101
  }
92
- return { id, status: "applied", detail: `no database roles left on ${sqlInstance}` };
102
+ return {
103
+ id,
104
+ status: "applied",
105
+ detail: `${GATEWAY_DATABASE_LOGINS.join(" and ")} hold no database roles on ${sqlInstance}`,
106
+ };
93
107
  }
@@ -55,6 +55,9 @@ export const INFRA_CONTRACT_PATHS: readonly string[] = [
55
55
  "packages/provisioning/src/audit-job-setup.ts",
56
56
  // #1217 — the capture job, its service account, bucket and grants.
57
57
  "packages/provisioning/src/capture-job-setup.ts",
58
+ // #1140 — the scheduler tick: its service account, workflow, Cloud
59
+ // Scheduler job and grants.
60
+ "packages/provisioning/src/scheduler-setup.ts",
58
61
  // #819 — the converge job, its database login and the admin's grant on it.
59
62
  "packages/provisioning/src/converge-job-setup.ts",
60
63
  // #891 — the edge security headers the stacks attach, and the converge's
@@ -115,7 +115,7 @@ describe("ensureGeneratedSecrets", () => {
115
115
  { ...secretsInstall, replication: regional },
116
116
  { run },
117
117
  );
118
- expect(outcomes.map((o) => o.status)).toEqual(Array(6).fill("present"));
118
+ expect(outcomes.map((o) => o.status)).toEqual(Array(7).fill("present"));
119
119
  expect(lines().some((l) => l.includes(" create ") || l.includes(" add "))).toBe(false);
120
120
  });
121
121
 
@@ -126,7 +126,7 @@ describe("ensureGeneratedSecrets", () => {
126
126
  { ...secretsInstall, replication: regional },
127
127
  { run, generate: () => `generated-value-${++n}` },
128
128
  );
129
- expect(outcomes.map((o) => o.status)).toEqual(Array(6).fill("applied"));
129
+ expect(outcomes.map((o) => o.status)).toEqual(Array(7).fill("applied"));
130
130
  const creates = calls.filter((c) => c.line.startsWith("secrets create"));
131
131
  // #806 — the seal key is 32 random bytes in base64 (not the injected hex generator).
132
132
  const sealKey = creates.find((c) => c.line.includes("mcp-oauth-seal-key"))?.stdin ?? "";
@@ -145,10 +145,15 @@ describe("ensureGeneratedSecrets", () => {
145
145
  line: "secrets create caelo-production-public-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
146
146
  stdin: "generated-value-3",
147
147
  },
148
+ // #1329 — the staging gateway's role password: its own, never the live one's.
149
+ {
150
+ line: "secrets create caelo-production-public-staging-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
151
+ stdin: "generated-value-4",
152
+ },
148
153
  // #650 — the staging access gate's key.
149
154
  {
150
155
  line: "secrets create caelo-production-staging-gate-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
151
- stdin: "generated-value-4",
156
+ stdin: "generated-value-5",
152
157
  },
153
158
  // #806 — remote MCP's OAuth client starts as the "not configured"
154
159
  // placeholder; the Owner writes the real one from Settings → MCP.
@@ -178,11 +183,12 @@ describe("ensureGeneratedSecrets", () => {
178
183
  { run, generate: () => "generated" },
179
184
  );
180
185
  const creates = calls.filter((c) => c.line.startsWith("secrets create"));
181
- expect(creates.map((c) => c.stdin).slice(0, 5)).toEqual([
186
+ expect(creates.map((c) => c.stdin).slice(0, 6)).toEqual([
182
187
  "operator-set",
183
188
  "generated",
184
189
  "generated",
185
190
  "generated",
191
+ "generated",
186
192
  "{}",
187
193
  ]);
188
194
  for (const c of calls) expect(c.line).not.toContain("operator-set");
@@ -194,6 +200,7 @@ describe("ensureGeneratedSecrets", () => {
194
200
  "secrets versions list caelo-production-internal-secret": [ok("")],
195
201
  "secrets versions list caelo-production-tool-approval-secret": [ok("v/1")],
196
202
  "secrets versions list caelo-production-public-role-password": [ok("v/1")],
203
+ "secrets versions list caelo-production-public-staging-role-password": [ok("v/1")],
197
204
  "secrets versions list caelo-production-staging-gate-secret": [ok("v/1")],
198
205
  "secrets versions list caelo-production-operator-oauth-client": [ok("v/1")],
199
206
  "secrets versions list caelo-production-mcp-oauth-seal-key": [ok("v/1")],
@@ -209,6 +216,7 @@ describe("ensureGeneratedSecrets", () => {
209
216
  "present",
210
217
  "present",
211
218
  "present",
219
+ "present",
212
220
  ]);
213
221
  expect(calls.filter((c) => c.line.startsWith("secrets versions add"))).toEqual([
214
222
  {
@@ -243,7 +251,7 @@ describe("ensureGeneratedSecrets", () => {
243
251
  { ...secretsInstall, replication: { kind: "automatic" } },
244
252
  { run, generate: () => "v" },
245
253
  );
246
- expect(outcomes.map((o) => o.status)).toEqual(Array(6).fill("applied"));
254
+ expect(outcomes.map((o) => o.status)).toEqual(Array(7).fill("applied"));
247
255
  expect(outcomes.every((o) => o.detail?.includes("gcp.resourceLocations"))).toBe(true);
248
256
  const creates = lines().filter((l) => l.startsWith("secrets create"));
249
257
  // One refused automatic attempt; then every secret — the refused one
@@ -253,6 +261,7 @@ describe("ensureGeneratedSecrets", () => {
253
261
  "secrets create caelo-production-internal-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
254
262
  "secrets create caelo-production-tool-approval-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
255
263
  "secrets create caelo-production-public-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
264
+ "secrets create caelo-production-public-staging-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
256
265
  // #652 — the staging gate's key follows the same path.
257
266
  "secrets create caelo-production-staging-gate-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
258
267
  "secrets create caelo-production-operator-oauth-client --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
@@ -284,7 +293,7 @@ describe("ensureGeneratedSecrets", () => {
284
293
  { run, generate: () => "v" },
285
294
  );
286
295
  expect(outcomes.every((o) => o.status === "failed")).toBe(true);
287
- expect(lines().filter((l) => l.startsWith("secrets create"))).toHaveLength(6);
296
+ expect(lines().filter((l) => l.startsWith("secrets create"))).toHaveLength(7);
288
297
  });
289
298
  });
290
299
 
@@ -329,6 +338,7 @@ describe("rotateRuntimeSecret", () => {
329
338
  service([
330
339
  ref("ADMIN_DATABASE_PASSWORD", "postgres-password"),
331
340
  ref("PUBLIC_ADMIN_DATABASE_PASSWORD", "postgres-password"),
341
+ ref("PUBLIC_STAGING_ADMIN_DATABASE_PASSWORD", "postgres-password"),
332
342
  ref("CAELO_SECRET_KEK", "secret-kek"),
333
343
  ref("CAELO_INTERNAL_SECRET", "internal-secret"),
334
344
  ref("CAELO_TOOL_APPROVAL_SECRET", "tool-approval-secret"),
@@ -574,8 +584,8 @@ describe("migrationJobEnvArgs", () => {
574
584
  it("gives the job password-less URLs and the password as a secret reference", () => {
575
585
  const args = migrationJobEnvArgs("db1.p-label.europe-west1.sql-psa.goog");
576
586
  expect(args).toEqual([
577
- "--set-env-vars=ADMIN_DATABASE_URL=postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_admin?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem,PUBLIC_ADMIN_DATABASE_URL=postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_public?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem",
578
- "--set-secrets=ADMIN_DATABASE_PASSWORD=caelo-production-postgres-password:latest,PUBLIC_ADMIN_DATABASE_PASSWORD=caelo-production-postgres-password:latest",
587
+ "--set-env-vars=ADMIN_DATABASE_URL=postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_admin?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem,PUBLIC_ADMIN_DATABASE_URL=postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_public?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem,PUBLIC_STAGING_ADMIN_DATABASE_URL=postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_public_staging?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem",
588
+ "--set-secrets=ADMIN_DATABASE_PASSWORD=caelo-production-postgres-password:latest,PUBLIC_ADMIN_DATABASE_PASSWORD=caelo-production-postgres-password:latest,PUBLIC_STAGING_ADMIN_DATABASE_PASSWORD=caelo-production-postgres-password:latest",
579
589
  ]);
580
590
  });
581
591
  });
@@ -572,6 +572,12 @@ async function setSqlUserPassword(
572
572
  http: HttpFetch,
573
573
  sleep: Sleep,
574
574
  user: { projectId: string; instance: string; role: string; password: string },
575
+ /**
576
+ * `update` sets an existing user's password (`users.update`); `create`
577
+ * adds the user with it (`users.insert`, #1329: the staging gateway's
578
+ * login on an install from before it).
579
+ */
580
+ mode: "update" | "create",
575
581
  ): Promise<{ ok: true } | { ok: false; error: string }> {
576
582
  const token = await run(["auth", "print-access-token"]);
577
583
  const bearer = token.ok ? token.stdout.trim() : "";
@@ -587,11 +593,17 @@ async function setSqlUserPassword(
587
593
  ok: false as const,
588
594
  error: `${what}: HTTP ${res.status} ${(await res.text()).slice(0, 500)}`,
589
595
  });
590
- const put = await http(
591
- `${project}/instances/${encodeURIComponent(user.instance)}/users?name=${encodeURIComponent(user.role)}`,
592
- { method: "PUT", headers, body: JSON.stringify({ name: user.role, password: user.password }) },
593
- );
594
- if (!put.ok) return failed("update the user", put);
596
+ const users = `${project}/instances/${encodeURIComponent(user.instance)}/users`;
597
+ const body = JSON.stringify({ name: user.role, password: user.password });
598
+ const put =
599
+ mode === "update"
600
+ ? await http(`${users}?name=${encodeURIComponent(user.role)}`, {
601
+ method: "PUT",
602
+ headers,
603
+ body,
604
+ })
605
+ : await http(users, { method: "POST", headers, body });
606
+ if (!put.ok) return failed(mode === "update" ? "update the user" : "create the user", put);
595
607
  type Operation = {
596
608
  name?: string;
597
609
  status?: string;
@@ -693,12 +705,13 @@ export async function rotateRuntimeSecret(
693
705
  }
694
706
  const previous = current.stdout;
695
707
  const setPassword = (role: string, password: string) =>
696
- setSqlUserPassword(run, http, sleep, {
697
- projectId: target.projectId,
698
- instance,
699
- role,
700
- password,
701
- });
708
+ setSqlUserPassword(
709
+ run,
710
+ http,
711
+ sleep,
712
+ { projectId: target.projectId, instance, role, password },
713
+ "update",
714
+ );
702
715
  const changed: string[] = [];
703
716
  const restore = async (): Promise<string> => {
704
717
  const failed: string[] = [];
@@ -807,7 +820,12 @@ export async function readSecretValue(
807
820
  export async function ensureDatabaseRolePassword(
808
821
  target: DatabaseRoleTarget,
809
822
  role: DatabaseRole,
810
- deps: RuntimeDeps & { readonly http?: HttpFetch; readonly password?: string } = {},
823
+ deps: RuntimeDeps & {
824
+ readonly http?: HttpFetch;
825
+ readonly password?: string;
826
+ /** Create the Cloud SQL user with the password instead (it does not exist yet, #1329). */
827
+ readonly create?: boolean;
828
+ } = {},
811
829
  ): Promise<EnsureOutcome> {
812
830
  const run = deps.run ?? defaultGcloud;
813
831
  const sleep = deps.sleep ?? realSleep;
@@ -819,11 +837,12 @@ export async function ensureDatabaseRolePassword(
819
837
  if (!value.ok) return { id, status: "failed", error: value.error };
820
838
  password = value.value;
821
839
  }
822
- const set = await setSqlUserPassword(run, http, sleep, {
823
- projectId: target.projectId,
824
- instance: target.sqlInstance,
825
- role,
826
- password,
827
- });
840
+ const set = await setSqlUserPassword(
841
+ run,
842
+ http,
843
+ sleep,
844
+ { projectId: target.projectId, instance: target.sqlInstance, role, password },
845
+ deps.create === true ? "create" : "update",
846
+ );
828
847
  return set.ok ? { id, status: "applied" } : { id, status: "failed", error: set.error };
829
848
  }
@@ -0,0 +1,201 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The scheduler tick's setup (scheduler-setup.ts) against a fake gcloud. The
5
+ * load-bearing assertions are the trust boundary (#1140): the scheduler's
6
+ * account may sign as itself, pass the admin's IAP, start workflows and
7
+ * write logs — no secret, database, storage, run or IAM-admin right — and
8
+ * the one Cloud Scheduler job starts the workflow at the knob's cadence.
9
+ */
10
+
11
+ import { describe, expect, it } from "bun:test";
12
+ import type { GcloudResult } from "./gcloud.js";
13
+ import {
14
+ ensureSchedulerTick,
15
+ SCHEDULER_FORBIDDEN_ROLES,
16
+ SCHEDULER_TICK_PATH,
17
+ type SchedulerTickTarget,
18
+ schedulerWorkflowSource,
19
+ tickOrigin,
20
+ } from "./scheduler-setup.js";
21
+
22
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
23
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
24
+
25
+ const SA = "caelo-production-scheduler@acme.iam.gserviceaccount.com";
26
+ const MEMBER = `serviceAccount:${SA}`;
27
+
28
+ const TARGET: SchedulerTickTarget = {
29
+ projectId: "acme",
30
+ region: "europe-west1",
31
+ env: "production",
32
+ adminUrl: "https://admin.acme.com",
33
+ resource: { kind: "backend-services", service: "caelo-production-admin-backend-1a2b" },
34
+ intervalMinutes: 5,
35
+ };
36
+
37
+ function fakeGcloud(overrides: [RegExp, GcloudResult][] = []) {
38
+ const calls: string[][] = [];
39
+ const sources: string[] = [];
40
+ const rules: [RegExp, GcloudResult][] = [
41
+ ...overrides,
42
+ [/^iam service-accounts describe/, fail("NOT_FOUND")],
43
+ [/^scheduler jobs describe/, fail("NOT_FOUND")],
44
+ ];
45
+ const run = async (args: string[]) => {
46
+ calls.push(args);
47
+ const src = args.find((a) => a.startsWith("--source="));
48
+ if (src) sources.push(await Bun.file(src.slice("--source=".length)).text());
49
+ const line = args.join(" ");
50
+ return rules.find(([re]) => re.test(line))?.[1] ?? ok("");
51
+ };
52
+ return { run, calls, sources };
53
+ }
54
+
55
+ const flag = (args: string[], name: string) =>
56
+ args.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
57
+
58
+ function grants(calls: string[][]) {
59
+ return calls
60
+ .filter((c) => c.includes("add-iam-policy-binding"))
61
+ .map((c) => ({
62
+ where: c.slice(0, c.indexOf("add-iam-policy-binding") + 2).join(" "),
63
+ member: flag(c, "member"),
64
+ role: flag(c, "role") as string,
65
+ }));
66
+ }
67
+
68
+ const sleep = async () => {};
69
+
70
+ describe("ensureSchedulerTick", () => {
71
+ it("grants the scheduler only self-signing, IAP access, workflow start and logs", async () => {
72
+ const { run, calls } = fakeGcloud();
73
+ const r = await ensureSchedulerTick(TARGET, { run, sleep });
74
+ expect(r.ok).toBe(true);
75
+ const mine = grants(calls).filter((g) => g.member === MEMBER);
76
+ expect(mine.map((g) => [g.where, g.role])).toEqual([
77
+ [`iam service-accounts add-iam-policy-binding ${SA}`, "roles/iam.serviceAccountTokenCreator"],
78
+ ["projects add-iam-policy-binding acme", "roles/workflows.invoker"],
79
+ ["projects add-iam-policy-binding acme", "roles/logging.logWriter"],
80
+ [
81
+ "iap web add-iam-policy-binding --resource-type=backend-services",
82
+ "roles/iap.httpsResourceAccessor",
83
+ ],
84
+ ]);
85
+ for (const g of mine) {
86
+ for (const forbidden of SCHEDULER_FORBIDDEN_ROLES) {
87
+ expect(g.role.startsWith(forbidden)).toBe(false);
88
+ }
89
+ }
90
+ // Nobody else is granted anything.
91
+ expect(grants(calls).every((g) => g.member === MEMBER)).toBe(true);
92
+ // The IAP grant names the admin's resource.
93
+ const iap = calls.find((c) => c[0] === "iap") as string[];
94
+ expect(iap).toContain("--resource-type=backend-services");
95
+ expect(iap).toContain("--service=caelo-production-admin-backend-1a2b");
96
+ });
97
+
98
+ it("signs as itself only: token creator on its own account, never on another", async () => {
99
+ const { run, calls } = fakeGcloud();
100
+ await ensureSchedulerTick(TARGET, { run, sleep });
101
+ const saGrants = calls.filter(
102
+ (c) => c[0] === "iam" && c[1] === "service-accounts" && c[2] === "add-iam-policy-binding",
103
+ );
104
+ expect(saGrants.map((c) => c[3])).toEqual([SA]);
105
+ });
106
+
107
+ it("deploys the workflow as the scheduler account and schedules it every 5 minutes", async () => {
108
+ const { run, calls, sources } = fakeGcloud();
109
+ await ensureSchedulerTick(TARGET, { run, sleep });
110
+ const deploy = calls.find((c) => c[0] === "workflows" && c[1] === "deploy") as string[];
111
+ expect(deploy[2]).toBe("caelo-production-scheduler-tick");
112
+ expect(flag(deploy, "service-account")).toBe(SA);
113
+ expect(flag(deploy, "location")).toBe("europe-west1");
114
+ expect(sources[0]).toContain(`url: "https://admin.acme.com${SCHEDULER_TICK_PATH}"`);
115
+ const create = calls.find(
116
+ (c) => c[0] === "scheduler" && c[1] === "jobs" && c[2] === "create",
117
+ ) as string[];
118
+ expect(create[4]).toBe("caelo-production-scheduler-tick");
119
+ expect(flag(create, "schedule")).toBe("*/5 * * * *");
120
+ expect(flag(create, "time-zone")).toBe("Etc/UTC");
121
+ expect(flag(create, "uri")).toBe(
122
+ "https://workflowexecutions.googleapis.com/v1/projects/acme/locations/europe-west1/workflows/caelo-production-scheduler-tick/executions",
123
+ );
124
+ expect(flag(create, "oauth-service-account-email")).toBe(SA);
125
+ // OAuth (a googleapis.com target), never an OIDC token IAP would refuse.
126
+ expect(create.some((a) => a.startsWith("--oidc"))).toBe(false);
127
+ });
128
+
129
+ it("updates the existing job on a re-run, at the knob's new cadence", async () => {
130
+ const { run, calls } = fakeGcloud([[/^scheduler jobs describe/, ok("projects/acme/…")]]);
131
+ const r = await ensureSchedulerTick({ ...TARGET, intervalMinutes: 1 }, { run, sleep });
132
+ expect(r.ok).toBe(true);
133
+ const update = calls.find(
134
+ (c) => c[0] === "scheduler" && c[1] === "jobs" && c[2] === "update",
135
+ ) as string[];
136
+ expect(flag(update, "schedule")).toBe("* * * * *");
137
+ expect(calls.some((c) => c[0] === "scheduler" && c[2] === "create")).toBe(false);
138
+ });
139
+
140
+ it("refuses an interval outside 1-15 minutes before touching the project", async () => {
141
+ const { run, calls } = fakeGcloud();
142
+ const r = await ensureSchedulerTick({ ...TARGET, intervalMinutes: 30 }, { run, sleep });
143
+ expect(r.ok).toBe(false);
144
+ expect(r.ok ? "" : r.error).toContain("between 1 and 15");
145
+ expect(calls).toEqual([]);
146
+ });
147
+
148
+ it("refuses an admin URL that is not a plain https origin", async () => {
149
+ for (const bad of ["http://admin.acme.com", "https://admin.acme.com/x", "https://a@b.com"]) {
150
+ const { run, calls } = fakeGcloud();
151
+ const r = await ensureSchedulerTick({ ...TARGET, adminUrl: bad }, { run, sleep });
152
+ expect(r.ok).toBe(false);
153
+ expect(calls).toEqual([]);
154
+ }
155
+ });
156
+
157
+ it("stops at the first failing step and says which", async () => {
158
+ const { run, calls } = fakeGcloud([[/^workflows deploy/, fail("PERMISSION_DENIED: nope")]]);
159
+ const r = await ensureSchedulerTick(TARGET, { run, sleep });
160
+ expect(r.ok).toBe(false);
161
+ expect(r.ok ? "" : r.error).toContain("deploy workflow caelo-production-scheduler-tick");
162
+ expect(calls.some((c) => c[0] === "scheduler")).toBe(false);
163
+ });
164
+ });
165
+
166
+ describe("schedulerWorkflowSource", () => {
167
+ const src = schedulerWorkflowSource({ serviceAccount: SA, origin: "https://admin.acme.com" });
168
+
169
+ it("signs a JWT for the admin origin as the scheduler itself", () => {
170
+ expect(src).toContain(`iss: "${SA}"`);
171
+ expect(src).toContain(`sub: "${SA}"`);
172
+ expect(src).toContain('aud: "https://admin.acme.com/*"');
173
+ expect(src).toContain(
174
+ `url: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${SA}:signJwt"`,
175
+ );
176
+ expect(src).toContain("type: OAuth2");
177
+ expect(src).toContain(`exp: \${now + 600}`);
178
+ });
179
+
180
+ it("posts the tick with the signed JWT and a bounded timeout", () => {
181
+ expect(src).toContain('url: "https://admin.acme.com/api/internal/scheduler/tick"');
182
+ expect(src).toContain(`Authorization: \${"Bearer " + signed.body.signedJwt}`);
183
+ expect(src).toMatch(/timeout: (\d+)/);
184
+ const timeout = Number(/timeout: (\d+)/.exec(src)?.[1]);
185
+ expect(timeout).toBeGreaterThan(300);
186
+ expect(timeout).toBeLessThanOrEqual(1800);
187
+ });
188
+ });
189
+
190
+ describe("tickOrigin", () => {
191
+ it("accepts the admin's origin, with or without a trailing slash", () => {
192
+ expect(tickOrigin("https://admin.acme.com/")).toEqual({
193
+ ok: true,
194
+ origin: "https://admin.acme.com",
195
+ });
196
+ expect(tickOrigin("https://caelo-production-admin-x-ew.a.run.app")).toEqual({
197
+ ok: true,
198
+ origin: "https://caelo-production-admin-x-ew.a.run.app",
199
+ });
200
+ });
201
+ });