@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.
- package/dist/adapter.d.ts +6 -0
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adopt-existing.d.ts +6 -2
- package/dist/adopt-existing.d.ts.map +1 -1
- package/dist/adopt-existing.js +57 -0
- package/dist/adopt-existing.js.map +1 -1
- package/dist/agent/command.d.ts +5 -2
- package/dist/agent/command.d.ts.map +1 -1
- package/dist/agent/command.js +1 -1
- package/dist/agent/command.js.map +1 -1
- package/dist/agent-db.js +196 -23
- package/dist/cli-routing.js +1 -1
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +59 -7
- package/dist/cli.js.map +1 -1
- package/dist/cloudsql-tls.d.ts +30 -0
- package/dist/cloudsql-tls.d.ts.map +1 -1
- package/dist/cloudsql-tls.js +7 -1
- package/dist/cloudsql-tls.js.map +1 -1
- package/dist/compose.d.ts +14 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +54 -1
- package/dist/compose.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 +12 -2
- package/dist/converge-job-gcp.d.ts.map +1 -1
- package/dist/converge-job-gcp.js +42 -3
- package/dist/converge-job-gcp.js.map +1 -1
- package/dist/converge-job-preflight.d.ts +34 -2
- package/dist/converge-job-preflight.d.ts.map +1 -1
- package/dist/converge-job-preflight.js +76 -1
- package/dist/converge-job-preflight.js.map +1 -1
- package/dist/converge-job-setup.d.ts.map +1 -1
- package/dist/converge-job-setup.js +4 -1
- package/dist/converge-job-setup.js.map +1 -1
- package/dist/converge-job.d.ts +44 -3
- package/dist/converge-job.d.ts.map +1 -1
- package/dist/converge-job.js +105 -11
- package/dist/converge-job.js.map +1 -1
- package/dist/gcp-names.d.ts +13 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +21 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- 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 +22 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job.d.ts +10 -2
- package/dist/install-job.d.ts.map +1 -1
- package/dist/install-job.js +10 -1
- package/dist/install-job.js.map +1 -1
- package/dist/install-state.d.ts +7 -0
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +3 -0
- package/dist/lifecycle.js.map +1 -1
- package/dist/mcp-auth-proxy-setup.d.ts +3 -1
- package/dist/mcp-auth-proxy-setup.d.ts.map +1 -1
- package/dist/mcp-auth-proxy-setup.js +9 -4
- package/dist/mcp-auth-proxy-setup.js.map +1 -1
- 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/runtime-secrets.d.ts +2 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +19 -15
- 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/self-hosted-roles.d.ts +16 -9
- package/dist/self-hosted-roles.d.ts.map +1 -1
- package/dist/self-hosted-roles.js +49 -15
- 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-upgrade.d.ts +7 -4
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +9 -5
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +67 -6
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +87 -6
- package/dist/stack-contract.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/converge.d.ts +5 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +110 -10
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts +6 -0
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +3 -0
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-gcp.d.ts +5 -4
- package/dist/upgrader-gcp.d.ts.map +1 -1
- package/dist/upgrader-gcp.js +5 -4
- package/dist/upgrader-gcp.js.map +1 -1
- package/dist/upgrader-iam-preflight.d.ts +8 -6
- package/dist/upgrader-iam-preflight.d.ts.map +1 -1
- package/dist/upgrader-iam-preflight.js +5 -1
- package/dist/upgrader-iam-preflight.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 +82 -12
- package/dist/wizards/gcp-cost.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +87 -11
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/adapter.ts +6 -0
- package/src/admin-memory.test.ts +1 -1
- package/src/adopt-existing.test.ts +46 -0
- package/src/adopt-existing.ts +58 -2
- package/src/agent/command.ts +6 -3
- package/src/caddy-redirects.test.ts +1 -1
- package/src/cli-routing.test.ts +1 -1
- package/src/cli-routing.ts +1 -1
- package/src/cli.ts +63 -8
- package/src/cloudsql-tls.test.ts +30 -0
- package/src/cloudsql-tls.ts +10 -3
- package/src/compose.ts +68 -0
- package/src/converge-db-trust.test.ts +237 -0
- package/src/converge-db-trust.ts +212 -0
- package/src/converge-job-gcp.ts +42 -2
- package/src/converge-job-preflight.test.ts +106 -0
- package/src/converge-job-preflight.ts +97 -3
- package/src/converge-job-setup.ts +4 -1
- package/src/converge-job.test.ts +213 -0
- package/src/converge-job.ts +175 -19
- package/src/database-tls-builders.test.ts +3 -2
- package/src/deployed-release.test.ts +3 -0
- package/src/gateway-env-isolation.test.ts +40 -7
- package/src/gcp-names.ts +25 -0
- package/src/index.test.ts +3 -3
- package/src/index.ts +6 -0
- package/src/infra-contract.test.ts +5 -3
- package/src/infra-contract.ts +22 -1
- package/src/install-job.ts +10 -1
- package/src/install-state.ts +7 -0
- package/src/lifecycle.ts +3 -0
- package/src/mcp-auth-proxy-setup.test.ts +33 -1
- package/src/mcp-auth-proxy-setup.ts +11 -4
- package/src/migration-runner.ts +14 -9
- package/src/migration-targets.ts +12 -0
- package/src/operator-access.test.ts +15 -0
- package/src/operator-access.ts +14 -0
- package/src/pool-budget-stack.ts +15 -0
- package/src/pool-budget-stacks.test.ts +38 -13
- 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/runtime-secrets.test.ts +18 -8
- package/src/runtime-secrets.ts +37 -18
- package/src/scheduler-setup.test.ts +201 -0
- package/src/scheduler-setup.ts +375 -0
- package/src/self-hosted-backup.test.ts +1 -0
- package/src/self-hosted-compose.test.ts +72 -1
- package/src/self-hosted-roles.integration.test.ts +2 -0
- package/src/self-hosted-roles.test.ts +49 -19
- package/src/self-hosted-roles.ts +60 -20
- 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 +1 -0
- package/src/self-hosted-upgrade.test.ts +2 -1
- package/src/self-hosted-upgrade.ts +14 -8
- package/src/stack-contract.test.ts +31 -3
- package/src/stack-contract.ts +113 -6
- package/src/stack-converge-secrets.test.ts +2 -0
- package/src/stack-converge.test.ts +6 -4
- package/src/staging-database.test.ts +114 -0
- package/src/staging-database.ts +148 -0
- package/src/upgrade/converge.ts +130 -9
- package/src/upgrade/upgrade.test.ts +85 -2
- package/src/upgrade/upgrade.ts +9 -0
- package/src/upgrader-gcp.ts +5 -4
- package/src/upgrader-iam-preflight.ts +15 -10
- package/src/wizard.ts +19 -0
- package/src/wizards/gcp-cost.test.ts +54 -1
- package/src/wizards/gcp-cost.ts +102 -12
- package/src/wizards/gcp.ts +109 -8
- package/stacks/aws/Pulumi.yaml +1 -1
- package/stacks/aws/index.ts +41 -1
- package/stacks/azure/index.ts +51 -3
- package/stacks/gcp/Pulumi.yaml +1 -1
- package/stacks/gcp/README.md +1 -0
- package/stacks/gcp/index.ts +50 -1
- package/stacks/gcp-firebase/Pulumi.yaml +1 -1
- package/stacks/gcp-firebase/index.ts +50 -1
- package/stacks/self-hosted/Pulumi.yaml +3 -0
- package/stacks/self-hosted/README.md +4 -0
- 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
|
|
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
|
-
|
|
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
|
|
59
|
-
* instead of throwing; `failed`
|
|
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 = "
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 {
|
|
102
|
+
return {
|
|
103
|
+
id,
|
|
104
|
+
status: "applied",
|
|
105
|
+
detail: `${GATEWAY_DATABASE_LOGINS.join(" and ")} hold no database roles on ${sqlInstance}`,
|
|
106
|
+
};
|
|
93
107
|
}
|
package/src/release-contract.ts
CHANGED
|
@@ -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(
|
|
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
|
|
|
@@ -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
|
});
|
package/src/runtime-secrets.ts
CHANGED
|
@@ -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
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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(
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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 & {
|
|
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(
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
+
});
|