@caelo-cms/provisioning 0.11.9 → 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.
- package/dist/adapter.d.ts +6 -0
- package/dist/adapter.d.ts.map +1 -1
- package/dist/admin-domain.d.ts +10 -1
- package/dist/admin-domain.d.ts.map +1 -1
- package/dist/admin-domain.js +13 -3
- package/dist/admin-domain.js.map +1 -1
- package/dist/adopt-existing.d.ts +12 -3
- package/dist/adopt-existing.d.ts.map +1 -1
- package/dist/adopt-existing.js +143 -0
- package/dist/adopt-existing.js.map +1 -1
- package/dist/agent/command.d.ts +23 -0
- package/dist/agent/command.d.ts.map +1 -1
- package/dist/agent/command.js +32 -9
- package/dist/agent/command.js.map +1 -1
- package/dist/agent/run.d.ts +2 -0
- package/dist/agent/run.d.ts.map +1 -1
- package/dist/agent/run.js +5 -1
- package/dist/agent/run.js.map +1 -1
- package/dist/agent-db.js +69 -10
- package/dist/aws-task-roles.d.ts +4 -2
- package/dist/aws-task-roles.d.ts.map +1 -1
- package/dist/aws-task-roles.js +4 -1
- package/dist/aws-task-roles.js.map +1 -1
- package/dist/caddy.d.ts +21 -4
- package/dist/caddy.d.ts.map +1 -1
- package/dist/caddy.js +27 -3
- package/dist/caddy.js.map +1 -1
- package/dist/cli-routing.js +1 -1
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +68 -7
- package/dist/cli.js.map +1 -1
- package/dist/cloudsql-tls.d.ts +93 -0
- package/dist/cloudsql-tls.d.ts.map +1 -0
- package/dist/cloudsql-tls.js +295 -0
- package/dist/cloudsql-tls.js.map +1 -0
- package/dist/compose.d.ts +43 -6
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +148 -23
- package/dist/compose.js.map +1 -1
- package/dist/converge-checkpoint.d.ts +1 -0
- package/dist/converge-checkpoint.d.ts.map +1 -1
- package/dist/converge-checkpoint.js +8 -1
- package/dist/converge-checkpoint.js.map +1 -1
- package/dist/converge-db-trust.d.ts +44 -0
- package/dist/converge-db-trust.d.ts.map +1 -0
- package/dist/converge-db-trust.js +158 -0
- package/dist/converge-db-trust.js.map +1 -0
- package/dist/converge-job-gcp.d.ts +5 -2
- package/dist/converge-job-gcp.d.ts.map +1 -1
- package/dist/converge-job-gcp.js +11 -13
- package/dist/converge-job-gcp.js.map +1 -1
- package/dist/converge-job-preflight.d.ts.map +1 -1
- package/dist/converge-job-preflight.js +8 -0
- package/dist/converge-job-preflight.js.map +1 -1
- package/dist/converge-job.d.ts +17 -0
- package/dist/converge-job.d.ts.map +1 -1
- package/dist/converge-job.js +12 -0
- package/dist/converge-job.js.map +1 -1
- package/dist/deployed-release.d.ts +4 -1
- package/dist/deployed-release.d.ts.map +1 -1
- package/dist/deployed-release.js +18 -8
- package/dist/deployed-release.js.map +1 -1
- package/dist/gcp-names.d.ts +34 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +45 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/gcp-url-map.d.ts.map +1 -1
- package/dist/gcp-url-map.js +6 -2
- package/dist/gcp-url-map.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.d.ts.map +1 -1
- package/dist/infra-contract.js +45 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job.d.ts +4 -2
- package/dist/install-job.d.ts.map +1 -1
- package/dist/install-job.js +4 -1
- package/dist/install-job.js.map +1 -1
- package/dist/install-state.d.ts +13 -0
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +17 -4
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +20 -8
- package/dist/lifecycle.js.map +1 -1
- package/dist/mcp-auth-proxy-setup.d.ts +48 -0
- package/dist/mcp-auth-proxy-setup.d.ts.map +1 -0
- package/dist/mcp-auth-proxy-setup.js +280 -0
- package/dist/mcp-auth-proxy-setup.js.map +1 -0
- package/dist/migration-runner.d.ts.map +1 -1
- package/dist/migration-runner.js +8 -3
- package/dist/migration-runner.js.map +1 -1
- package/dist/migration-targets.d.ts +11 -0
- package/dist/migration-targets.d.ts.map +1 -0
- package/dist/migration-targets.js +12 -0
- package/dist/migration-targets.js.map +1 -0
- package/dist/operator-access.d.ts +10 -0
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +5 -0
- package/dist/operator-access.js.map +1 -1
- package/dist/pool-budget-stack.d.ts +13 -1
- package/dist/pool-budget-stack.d.ts.map +1 -1
- package/dist/pool-budget-stack.js +11 -1
- package/dist/pool-budget-stack.js.map +1 -1
- package/dist/public-role-isolation.d.ts +7 -3
- package/dist/public-role-isolation.d.ts.map +1 -1
- package/dist/public-role-isolation.js +25 -13
- package/dist/public-role-isolation.js.map +1 -1
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +3 -0
- package/dist/release-contract.js.map +1 -1
- package/dist/release-manifest.d.ts +25 -1
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +36 -3
- package/dist/release-manifest.js.map +1 -1
- package/dist/remote-mcp.d.ts +55 -18
- package/dist/remote-mcp.d.ts.map +1 -1
- package/dist/remote-mcp.js +307 -77
- package/dist/remote-mcp.js.map +1 -1
- package/dist/runtime-secrets.d.ts +11 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +40 -25
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/scheduler-setup.d.ts +75 -0
- package/dist/scheduler-setup.d.ts.map +1 -0
- package/dist/scheduler-setup.js +331 -0
- package/dist/scheduler-setup.js.map +1 -0
- package/dist/seal-key-rotation.d.ts +7 -7
- package/dist/seal-key-rotation.d.ts.map +1 -1
- package/dist/seal-key-rotation.js +22 -22
- package/dist/seal-key-rotation.js.map +1 -1
- package/dist/self-hosted-db-tls.d.ts +37 -0
- package/dist/self-hosted-db-tls.d.ts.map +1 -0
- package/dist/self-hosted-db-tls.js +220 -0
- package/dist/self-hosted-db-tls.js.map +1 -0
- package/dist/self-hosted-release.d.ts +3 -1
- package/dist/self-hosted-release.d.ts.map +1 -1
- package/dist/self-hosted-release.js +33 -9
- package/dist/self-hosted-release.js.map +1 -1
- package/dist/self-hosted-roles.d.ts +43 -2
- package/dist/self-hosted-roles.d.ts.map +1 -1
- package/dist/self-hosted-roles.js +87 -1
- package/dist/self-hosted-roles.js.map +1 -1
- package/dist/self-hosted-scheduler-interval.d.ts +21 -0
- package/dist/self-hosted-scheduler-interval.d.ts.map +1 -0
- package/dist/self-hosted-scheduler-interval.js +68 -0
- package/dist/self-hosted-scheduler-interval.js.map +1 -0
- package/dist/self-hosted-start.d.ts +0 -21
- package/dist/self-hosted-start.d.ts.map +1 -1
- package/dist/self-hosted-start.js +39 -1
- package/dist/self-hosted-start.js.map +1 -1
- package/dist/self-hosted-upgrade.d.ts +19 -7
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +78 -25
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +234 -28
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +397 -39
- package/dist/stack-contract.js.map +1 -1
- package/dist/stack-converge.d.ts +31 -0
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +71 -30
- package/dist/stack-converge.js.map +1 -1
- package/dist/staging-database.d.ts +22 -0
- package/dist/staging-database.d.ts.map +1 -0
- package/dist/staging-database.js +117 -0
- package/dist/staging-database.js.map +1 -0
- package/dist/upgrade/app-roll.d.ts +25 -4
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +63 -17
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/converge.d.ts +5 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +207 -20
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/health.d.ts +31 -7
- package/dist/upgrade/health.d.ts.map +1 -1
- package/dist/upgrade/health.js +41 -17
- package/dist/upgrade/health.js.map +1 -1
- package/dist/upgrade/image-forms.d.ts +1 -1
- package/dist/upgrade/image-forms.d.ts.map +1 -1
- package/dist/upgrade/image-forms.js +24 -7
- package/dist/upgrade/image-forms.js.map +1 -1
- package/dist/upgrade/release-images.d.ts +12 -3
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +13 -4
- package/dist/upgrade/release-images.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts +19 -3
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +37 -2
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-gcp.d.ts +13 -0
- package/dist/upgrader-gcp.d.ts.map +1 -1
- package/dist/upgrader-gcp.js +50 -1
- package/dist/upgrader-gcp.js.map +1 -1
- package/dist/upgrader-iam-preflight.d.ts.map +1 -1
- package/dist/upgrader-iam-preflight.js +6 -1
- package/dist/upgrader-iam-preflight.js.map +1 -1
- package/dist/upgrader-job.d.ts +1 -1
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.js +20 -14
- package/dist/upgrader-job.js.map +1 -1
- package/dist/upgrader-records.d.ts +1 -1
- package/dist/upgrader-records.d.ts.map +1 -1
- package/dist/upgrader-records.js +26 -7
- package/dist/upgrader-records.js.map +1 -1
- package/dist/upgrader-setup.d.ts.map +1 -1
- package/dist/upgrader-setup.js +10 -5
- package/dist/upgrader-setup.js.map +1 -1
- package/dist/wizard.d.ts +7 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +11 -0
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp-cost.d.ts +31 -0
- package/dist/wizards/gcp-cost.d.ts.map +1 -1
- package/dist/wizards/gcp-cost.js +103 -18
- package/dist/wizards/gcp-cost.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +129 -27
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/adapter.ts +6 -0
- package/src/admin-domain.test.ts +9 -8
- package/src/admin-domain.ts +13 -3
- package/src/admin-memory.test.ts +2 -1
- package/src/adopt-existing.test.ts +85 -0
- package/src/adopt-existing.ts +141 -3
- package/src/agent/command.test.ts +49 -1
- package/src/agent/command.ts +39 -9
- package/src/agent/run.test.ts +19 -0
- package/src/agent/run.ts +11 -2
- package/src/aws-task-roles.ts +5 -2
- package/src/caddy-redirects.docker.test.ts +2 -0
- package/src/caddy-redirects.test.ts +4 -1
- package/src/caddy-validate.test.ts +7 -1
- package/src/caddy.test.ts +39 -1
- package/src/caddy.ts +36 -4
- package/src/checkpoint-secrets.test.ts +14 -6
- package/src/cli-routing.test.ts +1 -1
- package/src/cli-routing.ts +1 -1
- package/src/cli.ts +71 -5
- package/src/cloudsql-tls.test.ts +255 -0
- package/src/cloudsql-tls.ts +359 -0
- package/src/compose.ts +193 -24
- package/src/converge-checkpoint.ts +8 -1
- package/src/converge-db-trust.test.ts +237 -0
- package/src/converge-db-trust.ts +212 -0
- package/src/converge-job-gcp.ts +11 -12
- package/src/converge-job-preflight.test.ts +5 -0
- package/src/converge-job-preflight.ts +8 -0
- package/src/converge-job.test.ts +70 -0
- package/src/converge-job.ts +32 -0
- package/src/database-tls-builders.test.ts +56 -0
- package/src/deployed-release.test.ts +107 -40
- package/src/deployed-release.ts +30 -9
- package/src/gateway-env-isolation.test.ts +50 -12
- package/src/gcp-names.ts +56 -0
- package/src/gcp-url-map.test.ts +6 -8
- package/src/gcp-url-map.ts +6 -2
- package/src/index.test.ts +10 -4
- package/src/index.ts +7 -0
- package/src/infra-contract.test.ts +7 -3
- package/src/infra-contract.ts +45 -1
- package/src/install-job.ts +4 -1
- package/src/install-state.ts +33 -4
- package/src/lifecycle.ts +23 -11
- package/src/mcp-auth-proxy-setup.test.ts +305 -0
- package/src/mcp-auth-proxy-setup.ts +374 -0
- package/src/mcp-iap-stacks.test.ts +1 -1
- package/src/media-volume.test.ts +1 -1
- package/src/migration-runner.ts +14 -9
- package/src/migration-targets.ts +12 -0
- package/src/operator-access-stacks.test.ts +1 -1
- package/src/operator-access.test.ts +20 -4
- package/src/operator-access.ts +14 -0
- package/src/pool-budget-stack.ts +15 -0
- package/src/pool-budget-stacks.test.ts +39 -14
- package/src/public-role-isolation.test.ts +18 -8
- package/src/public-role-isolation.ts +31 -17
- package/src/release-contract.ts +3 -0
- package/src/release-manifest.test.ts +25 -0
- package/src/release-manifest.ts +59 -4
- package/src/remote-mcp-stack-config.test.ts +19 -9
- package/src/remote-mcp.test.ts +252 -57
- package/src/remote-mcp.ts +336 -77
- package/src/runtime-secrets.test.ts +37 -14
- package/src/runtime-secrets.ts +87 -27
- package/src/scheduler-setup.test.ts +201 -0
- package/src/scheduler-setup.ts +375 -0
- package/src/seal-key-rotation.test.ts +2 -2
- package/src/seal-key-rotation.ts +25 -25
- package/src/self-hosted-backup.test.ts +1 -0
- package/src/self-hosted-compose.test.ts +180 -6
- package/src/self-hosted-db-tls.test.ts +111 -0
- package/src/self-hosted-db-tls.ts +287 -0
- package/src/self-hosted-release-resolve.test.ts +71 -0
- package/src/self-hosted-release.ts +41 -11
- package/src/self-hosted-roles.integration.test.ts +2 -0
- package/src/self-hosted-roles.test.ts +87 -3
- package/src/self-hosted-roles.ts +101 -3
- package/src/self-hosted-scheduler-interval.test.ts +66 -0
- package/src/self-hosted-scheduler-interval.ts +79 -0
- package/src/self-hosted-start.test.ts +35 -2
- package/src/self-hosted-start.ts +20 -2
- package/src/self-hosted-upgrade.test.ts +104 -14
- package/src/self-hosted-upgrade.ts +123 -31
- package/src/stack-contract-parity.test.ts +79 -12
- package/src/stack-contract.test.ts +199 -31
- package/src/stack-contract.ts +472 -45
- package/src/stack-converge-secrets.test.ts +28 -13
- package/src/stack-converge.test.ts +203 -28
- package/src/stack-converge.ts +92 -30
- package/src/staging-database.test.ts +114 -0
- package/src/staging-database.ts +148 -0
- package/src/state-store.test.ts +2 -1
- package/src/upgrade/app-roll.ts +97 -19
- package/src/upgrade/converge.ts +249 -19
- package/src/upgrade/health.test.ts +63 -25
- package/src/upgrade/health.ts +54 -22
- package/src/upgrade/image-forms.test.ts +34 -0
- package/src/upgrade/image-forms.ts +23 -8
- package/src/upgrade/release-images.test.ts +5 -1
- package/src/upgrade/release-images.ts +22 -6
- package/src/upgrade/upgrade.test.ts +438 -46
- package/src/upgrade/upgrade.ts +64 -6
- package/src/upgrader-gcp.ts +66 -3
- package/src/upgrader-iam-preflight.test.ts +19 -0
- package/src/upgrader-iam-preflight.ts +6 -0
- package/src/upgrader-job.test.ts +101 -27
- package/src/upgrader-job.ts +27 -14
- package/src/upgrader-records.test.ts +97 -12
- package/src/upgrader-records.ts +28 -11
- package/src/upgrader-setup.test.ts +2 -0
- package/src/upgrader-setup.ts +10 -4
- package/src/wizard.ts +19 -0
- package/src/wizards/gcp-cost.test.ts +103 -0
- package/src/wizards/gcp-cost.ts +125 -18
- package/src/wizards/gcp.ts +164 -22
- package/stacks/aws/Pulumi.yaml +1 -1
- package/stacks/aws/index.ts +72 -14
- package/stacks/azure/index.ts +108 -21
- package/stacks/gcp/Pulumi.yaml +1 -1
- package/stacks/gcp/README.md +1 -0
- package/stacks/gcp/index.ts +133 -24
- package/stacks/gcp-firebase/Pulumi.yaml +1 -1
- package/stacks/gcp-firebase/index.ts +212 -34
- package/stacks/self-hosted/Pulumi.yaml +3 -0
- package/stacks/self-hosted/README.md +6 -1
- package/stacks/self-hosted/index.ts +40 -5
|
@@ -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(
|
|
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(
|
|
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-
|
|
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,
|
|
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(
|
|
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(
|
|
296
|
+
expect(lines().filter((l) => l.startsWith("secrets create"))).toHaveLength(7);
|
|
288
297
|
});
|
|
289
298
|
});
|
|
290
299
|
|
|
@@ -314,7 +323,7 @@ describe("rotateRuntimeSecret", () => {
|
|
|
314
323
|
projectId: "acme",
|
|
315
324
|
region: "europe-west1",
|
|
316
325
|
env: "production",
|
|
317
|
-
services: { admin: "adm-svc", gateway: "gw-svc" },
|
|
326
|
+
services: { admin: "adm-svc", gateway: "gw-svc", "mcp-auth-proxy": "px-svc" },
|
|
318
327
|
sqlInstance: "caelo-production-pg-1",
|
|
319
328
|
};
|
|
320
329
|
const ref = (name: string, secret: string, version = "latest") => ({
|
|
@@ -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"),
|
|
@@ -337,6 +347,10 @@ describe("rotateRuntimeSecret", () => {
|
|
|
337
347
|
"run services describe gw-svc": [
|
|
338
348
|
service([ref("PUBLIC_DATABASE_PASSWORD", "public-role-password")]),
|
|
339
349
|
],
|
|
350
|
+
// #1328 — the proxy reads none of the rotatable database secrets.
|
|
351
|
+
"run services describe px-svc": [
|
|
352
|
+
service([ref("CAELO_MCP_OAUTH_SEAL_KEY", "mcp-oauth-seal-key")]),
|
|
353
|
+
],
|
|
340
354
|
"auth print-access-token": [ok("tok\n")],
|
|
341
355
|
});
|
|
342
356
|
|
|
@@ -451,7 +465,10 @@ describe("rotateRuntimeSecret", () => {
|
|
|
451
465
|
const { run, lines } = fakeGcloud({
|
|
452
466
|
"run services describe adm-svc": [
|
|
453
467
|
service([
|
|
454
|
-
{
|
|
468
|
+
{
|
|
469
|
+
name: "ADMIN_DATABASE_URL",
|
|
470
|
+
value: "postgres://admin_role:pw@db1.p-label.europe-west1.sql-psa.goog:5432/cms_admin",
|
|
471
|
+
},
|
|
455
472
|
]),
|
|
456
473
|
],
|
|
457
474
|
});
|
|
@@ -534,17 +551,23 @@ describe("parseAdminConfig", () => {
|
|
|
534
551
|
it("marks an admin still on an inline-password URL (jobs on its image need upgrade first)", () => {
|
|
535
552
|
const cfg = parseAdminConfig(
|
|
536
553
|
admin([
|
|
537
|
-
{
|
|
554
|
+
{
|
|
555
|
+
name: "ADMIN_DATABASE_URL",
|
|
556
|
+
value: "postgres://admin_role:pw@db1.p-label.europe-west1.sql-psa.goog:5432/cms_admin",
|
|
557
|
+
},
|
|
538
558
|
]),
|
|
539
559
|
);
|
|
540
|
-
expect(cfg?.databaseHost).toBe("
|
|
560
|
+
expect(cfg?.databaseHost).toBe("db1.p-label.europe-west1.sql-psa.goog");
|
|
541
561
|
expect(cfg?.secretEnv).toBe(false);
|
|
542
562
|
});
|
|
543
563
|
|
|
544
564
|
it("marks an admin that reads the password from Secret Manager", () => {
|
|
545
565
|
const cfg = parseAdminConfig(
|
|
546
566
|
admin([
|
|
547
|
-
{
|
|
567
|
+
{
|
|
568
|
+
name: "ADMIN_DATABASE_URL",
|
|
569
|
+
value: "postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_admin",
|
|
570
|
+
},
|
|
548
571
|
{
|
|
549
572
|
name: "ADMIN_DATABASE_PASSWORD",
|
|
550
573
|
valueFrom: {
|
|
@@ -559,10 +582,10 @@ describe("parseAdminConfig", () => {
|
|
|
559
582
|
|
|
560
583
|
describe("migrationJobEnvArgs", () => {
|
|
561
584
|
it("gives the job password-less URLs and the password as a secret reference", () => {
|
|
562
|
-
const args = migrationJobEnvArgs("
|
|
585
|
+
const args = migrationJobEnvArgs("db1.p-label.europe-west1.sql-psa.goog");
|
|
563
586
|
expect(args).toEqual([
|
|
564
|
-
"--set-env-vars=ADMIN_DATABASE_URL=postgresql://admin_role@
|
|
565
|
-
"--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",
|
|
566
589
|
]);
|
|
567
590
|
});
|
|
568
591
|
});
|
package/src/runtime-secrets.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* Pulumi) creates and maintains, through gcloud:
|
|
6
6
|
*
|
|
7
7
|
* - {@link ensureGatewayServiceAccount}: the gateway's own run SA, so it
|
|
8
|
-
* can be granted only the secrets the gateway reads
|
|
8
|
+
* can be granted only the secrets the gateway reads; the same for the
|
|
9
|
+
* MCP-Auth-Proxy ({@link ensureMcpAuthProxyServiceAccount}, #1328).
|
|
9
10
|
* - {@link ensureGeneratedSecrets}: the secrets in
|
|
10
11
|
* `CLI_GENERATED_SECRETS` (stack-contract.ts). The wizard runs it before
|
|
11
12
|
* `pulumi up`, upgrade on every roll, so installs that predate a secret
|
|
@@ -23,9 +24,16 @@
|
|
|
23
24
|
import { randomBytes } from "node:crypto";
|
|
24
25
|
import { gcloud as defaultGcloud } from "./gcloud.js";
|
|
25
26
|
import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
|
|
26
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
gatewayServiceAccountEmail,
|
|
29
|
+
gatewayServiceAccountId,
|
|
30
|
+
gcpSecretId,
|
|
31
|
+
mcpAuthProxyServiceAccountEmail,
|
|
32
|
+
mcpAuthProxyServiceAccountId,
|
|
33
|
+
} from "./gcp-names.js";
|
|
27
34
|
import {
|
|
28
35
|
CLI_GENERATED_SECRETS,
|
|
36
|
+
CLOUD_RUN_SLUGS,
|
|
29
37
|
type CloudRunSlug,
|
|
30
38
|
DATABASE_ROLE_SECRET,
|
|
31
39
|
type DatabaseRole,
|
|
@@ -84,14 +92,47 @@ const ALREADY_EXISTS = /ALREADY_EXISTS|already exists/i;
|
|
|
84
92
|
// ===========================================================================
|
|
85
93
|
|
|
86
94
|
/** Create the gateway's run SA unless it exists. */
|
|
87
|
-
export
|
|
95
|
+
export function ensureGatewayServiceAccount(
|
|
96
|
+
install: { readonly projectId: string; readonly env: string },
|
|
97
|
+
deps: RuntimeDeps = {},
|
|
98
|
+
): Promise<EnsureOutcome> {
|
|
99
|
+
return ensureServiceAccount(
|
|
100
|
+
install.projectId,
|
|
101
|
+
gatewayServiceAccountId(install.env),
|
|
102
|
+
gatewayServiceAccountEmail(install.projectId, install.env),
|
|
103
|
+
`Caelo ${install.env} gateway`,
|
|
104
|
+
deps,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* #1328 — create the MCP-Auth-Proxy's run SA unless it exists. It gets no
|
|
110
|
+
* role here or anywhere: only `secretAccessor` on its two secrets
|
|
111
|
+
* (stack-contract.ts runtimeSecretBindings).
|
|
112
|
+
*/
|
|
113
|
+
export function ensureMcpAuthProxyServiceAccount(
|
|
88
114
|
install: { readonly projectId: string; readonly env: string },
|
|
89
115
|
deps: RuntimeDeps = {},
|
|
116
|
+
): Promise<EnsureOutcome> {
|
|
117
|
+
return ensureServiceAccount(
|
|
118
|
+
install.projectId,
|
|
119
|
+
mcpAuthProxyServiceAccountId(install.env),
|
|
120
|
+
mcpAuthProxyServiceAccountEmail(install.projectId, install.env),
|
|
121
|
+
`Caelo ${install.env} MCP-Auth-Proxy`,
|
|
122
|
+
deps,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function ensureServiceAccount(
|
|
127
|
+
projectId: string,
|
|
128
|
+
accountId: string,
|
|
129
|
+
email: string,
|
|
130
|
+
displayName: string,
|
|
131
|
+
deps: RuntimeDeps,
|
|
90
132
|
): Promise<EnsureOutcome> {
|
|
91
133
|
const run = deps.run ?? defaultGcloud;
|
|
92
|
-
const email = gatewayServiceAccountEmail(install.projectId, install.env);
|
|
93
134
|
const id = `service account ${email}`;
|
|
94
|
-
const project = `--project=${
|
|
135
|
+
const project = `--project=${projectId}`;
|
|
95
136
|
const describe = await run([
|
|
96
137
|
"iam",
|
|
97
138
|
"service-accounts",
|
|
@@ -108,9 +149,9 @@ export async function ensureGatewayServiceAccount(
|
|
|
108
149
|
"iam",
|
|
109
150
|
"service-accounts",
|
|
110
151
|
"create",
|
|
111
|
-
|
|
152
|
+
accountId,
|
|
112
153
|
project,
|
|
113
|
-
`--display-name
|
|
154
|
+
`--display-name=${displayName}`,
|
|
114
155
|
"--quiet",
|
|
115
156
|
]);
|
|
116
157
|
if (create.ok || ALREADY_EXISTS.test(create.stderr)) return { id, status: "applied" };
|
|
@@ -470,7 +511,7 @@ async function checkRotationReaders(
|
|
|
470
511
|
const isSecret = (live: { kind: string; secret?: string } | undefined) =>
|
|
471
512
|
live?.kind === "secret" &&
|
|
472
513
|
(live.secret === secretId || (live.secret?.endsWith(`/secrets/${secretId}`) ?? false));
|
|
473
|
-
for (const service of
|
|
514
|
+
for (const service of CLOUD_RUN_SLUGS) {
|
|
474
515
|
const vars = Object.entries(SERVICE_SECRET_ENV[service])
|
|
475
516
|
.filter(([, s]) => s === secret)
|
|
476
517
|
.map(([name]) => name);
|
|
@@ -531,6 +572,12 @@ async function setSqlUserPassword(
|
|
|
531
572
|
http: HttpFetch,
|
|
532
573
|
sleep: Sleep,
|
|
533
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",
|
|
534
581
|
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
535
582
|
const token = await run(["auth", "print-access-token"]);
|
|
536
583
|
const bearer = token.ok ? token.stdout.trim() : "";
|
|
@@ -546,11 +593,17 @@ async function setSqlUserPassword(
|
|
|
546
593
|
ok: false as const,
|
|
547
594
|
error: `${what}: HTTP ${res.status} ${(await res.text()).slice(0, 500)}`,
|
|
548
595
|
});
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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);
|
|
554
607
|
type Operation = {
|
|
555
608
|
name?: string;
|
|
556
609
|
status?: string;
|
|
@@ -652,12 +705,13 @@ export async function rotateRuntimeSecret(
|
|
|
652
705
|
}
|
|
653
706
|
const previous = current.stdout;
|
|
654
707
|
const setPassword = (role: string, password: string) =>
|
|
655
|
-
setSqlUserPassword(
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
password,
|
|
660
|
-
|
|
708
|
+
setSqlUserPassword(
|
|
709
|
+
run,
|
|
710
|
+
http,
|
|
711
|
+
sleep,
|
|
712
|
+
{ projectId: target.projectId, instance, role, password },
|
|
713
|
+
"update",
|
|
714
|
+
);
|
|
661
715
|
const changed: string[] = [];
|
|
662
716
|
const restore = async (): Promise<string> => {
|
|
663
717
|
const failed: string[] = [];
|
|
@@ -696,7 +750,7 @@ export async function rotateRuntimeSecret(
|
|
|
696
750
|
steps.push(`added a new version to ${secretId}`);
|
|
697
751
|
|
|
698
752
|
const failures: string[] = [];
|
|
699
|
-
for (const service of
|
|
753
|
+
for (const service of CLOUD_RUN_SLUGS) {
|
|
700
754
|
if (!serviceSecrets(service).includes(secret)) continue;
|
|
701
755
|
const name = target.services[service];
|
|
702
756
|
const [update, flip] = rollArgs(name, target.region, target.projectId) as [string[], string[]];
|
|
@@ -766,7 +820,12 @@ export async function readSecretValue(
|
|
|
766
820
|
export async function ensureDatabaseRolePassword(
|
|
767
821
|
target: DatabaseRoleTarget,
|
|
768
822
|
role: DatabaseRole,
|
|
769
|
-
deps: RuntimeDeps & {
|
|
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
|
+
} = {},
|
|
770
829
|
): Promise<EnsureOutcome> {
|
|
771
830
|
const run = deps.run ?? defaultGcloud;
|
|
772
831
|
const sleep = deps.sleep ?? realSleep;
|
|
@@ -778,11 +837,12 @@ export async function ensureDatabaseRolePassword(
|
|
|
778
837
|
if (!value.ok) return { id, status: "failed", error: value.error };
|
|
779
838
|
password = value.value;
|
|
780
839
|
}
|
|
781
|
-
const set = await setSqlUserPassword(
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
password,
|
|
786
|
-
|
|
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
|
+
);
|
|
787
847
|
return set.ok ? { id, status: "applied" } : { id, status: "failed", error: set.error };
|
|
788
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
|
+
});
|