@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
package/src/converge-job.test.ts
CHANGED
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
type ConvergeQueue,
|
|
20
20
|
type ConvergeUpgradeOutcome,
|
|
21
21
|
runConvergeJob,
|
|
22
|
+
SELF_GRANT_RECHECK_ATTEMPTS,
|
|
23
|
+
type SecureDatabaseResult,
|
|
22
24
|
} from "./converge-job.js";
|
|
23
25
|
import type { ExecutionState } from "./install-job.js";
|
|
24
26
|
import type { InstallMetadata } from "./install-state.js";
|
|
@@ -147,6 +149,10 @@ function harness(
|
|
|
147
149
|
imageSigned?: boolean;
|
|
148
150
|
/** What IAM answers the self-check; every permission asked for by default. */
|
|
149
151
|
iam?: IamTester;
|
|
152
|
+
/** #1373 — the database TLS step's answer; null: the queue dials the DNS name already. */
|
|
153
|
+
secureDatabase?: SecureDatabaseResult | null;
|
|
154
|
+
/** What a self-grant answers; ok by default. */
|
|
155
|
+
grant?: (role: string) => { ok: true } | { ok: false; error: string };
|
|
150
156
|
} = {},
|
|
151
157
|
): Harness {
|
|
152
158
|
const calls: string[] = [];
|
|
@@ -156,11 +162,25 @@ function harness(
|
|
|
156
162
|
let stored = opts.checkpoint ?? null;
|
|
157
163
|
const deps: ConvergeJobDeps = {
|
|
158
164
|
queue: q.queue,
|
|
165
|
+
secureDatabase:
|
|
166
|
+
opts.secureDatabase === undefined || opts.secureDatabase === null
|
|
167
|
+
? null
|
|
168
|
+
: async () => {
|
|
169
|
+
calls.push("secure database");
|
|
170
|
+
return opts.secureDatabase as SecureDatabaseResult;
|
|
171
|
+
},
|
|
159
172
|
projectId: "acme",
|
|
160
173
|
region: "europe-west1",
|
|
161
174
|
iam: opts.iam ?? {
|
|
162
175
|
granted: async (_resource, permissions) => ({ ok: true, granted: permissions }),
|
|
163
176
|
},
|
|
177
|
+
grantOwnRole: async (role) => {
|
|
178
|
+
calls.push(`grant ${role}`);
|
|
179
|
+
return opts.grant ? opts.grant(role) : { ok: true };
|
|
180
|
+
},
|
|
181
|
+
sleep: async () => {
|
|
182
|
+
calls.push("sleep");
|
|
183
|
+
},
|
|
164
184
|
execution: "caelo-production-converge-run1",
|
|
165
185
|
ownVersion: opts.ownVersion ?? "0.11.0",
|
|
166
186
|
fetchManifest: async (v) => {
|
|
@@ -455,6 +475,139 @@ describe("runConvergeJob — stage 2 (the target release's own installer)", () =
|
|
|
455
475
|
});
|
|
456
476
|
});
|
|
457
477
|
|
|
478
|
+
/**
|
|
479
|
+
* An IAM that lacks the workflows.* permissions (an install set up before
|
|
480
|
+
* 0.11.11 added roles/workflows.editor, the searchviu-website case) until
|
|
481
|
+
* the job granted the role, and confirms it `lag` asks later.
|
|
482
|
+
*/
|
|
483
|
+
function lacksWorkflows(lag = 0, extraMissing: (p: string) => boolean = () => false) {
|
|
484
|
+
const state = { granted: false, asksSinceGrant: 0 };
|
|
485
|
+
const iam: IamTester = {
|
|
486
|
+
granted: async (_resource, permissions) => {
|
|
487
|
+
if (state.granted) state.asksSinceGrant += 1;
|
|
488
|
+
const workflowsHeld = state.granted && state.asksSinceGrant > lag;
|
|
489
|
+
return {
|
|
490
|
+
ok: true,
|
|
491
|
+
granted: permissions.filter(
|
|
492
|
+
(p) => !extraMissing(p) && (workflowsHeld || !p.startsWith("workflows.")),
|
|
493
|
+
),
|
|
494
|
+
};
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
const grant = (role: string) => {
|
|
498
|
+
if (role === "roles/workflows.editor") state.granted = true;
|
|
499
|
+
return { ok: true } as const;
|
|
500
|
+
};
|
|
501
|
+
return { iam, grant };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
describe("runConvergeJob — the job grants itself the roles its release names (2026-11-03)", () => {
|
|
505
|
+
it("stage 1 leaves a missing allowlisted role to stage 2 and hands over without granting", async () => {
|
|
506
|
+
const { iam, grant } = lacksWorkflows();
|
|
507
|
+
const h = harness({ ownVersion: "0.10.53", iam, grant });
|
|
508
|
+
const r = await runConvergeJob(h.deps);
|
|
509
|
+
expect(r.status).toBeNull();
|
|
510
|
+
expect(h.calls.some((c) => c.startsWith("grant"))).toBe(false);
|
|
511
|
+
expect(h.calls).toContain("start");
|
|
512
|
+
expect(h.q.ends).toEqual([]);
|
|
513
|
+
expect(
|
|
514
|
+
h.q.events.some(
|
|
515
|
+
(e) =>
|
|
516
|
+
e.message.includes("lacks roles/workflows.editor") &&
|
|
517
|
+
e.message.includes("The installer of 0.11.0 grants them"),
|
|
518
|
+
),
|
|
519
|
+
).toBe(true);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("stage 2 grants the role after the image check and under the lock, records it, re-checks, converges", async () => {
|
|
523
|
+
const { iam, grant } = lacksWorkflows();
|
|
524
|
+
const h = harness({ iam, grant });
|
|
525
|
+
const r = await runConvergeJob(h.deps);
|
|
526
|
+
expect(r.status).toBe("succeeded");
|
|
527
|
+
const g = h.calls.indexOf("grant roles/workflows.editor");
|
|
528
|
+
expect(g).toBeGreaterThan(h.calls.indexOf("lock"));
|
|
529
|
+
expect(g).toBeLessThan(h.calls.indexOf("checkpoint running"));
|
|
530
|
+
expect(g).toBeLessThan(h.calls.indexOf("upgrade"));
|
|
531
|
+
// Only the missing role, nothing else of the list.
|
|
532
|
+
expect(h.calls.filter((c) => c.startsWith("grant"))).toEqual(["grant roles/workflows.editor"]);
|
|
533
|
+
const event = h.q.events.find((e) => e.message.startsWith("Granted roles/workflows.editor"));
|
|
534
|
+
expect(event?.level).toBe("success");
|
|
535
|
+
expect(event?.message).toBe(
|
|
536
|
+
"Granted roles/workflows.editor to the converge job's account (caelo-provisioner@acme.iam.gserviceaccount.com), needed by Caelo 0.11.0 to deploy and update the workflow the scheduler tick runs (#1140).",
|
|
537
|
+
);
|
|
538
|
+
expect(h.q.events.some((e) => e.message.startsWith("IAM confirms the granted roles"))).toBe(
|
|
539
|
+
true,
|
|
540
|
+
);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("waits for IAM to confirm a fresh grant (propagation) before it goes on", async () => {
|
|
544
|
+
const { iam, grant } = lacksWorkflows(3);
|
|
545
|
+
const h = harness({ iam, grant });
|
|
546
|
+
const r = await runConvergeJob(h.deps);
|
|
547
|
+
expect(r.status).toBe("succeeded");
|
|
548
|
+
const sleeps = h.calls.filter((c) => c === "sleep").length;
|
|
549
|
+
expect(sleeps).toBeGreaterThan(0);
|
|
550
|
+
expect(h.calls.lastIndexOf("sleep")).toBeLessThan(h.calls.indexOf("upgrade"));
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it("a grant that fails (org policy) ends the request with the Cloud Shell step, before any change", async () => {
|
|
554
|
+
const { iam } = lacksWorkflows();
|
|
555
|
+
const h = harness({
|
|
556
|
+
iam,
|
|
557
|
+
grant: () => ({
|
|
558
|
+
ok: false,
|
|
559
|
+
error: "ERROR: (gcloud.projects.add-iam-policy-binding) FAILED_PRECONDITION: org policy",
|
|
560
|
+
}),
|
|
561
|
+
});
|
|
562
|
+
const r = await runConvergeJob(h.deps);
|
|
563
|
+
expect(r.status).toBe("needs_cloud_shell");
|
|
564
|
+
expect(h.q.ends[0]?.codes).toEqual(["iam-self-grant"]);
|
|
565
|
+
const reason = h.q.ends[0]?.reason ?? "";
|
|
566
|
+
expect(reason).toStartWith(
|
|
567
|
+
"Nothing was changed: the converge job could not grant its own account",
|
|
568
|
+
);
|
|
569
|
+
expect(reason).toContain("roles/workflows.editor");
|
|
570
|
+
expect(reason).toContain("org policy");
|
|
571
|
+
expect(reason).toContain("Run the update once from Cloud Shell");
|
|
572
|
+
expect(h.upgrades).toEqual([]);
|
|
573
|
+
expect(h.calls.some((c) => c.startsWith("checkpoint"))).toBe(false);
|
|
574
|
+
expect(h.calls.at(-1)).toBe("unlock");
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("a grant IAM never confirms ends with the Cloud Shell step after the wait", async () => {
|
|
578
|
+
const { iam, grant } = lacksWorkflows(10_000);
|
|
579
|
+
const h = harness({ iam, grant });
|
|
580
|
+
const r = await runConvergeJob(h.deps);
|
|
581
|
+
expect(r.status).toBe("needs_cloud_shell");
|
|
582
|
+
expect(h.q.ends[0]?.codes).toEqual(["iam-self-grant"]);
|
|
583
|
+
expect(h.q.ends[0]?.reason).toContain("workflows.workflows.create");
|
|
584
|
+
expect(h.calls.filter((c) => c === "sleep").length).toBe(SELF_GRANT_RECHECK_ATTEMPTS - 1);
|
|
585
|
+
expect(h.upgrades).toEqual([]);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it("a missing permission no allowlisted role carries stops either stage without granting anything", async () => {
|
|
589
|
+
// workflows.* is repairable, actAs on its own account is not.
|
|
590
|
+
const { iam, grant } = lacksWorkflows(0, (p) => p === "iam.serviceAccounts.actAs");
|
|
591
|
+
for (const stage of ["0.10.53", "0.11.0"]) {
|
|
592
|
+
const h = harness({ ownVersion: stage, iam, grant });
|
|
593
|
+
const r = await runConvergeJob(h.deps);
|
|
594
|
+
expect(r.status).toBe("needs_cloud_shell");
|
|
595
|
+
expect(h.q.ends[0]?.codes).toEqual(["iam-preflight"]);
|
|
596
|
+
expect(h.calls.some((c) => /^(grant|lock|point|start|upgrade)/.test(c))).toBe(false);
|
|
597
|
+
expect(h.q.ends[0]?.reason).toContain("workflows.workflows.create");
|
|
598
|
+
expect(h.q.ends[0]?.reason).toContain("iam.serviceAccounts.actAs");
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("stage 2 refuses an image the manifest does not sign before it grants anything", async () => {
|
|
603
|
+
const { iam, grant } = lacksWorkflows();
|
|
604
|
+
const h = harness({ iam, grant, ownDigest: D("9") });
|
|
605
|
+
const r = await runConvergeJob(h.deps);
|
|
606
|
+
expect(r.status).toBe("refused");
|
|
607
|
+
expect(h.calls.some((c) => c.startsWith("grant"))).toBe(false);
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
|
|
458
611
|
describe("runConvergeJob — resume after a killed run", () => {
|
|
459
612
|
it("a run that died is adopted once Cloud Run says it ended, and resumes from the checkpoint", async () => {
|
|
460
613
|
const h = harness({
|
|
@@ -556,6 +709,66 @@ describe("runConvergeJob — resume after a killed run", () => {
|
|
|
556
709
|
});
|
|
557
710
|
});
|
|
558
711
|
|
|
712
|
+
describe("runConvergeJob — the database TLS step (#1373)", () => {
|
|
713
|
+
const DNS = "abc.p-label.europe-west1.sql-psa.goog";
|
|
714
|
+
|
|
715
|
+
it("a failed TLS step reaches the timeline and ends the request, before any roll", async () => {
|
|
716
|
+
const h = harness({
|
|
717
|
+
secureDatabase: {
|
|
718
|
+
ok: false,
|
|
719
|
+
error:
|
|
720
|
+
"database TLS could not be set up: caelo-production-pg-1 reports no PRIVATE_SERVICES_ACCESS DNS name",
|
|
721
|
+
},
|
|
722
|
+
});
|
|
723
|
+
const r = await runConvergeJob(h.deps);
|
|
724
|
+
expect(r.exitCode).toBe(1);
|
|
725
|
+
expect(r.status).toBe("failed");
|
|
726
|
+
expect(h.upgrades).toEqual([]);
|
|
727
|
+
// Under the lock, after the resumable checkpoint, released at the end.
|
|
728
|
+
expect(h.calls).toEqual([
|
|
729
|
+
"manifest 0.11.0",
|
|
730
|
+
"lock",
|
|
731
|
+
"checkpoint running",
|
|
732
|
+
"secure database",
|
|
733
|
+
"checkpoint failed",
|
|
734
|
+
"unlock",
|
|
735
|
+
]);
|
|
736
|
+
const end = h.q.ends[0];
|
|
737
|
+
expect(end?.codes).toEqual(["database-tls"]);
|
|
738
|
+
expect(end?.reason).toContain("reports no PRIVATE_SERVICES_ACCESS DNS name");
|
|
739
|
+
// The step's start and its error are timeline events of the request.
|
|
740
|
+
const converge = h.q.events.filter((e) => e.phase === "converge");
|
|
741
|
+
expect(converge[0]?.message).toStartWith("Setting up verified encryption");
|
|
742
|
+
const error = h.q.events.find((e) => e.level === "error");
|
|
743
|
+
expect(error?.message).toContain("reports no PRIVATE_SERVICES_ACCESS DNS name");
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
it("a successful TLS step runs right before the upgrade and names the verified host", async () => {
|
|
747
|
+
const h = harness({ secureDatabase: { ok: true, host: DNS } });
|
|
748
|
+
const r = await runConvergeJob(h.deps);
|
|
749
|
+
expect(r.status).toBe("succeeded");
|
|
750
|
+
const at = h.calls.indexOf("secure database");
|
|
751
|
+
expect(at).toBeGreaterThan(h.calls.indexOf("lock"));
|
|
752
|
+
expect(h.calls[at + 1]).toBe("upgrade");
|
|
753
|
+
expect(h.q.events.some((e) => e.level === "success" && e.message.includes(DNS))).toBe(true);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
it("stage 1 never runs it: the release's own installer changes the database", async () => {
|
|
757
|
+
const h = harness({ ownVersion: "0.10.53", secureDatabase: { ok: true, host: DNS } });
|
|
758
|
+
await runConvergeJob(h.deps);
|
|
759
|
+
expect(h.calls.includes("secure database")).toBe(false);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it("a stop before the lock leaves the database alone", async () => {
|
|
763
|
+
const h = harness({
|
|
764
|
+
secureDatabase: { ok: true, host: DNS },
|
|
765
|
+
manifest: { ok: false, error: { code: "signature", message: "no valid signature" } },
|
|
766
|
+
});
|
|
767
|
+
await runConvergeJob(h.deps);
|
|
768
|
+
expect(h.calls.includes("secure database")).toBe(false);
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
|
|
559
772
|
describe("runConvergeJob — nothing to do", () => {
|
|
560
773
|
it("exits cleanly without a converge request", async () => {
|
|
561
774
|
const h = harness({ claim: null });
|
package/src/converge-job.ts
CHANGED
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
* verifies its signature (release-manifest.ts), and decides
|
|
17
17
|
* (upgrader-assess.ts, `route: "converge"`): no downgrade, nothing
|
|
18
18
|
* below minFromVersion, no automatic converge unless the policy allows
|
|
19
|
-
* it; then it checks its own IAM access (converge-job-preflight.ts)
|
|
19
|
+
* it; then it checks its own IAM access (converge-job-preflight.ts) and
|
|
20
|
+
* stops on anything missing — except a role of the self-grant
|
|
21
|
+
* allowlist, which it leaves to stage 2.
|
|
20
22
|
* Then it verifies the target's ops image, points this job at it BY DIGEST and starts a new
|
|
21
23
|
* execution, handing the request over to it. It changes nothing else:
|
|
22
24
|
* the converge logic always comes from the release it installs.
|
|
@@ -25,7 +27,9 @@
|
|
|
25
27
|
* version is the request's). It verifies the manifest again, checks
|
|
26
28
|
* that the image it runs is the one the manifest signs, takes the
|
|
27
29
|
* install's state lock (#815; the upgrader job and the terminal
|
|
28
|
-
* `upgrade` take the same one, so they exclude each other),
|
|
30
|
+
* `upgrade` take the same one, so they exclude each other), grants its
|
|
31
|
+
* own account the allowlisted roles the self-check found missing (each
|
|
32
|
+
* a timeline event; IAM is asked again before it goes on), and runs
|
|
29
33
|
* the very upgrade the terminal runs (upgrade/upgrade.ts): backup →
|
|
30
34
|
* converge + app-roll → deep health check, rollback on failure →
|
|
31
35
|
* install.json + the infrastructure contract → the request's end.
|
|
@@ -46,10 +50,16 @@
|
|
|
46
50
|
*/
|
|
47
51
|
|
|
48
52
|
import type { ConvergeCheckpoint } from "./converge-checkpoint.js";
|
|
49
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
CONVERGE_PREFLIGHT_VOICE,
|
|
55
|
+
convergeAccessChecks,
|
|
56
|
+
INSTALL_ROLE_USES,
|
|
57
|
+
selfGrantPlan,
|
|
58
|
+
} from "./converge-job-preflight.js";
|
|
50
59
|
import { GCP_STACK_ENV } from "./gcp-names.js";
|
|
51
|
-
import { decideInstallJob, type ExecutionState } from "./install-job.js";
|
|
60
|
+
import { decideInstallJob, type ExecutionState, type InstallJobRole } from "./install-job.js";
|
|
52
61
|
import type { InstallMetadata } from "./install-state.js";
|
|
62
|
+
import { provisionerServiceAccountEmail } from "./provisioner-identity.js";
|
|
53
63
|
import {
|
|
54
64
|
compareSemver,
|
|
55
65
|
type ReleaseManifest,
|
|
@@ -60,6 +70,7 @@ import type { UpgradeCheckpoint } from "./upgrade/upgrade.js";
|
|
|
60
70
|
import { assessUpgradeRequest } from "./upgrader-assess.js";
|
|
61
71
|
import {
|
|
62
72
|
type IamTester,
|
|
73
|
+
missingAccessText,
|
|
63
74
|
preflightErrorReason,
|
|
64
75
|
preflightMissingReason,
|
|
65
76
|
runIamPreflight,
|
|
@@ -116,14 +127,46 @@ export type ConvergeUpgradeOutcome =
|
|
|
116
127
|
readonly unrecorded: boolean;
|
|
117
128
|
};
|
|
118
129
|
|
|
130
|
+
/**
|
|
131
|
+
* How long stage 2 waits for IAM to confirm the roles it granted itself:
|
|
132
|
+
* Google documents that a policy change usually applies within two minutes
|
|
133
|
+
* and can take seven (cloud.google.com/iam/docs/access-change-propagation).
|
|
134
|
+
*/
|
|
135
|
+
export const SELF_GRANT_RECHECK_INTERVAL_MS = 15_000;
|
|
136
|
+
export const SELF_GRANT_RECHECK_ATTEMPTS = 32;
|
|
137
|
+
|
|
138
|
+
/** How {@link ConvergeJobDeps.secureDatabase} ended. */
|
|
139
|
+
export type SecureDatabaseResult =
|
|
140
|
+
| { readonly ok: true; readonly host: string }
|
|
141
|
+
| { readonly ok: false; readonly error: string };
|
|
142
|
+
|
|
119
143
|
export interface ConvergeJobDeps {
|
|
120
144
|
readonly queue: ConvergeQueue;
|
|
145
|
+
/**
|
|
146
|
+
* #1373 — the install's database TLS step (#1338, cloudsql-tls.ts), and
|
|
147
|
+
* then {@link queue} moved onto the database's DNS name with verify-full.
|
|
148
|
+
* Null when the queue already dials that name. Until it ran the queue
|
|
149
|
+
* dials the instance's IP (converge-db-trust.ts), so a failure of the
|
|
150
|
+
* step reaches the timeline and the request's end like any other step;
|
|
151
|
+
* when it fails the queue is reopened on whatever the install dials then.
|
|
152
|
+
*/
|
|
153
|
+
readonly secureDatabase: (() => Promise<SecureDatabaseResult>) | null;
|
|
121
154
|
/** From the metadata server — never from env or argv. */
|
|
122
155
|
readonly projectId: string;
|
|
123
156
|
/** The region this job runs in (metadata server). */
|
|
124
157
|
readonly region: string;
|
|
125
158
|
/** Asks IAM as this job's own identity (the IAM self-check, #884). */
|
|
126
159
|
readonly iam: IamTester;
|
|
160
|
+
/**
|
|
161
|
+
* Grant this job's own account (the install's provisioner) `role` on the
|
|
162
|
+
* project. Stage 2 only, and only for roles of the self-grant allowlist
|
|
163
|
+
* (converge-job-preflight.ts); the real one refuses any other role.
|
|
164
|
+
*/
|
|
165
|
+
readonly grantOwnRole: (
|
|
166
|
+
role: InstallJobRole,
|
|
167
|
+
) => Promise<{ readonly ok: true } | { readonly ok: false; readonly error: string }>;
|
|
168
|
+
/** Waits between the IAM re-checks after a self-grant. */
|
|
169
|
+
readonly sleep: (ms: number) => Promise<void>;
|
|
127
170
|
/** This Cloud Run execution; null outside Cloud Run. */
|
|
128
171
|
readonly execution: string | null;
|
|
129
172
|
/** The Caelo release this code belongs to (CAELO_VERSION of the image). */
|
|
@@ -312,18 +355,11 @@ export async function runConvergeJob(deps: ConvergeJobDeps): Promise<UpgraderRun
|
|
|
312
355
|
|
|
313
356
|
// #884 — decided to run: ask IAM for what the rest needs before the
|
|
314
357
|
// lock, the hand-over or the backup (converge-job-preflight.ts). Both
|
|
315
|
-
// stages ask; stage 2's list is the target release's own.
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
);
|
|
320
|
-
if (access.kind === "missing") {
|
|
321
|
-
return stopWith(
|
|
322
|
-
"needs_cloud_shell",
|
|
323
|
-
["iam-preflight"],
|
|
324
|
-
preflightMissingReason(access.missing, CONVERGE_PREFLIGHT_VOICE),
|
|
325
|
-
);
|
|
326
|
-
}
|
|
358
|
+
// stages ask; stage 2's list is the target release's own. A missing
|
|
359
|
+
// role of the self-grant allowlist does not stop the run: stage 2
|
|
360
|
+
// grants it once it has verified its own image and holds the lock.
|
|
361
|
+
const stage1 = compareSemver(deps.ownVersion, m.version) !== 0;
|
|
362
|
+
const access = await askIam();
|
|
327
363
|
if (access.kind === "error") {
|
|
328
364
|
return stopWith(
|
|
329
365
|
"failed",
|
|
@@ -331,12 +367,36 @@ export async function runConvergeJob(deps: ConvergeJobDeps): Promise<UpgraderRun
|
|
|
331
367
|
preflightErrorReason(access.errors, CONVERGE_PREFLIGHT_VOICE),
|
|
332
368
|
);
|
|
333
369
|
}
|
|
334
|
-
|
|
370
|
+
let toGrant: readonly InstallJobRole[] = [];
|
|
371
|
+
if (access.kind === "missing") {
|
|
372
|
+
const plan = selfGrantPlan(access.missing);
|
|
373
|
+
if (plan.blocking.length > 0) {
|
|
374
|
+
return stopWith(
|
|
375
|
+
"needs_cloud_shell",
|
|
376
|
+
["iam-preflight"],
|
|
377
|
+
preflightMissingReason(access.missing, CONVERGE_PREFLIGHT_VOICE),
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
toGrant = plan.grant;
|
|
381
|
+
}
|
|
382
|
+
if (toGrant.length === 0) {
|
|
383
|
+
r.info("The converge job has every permission this update needs.");
|
|
384
|
+
} else {
|
|
385
|
+
r.info(
|
|
386
|
+
`The converge job's account lacks ${toGrant.join(", ")}, which this update needs. ${stage1 ? `The installer of ${m.version} grants them to it` : "It grants them to itself"} before it changes anything, once its own installer image is verified.`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
335
389
|
|
|
336
|
-
if (
|
|
390
|
+
if (stage1) {
|
|
337
391
|
return await handOver(m);
|
|
338
392
|
}
|
|
339
|
-
return await convergeHere(
|
|
393
|
+
return await convergeHere(
|
|
394
|
+
m,
|
|
395
|
+
fromVersion ?? (meta.releaseVersion as string),
|
|
396
|
+
checkpoint,
|
|
397
|
+
meta,
|
|
398
|
+
toGrant,
|
|
399
|
+
);
|
|
340
400
|
} catch (e) {
|
|
341
401
|
return stopWith(
|
|
342
402
|
"failed",
|
|
@@ -400,6 +460,7 @@ export async function runConvergeJob(deps: ConvergeJobDeps): Promise<UpgraderRun
|
|
|
400
460
|
fromVersion: string,
|
|
401
461
|
checkpoint: ConvergeCheckpoint | null,
|
|
402
462
|
judged: InstallMetadata,
|
|
463
|
+
toGrant: readonly InstallJobRole[],
|
|
403
464
|
): Promise<UpgraderRunResult> {
|
|
404
465
|
const installId = judged.installId;
|
|
405
466
|
const own = await deps.ownImageDigest();
|
|
@@ -439,6 +500,13 @@ export async function runConvergeJob(deps: ConvergeJobDeps): Promise<UpgraderRun
|
|
|
439
500
|
const stale = recordsChangedSince(judged, changed);
|
|
440
501
|
if (stale) return stopWith(stale.status, stale.codes, stale.reason);
|
|
441
502
|
|
|
503
|
+
// After the image check and under the lock: only the target release's
|
|
504
|
+
// verified installer grants, and only roles of its own allowlist.
|
|
505
|
+
if (toGrant.length > 0) {
|
|
506
|
+
const granted = await grantOwnRoles(m, toGrant, changed);
|
|
507
|
+
if (granted) return stopWith(granted.status, granted.codes, granted.reason);
|
|
508
|
+
}
|
|
509
|
+
|
|
442
510
|
const startedAt = deps.now().toISOString();
|
|
443
511
|
let current: ConvergeCheckpoint = {
|
|
444
512
|
statusVersion: 1,
|
|
@@ -463,6 +531,24 @@ export async function runConvergeJob(deps: ConvergeJobDeps): Promise<UpgraderRun
|
|
|
463
531
|
save({});
|
|
464
532
|
|
|
465
533
|
r.phase("converge");
|
|
534
|
+
// #1373 — stage 2 only, under the lock and after every check: it
|
|
535
|
+
// changes the database instance, its DNS and the services' URLs.
|
|
536
|
+
if (deps.secureDatabase) {
|
|
537
|
+
r.info(
|
|
538
|
+
"Setting up verified encryption for the database connection (the database's certificate and its private DNS name).",
|
|
539
|
+
);
|
|
540
|
+
await r.flush();
|
|
541
|
+
const secured = await deps.secureDatabase();
|
|
542
|
+
if (!secured.ok) {
|
|
543
|
+
save({ state: "failed", phase: "stopped" });
|
|
544
|
+
return stopWith(
|
|
545
|
+
"failed",
|
|
546
|
+
["database-tls"],
|
|
547
|
+
`The update to ${m.version} stopped at the database's TLS step, before it rolled anything: ${secured.error}. The previous release keeps serving. Fix what it names and start the update again; it continues where it stopped.`,
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
r.success(`The database connection is verified (${secured.host}).`);
|
|
551
|
+
}
|
|
466
552
|
const outcome = await deps.upgrade({
|
|
467
553
|
manifest: m,
|
|
468
554
|
reporter: r,
|
|
@@ -518,6 +604,76 @@ export async function runConvergeJob(deps: ConvergeJobDeps): Promise<UpgraderRun
|
|
|
518
604
|
);
|
|
519
605
|
}
|
|
520
606
|
|
|
607
|
+
/** The converge self-check against IAM, as this job's identity. */
|
|
608
|
+
function askIam() {
|
|
609
|
+
return runIamPreflight(
|
|
610
|
+
convergeAccessChecks({ projectId: deps.projectId, region: deps.region, env: GCP_STACK_ENV }),
|
|
611
|
+
deps.iam,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Stage 2: grant this job's account each role in `roles` (the self-check
|
|
617
|
+
* found them missing; all are on the allowlist), then wait until IAM
|
|
618
|
+
* confirms the whole self-check. Null when it does; how the request ends
|
|
619
|
+
* otherwise.
|
|
620
|
+
*/
|
|
621
|
+
async function grantOwnRoles(
|
|
622
|
+
m: ReleaseManifest,
|
|
623
|
+
roles: readonly InstallJobRole[],
|
|
624
|
+
changed: boolean,
|
|
625
|
+
): Promise<{ status: UpdateEndStatus; codes: string[]; reason: string } | null> {
|
|
626
|
+
const account = provisionerServiceAccountEmail(deps.projectId);
|
|
627
|
+
const granted: InstallJobRole[] = [];
|
|
628
|
+
const before = () =>
|
|
629
|
+
changed
|
|
630
|
+
? "This update already started changing the site; "
|
|
631
|
+
: granted.length > 0
|
|
632
|
+
? `Nothing on the site was changed (the converge job's account got ${granted.join(", ")}): `
|
|
633
|
+
: "Nothing was changed: ";
|
|
634
|
+
for (const role of roles) {
|
|
635
|
+
const result = await deps.grantOwnRole(role);
|
|
636
|
+
if (!result.ok) {
|
|
637
|
+
return {
|
|
638
|
+
status: "needs_cloud_shell",
|
|
639
|
+
codes: ["iam-self-grant"],
|
|
640
|
+
reason: `${before()}the converge job could not grant its own account (${account}) ${role}, which Caelo ${m.version} needs to ${INSTALL_ROLE_USES[role].need} (${result.error}). ${CONVERGE_PREFLIGHT_VOICE.repair}`,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
granted.push(role);
|
|
644
|
+
r.success(
|
|
645
|
+
`Granted ${role} to the converge job's account (${account}), needed by Caelo ${m.version} to ${INSTALL_ROLE_USES[role].need}.`,
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
await r.flush();
|
|
649
|
+
let last = await askIam();
|
|
650
|
+
for (let attempt = 1; last.kind !== "ok" && attempt < SELF_GRANT_RECHECK_ATTEMPTS; attempt++) {
|
|
651
|
+
await deps.sleep(SELF_GRANT_RECHECK_INTERVAL_MS);
|
|
652
|
+
last = await askIam();
|
|
653
|
+
}
|
|
654
|
+
if (last.kind === "ok") {
|
|
655
|
+
r.info(
|
|
656
|
+
"IAM confirms the granted roles; the converge job has every permission this update needs.",
|
|
657
|
+
);
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
const minutes = Math.round(
|
|
661
|
+
(SELF_GRANT_RECHECK_INTERVAL_MS * (SELF_GRANT_RECHECK_ATTEMPTS - 1)) / 60_000,
|
|
662
|
+
);
|
|
663
|
+
if (last.kind === "error") {
|
|
664
|
+
return {
|
|
665
|
+
status: "failed",
|
|
666
|
+
codes: ["iam-self-grant"],
|
|
667
|
+
reason: `${before()}after granting its own account ${granted.join(", ")}, the converge job could not check its access again (${last.errors.join("; ")}). Start the update again from Settings → Updates in a few minutes.`,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
return {
|
|
671
|
+
status: "needs_cloud_shell",
|
|
672
|
+
codes: ["iam-self-grant"],
|
|
673
|
+
reason: `${before()}the converge job granted its own account ${granted.join(", ")}, but ${minutes} minutes later Google Cloud still reports permissions missing — ${missingAccessText(last.missing)}. ${CONVERGE_PREFLIGHT_VOICE.repair}`,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
521
677
|
/**
|
|
522
678
|
* Whether install.json, re-read under the lock, is still what the run was
|
|
523
679
|
* judged on; how the request ends otherwise.
|
|
@@ -28,7 +28,7 @@ describe("database URL builders (#1338)", () => {
|
|
|
28
28
|
const yaml = generateDockerCompose({
|
|
29
29
|
domain: "example.com",
|
|
30
30
|
postgresPassword: "superpw",
|
|
31
|
-
rolePasswords: { admin: "adminpw", public: "publicpw" },
|
|
31
|
+
rolePasswords: { admin: "adminpw", public: "publicpw", publicStaging: "ps" },
|
|
32
32
|
caeloSecretKek: "0".repeat(64),
|
|
33
33
|
dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
|
|
34
34
|
internalSecret: "i".repeat(48),
|
|
@@ -42,7 +42,8 @@ describe("database URL builders (#1338)", () => {
|
|
|
42
42
|
project: "caelo-example-com",
|
|
43
43
|
});
|
|
44
44
|
const urls = [...yaml.matchAll(/_DATABASE_URL: "([^"]+)"/g)].map((m) => m[1] as string);
|
|
45
|
-
|
|
45
|
+
// admin: cms_admin, cms_public, cms_public_staging (#1329); gateway: cms_public.
|
|
46
|
+
expect(urls).toHaveLength(4);
|
|
46
47
|
for (const url of urls) expect(databaseTlsProblem(url)).toBeNull();
|
|
47
48
|
});
|
|
48
49
|
|
|
@@ -128,6 +128,9 @@ describe("checkDeployedRuntimeEnv", () => {
|
|
|
128
128
|
const adminOk = svc([
|
|
129
129
|
secret("ADMIN_DATABASE_PASSWORD"),
|
|
130
130
|
secret("PUBLIC_ADMIN_DATABASE_PASSWORD"),
|
|
131
|
+
// #1329 — the staging database's pool: a release from before it is
|
|
132
|
+
// refused here too and goes through `upgrade` first.
|
|
133
|
+
secret("PUBLIC_STAGING_ADMIN_DATABASE_PASSWORD"),
|
|
131
134
|
]);
|
|
132
135
|
const gatewayOk = svc([secret("PUBLIC_DATABASE_PASSWORD", "public-role-password")]);
|
|
133
136
|
const proxyOk = svc([secret("CAELO_MCP_OAUTH_SEAL_KEY", "mcp-oauth-seal-key")]);
|
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
* self-hosted the generated compose gateway service: env, secrets,
|
|
17
17
|
* mounts
|
|
18
18
|
*
|
|
19
|
+
* Issue #1329 — the same crosswise between the two public databases: the
|
|
20
|
+
* live gateway's env names nothing of the staging database
|
|
21
|
+
* (cms_public_staging, public_staging_role, its password secret), and its
|
|
22
|
+
* identity cannot read the staging login's password, on every adapter. The
|
|
23
|
+
* staging gateway (#1330) gets the mirror-image checks with its service.
|
|
24
|
+
*
|
|
19
25
|
* The gateway itself refuses to boot on such an env
|
|
20
26
|
* (apps/api-gateway `adminDatabaseAccessInEnv`); this catches it at review
|
|
21
27
|
* time, before a release can ship it.
|
|
@@ -42,6 +48,12 @@ const ADMIN_ACCESS = /cms_admin|admin_role|gateway_role|ADMIN_DATABASE|GATEWAY_D
|
|
|
42
48
|
/** Secrets that unlock a cms_admin login. */
|
|
43
49
|
const ADMIN_SECRETS = ["postgres-password", "gateway-role-password"];
|
|
44
50
|
|
|
51
|
+
/** #1329 — anything in the live gateway's env that reaches the staging database. */
|
|
52
|
+
const STAGING_ACCESS = /cms_public_staging|public_staging_role|PUBLIC_STAGING/;
|
|
53
|
+
|
|
54
|
+
/** #1329 — the secret that unlocks the staging gateway's login. */
|
|
55
|
+
const STAGING_SECRETS = ["public-staging-role-password"];
|
|
56
|
+
|
|
45
57
|
/** The secrets the gateway reads on GCP: its own login and the staging gate key. */
|
|
46
58
|
const GCP_GATEWAY_SECRETS = [
|
|
47
59
|
"public-role-password",
|
|
@@ -68,11 +80,19 @@ describe("gcp + gcp-firebase: the gateway env contract (#635)", () => {
|
|
|
68
80
|
databaseUrls: databaseUrls("db1.p-label.europe-west1.sql-psa.goog"),
|
|
69
81
|
});
|
|
70
82
|
|
|
71
|
-
it("carries no cms_admin URL, role or credential name", () => {
|
|
83
|
+
it("carries no cms_admin URL, role or credential name, and nothing of the staging database (#1329)", () => {
|
|
72
84
|
for (const v of env) {
|
|
73
85
|
expect(v.name).not.toMatch(ADMIN_ACCESS);
|
|
74
|
-
|
|
86
|
+
expect(v.name).not.toMatch(STAGING_ACCESS);
|
|
87
|
+
if ("value" in v && typeof v.value === "string") {
|
|
88
|
+
expect(v.value).not.toMatch(ADMIN_ACCESS);
|
|
89
|
+
expect(v.value).not.toMatch(STAGING_ACCESS);
|
|
90
|
+
}
|
|
75
91
|
}
|
|
92
|
+
expect(env.find((v) => v.name === "CAELO_GATEWAY_ENVIRONMENT")).toEqual({
|
|
93
|
+
name: "CAELO_GATEWAY_ENVIRONMENT",
|
|
94
|
+
value: "live",
|
|
95
|
+
});
|
|
76
96
|
expect(env.find((v) => v.name === "PUBLIC_DATABASE_URL")).toEqual({
|
|
77
97
|
name: "PUBLIC_DATABASE_URL",
|
|
78
98
|
value:
|
|
@@ -80,12 +100,16 @@ describe("gcp + gcp-firebase: the gateway env contract (#635)", () => {
|
|
|
80
100
|
});
|
|
81
101
|
});
|
|
82
102
|
|
|
83
|
-
it("references no secret that unlocks a cms_admin login", () => {
|
|
103
|
+
it("references no secret that unlocks a cms_admin or the staging login", () => {
|
|
84
104
|
for (const secret of Object.values(SERVICE_SECRET_ENV.gateway)) {
|
|
85
105
|
expect(ADMIN_SECRETS).not.toContain(secret);
|
|
106
|
+
expect(STAGING_SECRETS).not.toContain(secret);
|
|
86
107
|
}
|
|
87
108
|
const gatewayBindings = runtimeSecretBindings().filter((b) => b.service === "gateway");
|
|
88
|
-
for (const b of gatewayBindings)
|
|
109
|
+
for (const b of gatewayBindings) {
|
|
110
|
+
expect(ADMIN_SECRETS).not.toContain(b.secret);
|
|
111
|
+
expect(STAGING_SECRETS).not.toContain(b.secret);
|
|
112
|
+
}
|
|
89
113
|
});
|
|
90
114
|
|
|
91
115
|
for (const provider of ["gcp", "gcp-firebase"] as const) {
|
|
@@ -134,7 +158,7 @@ describe("self-hosted: the generated compose gateway service (#635)", () => {
|
|
|
134
158
|
const out = generateDockerCompose({
|
|
135
159
|
domain: "example.com",
|
|
136
160
|
postgresPassword: "superpw",
|
|
137
|
-
rolePasswords: { admin: "adminpw", public: "publicpw" },
|
|
161
|
+
rolePasswords: { admin: "adminpw", public: "publicpw", publicStaging: "stagingpw" },
|
|
138
162
|
caeloSecretKek: "0".repeat(64),
|
|
139
163
|
dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
|
|
140
164
|
internalSecret: "i".repeat(48),
|
|
@@ -152,7 +176,9 @@ describe("self-hosted: the generated compose gateway service (#635)", () => {
|
|
|
152
176
|
const gateway = out.slice(start, end === -1 ? undefined : end);
|
|
153
177
|
expect(gateway).toContain("PUBLIC_DATABASE_URL:");
|
|
154
178
|
expect(gateway).not.toMatch(ADMIN_ACCESS);
|
|
155
|
-
expect(gateway).not.toMatch(
|
|
179
|
+
expect(gateway).not.toMatch(STAGING_ACCESS);
|
|
180
|
+
expect(gateway).not.toMatch(/superpw|adminpw|gatewaypw|stagingpw/);
|
|
181
|
+
expect(gateway).toContain('CAELO_GATEWAY_ENVIRONMENT: "live"');
|
|
156
182
|
// No other way in: no Compose secrets, no env file, and exactly one
|
|
157
183
|
// mount — the database CA certificate (#1338), public, no credential.
|
|
158
184
|
expect(gateway).not.toMatch(/^ {4}(secrets|env_file):/m);
|
|
@@ -164,6 +190,8 @@ describe("self-hosted: the generated compose gateway service (#635)", () => {
|
|
|
164
190
|
expect(keys.sort()).toEqual([
|
|
165
191
|
// #1254 — its cms_public pool size (pool-budget-stack.ts).
|
|
166
192
|
"CAELO_DB_PUBLIC_POOL_MAX",
|
|
193
|
+
// #1329 — the live site's gateway.
|
|
194
|
+
"CAELO_GATEWAY_ENVIRONMENT",
|
|
167
195
|
"CAELO_TRUSTED_PROXY_HOPS",
|
|
168
196
|
"GATEWAY_PORT",
|
|
169
197
|
"NODE_ENV",
|
|
@@ -181,6 +209,9 @@ describe("azure: the gateway container app (#635)", () => {
|
|
|
181
209
|
const block = src.slice(start, src.indexOf("});", start));
|
|
182
210
|
expect(block).toContain('{ name: "PUBLIC_DATABASE_URL", value: publicDatabaseUrl }');
|
|
183
211
|
expect(block).not.toMatch(/adminDatabase|gatewayDatabase|ADMIN_DATABASE|GATEWAY_DATABASE/);
|
|
212
|
+
// #1329 — nothing of the staging database, and it says it is the live one.
|
|
213
|
+
expect(block).not.toMatch(/publicStaging|PUBLIC_STAGING/);
|
|
214
|
+
expect(block).toContain('{ name: GATEWAY_ENVIRONMENT_ENV, value: "live" }');
|
|
184
215
|
});
|
|
185
216
|
|
|
186
217
|
it("builds no cms_admin URL for any role but the admin's", () => {
|
|
@@ -206,7 +237,9 @@ describe("aws: the stack (#635)", () => {
|
|
|
206
237
|
|
|
207
238
|
it("the gateway's task role reads only the public_role password (review finding)", () => {
|
|
208
239
|
expect(AWS_SERVICE_SECRETS.gateway).toEqual(["public-role-password"]);
|
|
209
|
-
for (const secret of ADMIN_SECRETS)
|
|
240
|
+
for (const secret of [...ADMIN_SECRETS, ...STAGING_SECRETS]) {
|
|
241
|
+
expect(AWS_SERVICE_SECRETS.gateway).not.toContain(secret);
|
|
242
|
+
}
|
|
210
243
|
});
|
|
211
244
|
|
|
212
245
|
it("each service gets its own task role whose policy is built from that table", () => {
|