@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
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #1328 — resolving a self-hosted release: the MCP-Auth-Proxy image is
|
|
5
|
+
* optional only for a release that has none (the registry says its tag does
|
|
6
|
+
* not exist), never because the registry could not be asked.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import { type HostRunner, resolveVerifiedRelease } from "./self-hosted-release.js";
|
|
11
|
+
|
|
12
|
+
const D = (c: string) => `sha256:${c.repeat(64)}`;
|
|
13
|
+
|
|
14
|
+
/** GHCR with a token challenge; `proxy` decides the MCP-Auth-Proxy tag's answer. */
|
|
15
|
+
function ghcr(proxy: "present" | 404 | 503): typeof fetch {
|
|
16
|
+
return (async (input: string | URL | Request, init?: RequestInit) => {
|
|
17
|
+
const url = String(input);
|
|
18
|
+
if (url.startsWith("https://ghcr.io/token")) return Response.json({ token: "t" });
|
|
19
|
+
if (!new Headers(init?.headers).get("authorization")) {
|
|
20
|
+
return new Response(null, {
|
|
21
|
+
status: 401,
|
|
22
|
+
headers: { "www-authenticate": 'Bearer realm="https://ghcr.io/token",service="ghcr.io"' },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
if (url.includes("/caelo-cms/mcp-auth-proxy/")) {
|
|
26
|
+
if (proxy !== "present") return new Response(null, { status: proxy });
|
|
27
|
+
return new Response(null, { status: 200, headers: { "docker-content-digest": D("c") } });
|
|
28
|
+
}
|
|
29
|
+
const digest = url.includes("/caelo-cms/admin/") ? D("a") : D("b");
|
|
30
|
+
return new Response(null, { status: 200, headers: { "docker-content-digest": digest } });
|
|
31
|
+
}) as typeof fetch;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function runner(): HostRunner & { calls: string[] } {
|
|
35
|
+
const calls: string[] = [];
|
|
36
|
+
return {
|
|
37
|
+
calls,
|
|
38
|
+
async run(argv) {
|
|
39
|
+
calls.push(argv.join(" "));
|
|
40
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("resolveVerifiedRelease (#1328)", () => {
|
|
46
|
+
it("resolves and verifies all three images of a release that ships the proxy", async () => {
|
|
47
|
+
const r = runner();
|
|
48
|
+
const res = await resolveVerifiedRelease("0.12.0", { runner: r, fetchImpl: ghcr("present") });
|
|
49
|
+
expect(res.ok).toBe(true);
|
|
50
|
+
if (!res.ok) return;
|
|
51
|
+
expect(res.images["mcp-auth-proxy"]).toBe(`ghcr.io/caelo-cms/mcp-auth-proxy@${D("c")}`);
|
|
52
|
+
expect(r.calls.filter((c) => c.startsWith("cosign verify"))).toHaveLength(3);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("a release from before the proxy (its tag does not exist) resolves without it", async () => {
|
|
56
|
+
const r = runner();
|
|
57
|
+
const res = await resolveVerifiedRelease("0.11.0", { runner: r, fetchImpl: ghcr(404) });
|
|
58
|
+
expect(res.ok).toBe(true);
|
|
59
|
+
if (!res.ok) return;
|
|
60
|
+
expect("mcp-auth-proxy" in res.images).toBe(false);
|
|
61
|
+
expect(res.images.admin).toBe(`ghcr.io/caelo-cms/admin@${D("a")}`);
|
|
62
|
+
expect(r.calls.filter((c) => c.startsWith("cosign verify"))).toHaveLength(2);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("a registry outage on the proxy tag fails instead of installing without it", async () => {
|
|
66
|
+
const res = await resolveVerifiedRelease("0.12.0", { runner: runner(), fetchImpl: ghcr(503) });
|
|
67
|
+
expect(res.ok).toBe(false);
|
|
68
|
+
if (res.ok) return;
|
|
69
|
+
expect(res.error).toContain("mcp-auth-proxy:0.12.0");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
*
|
|
7
7
|
* `.caelo/release.json` is the install metadata for self-hosted (the cloud
|
|
8
8
|
* providers keep theirs in `~/.caelo-<id>/install.json`): the release
|
|
9
|
-
* version, the digest-pinned
|
|
10
|
-
*
|
|
9
|
+
* version, the digest-pinned image references the Compose file runs (admin,
|
|
10
|
+
* gateway, and since #1328 the MCP-Auth-Proxy), and a short history of installs/upgrades/rollbacks. It holds no
|
|
11
11
|
* secrets (config.json does), so it is safe to read from tooling and
|
|
12
12
|
* backups; writes refuse secret material like install.json's do.
|
|
13
13
|
*
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
GHCR_NAMESPACE,
|
|
28
28
|
GHCR_REGISTRY,
|
|
29
29
|
RELEASE_SERVICES,
|
|
30
|
+
type ReleaseService,
|
|
30
31
|
resolveRegistryDigest,
|
|
31
32
|
} from "./upgrade/release-images.js";
|
|
32
33
|
|
|
@@ -103,7 +104,12 @@ export interface ReleaseHistoryEntry {
|
|
|
103
104
|
export interface SelfHostedRelease {
|
|
104
105
|
/** The release tag the images were resolved from (e.g. `0.10.43`). */
|
|
105
106
|
readonly version: string;
|
|
106
|
-
readonly images: {
|
|
107
|
+
readonly images: {
|
|
108
|
+
readonly admin: string;
|
|
109
|
+
readonly gateway: string;
|
|
110
|
+
/** #1328 — absent in records of releases from before the MCP-Auth-Proxy. */
|
|
111
|
+
readonly "mcp-auth-proxy"?: string;
|
|
112
|
+
};
|
|
107
113
|
readonly installedAt: string;
|
|
108
114
|
/** Newest last; capped at {@link HISTORY_LIMIT}. */
|
|
109
115
|
readonly history: readonly ReleaseHistoryEntry[];
|
|
@@ -117,7 +123,17 @@ export function releasePath(caeloDir: string): string {
|
|
|
117
123
|
|
|
118
124
|
/** The images of a recorded release, as the Compose generator takes them. */
|
|
119
125
|
export function releaseImages(release: SelfHostedRelease): ReleaseImages {
|
|
120
|
-
return { mode: "release",
|
|
126
|
+
return { mode: "release", ...release.images };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The image references of `images`, as release.json stores them. */
|
|
130
|
+
function recordedImages(images: ReleaseImages): SelfHostedRelease["images"] {
|
|
131
|
+
const proxy = images["mcp-auth-proxy"];
|
|
132
|
+
return {
|
|
133
|
+
admin: images.admin,
|
|
134
|
+
gateway: images.gateway,
|
|
135
|
+
...(proxy !== undefined ? { "mcp-auth-proxy": proxy } : {}),
|
|
136
|
+
};
|
|
121
137
|
}
|
|
122
138
|
|
|
123
139
|
/**
|
|
@@ -131,10 +147,12 @@ export function readRelease(caeloDir: string): SelfHostedRelease | null {
|
|
|
131
147
|
const raw = JSON.parse(readFileSync(path, "utf8")) as Partial<SelfHostedRelease>;
|
|
132
148
|
const admin = raw.images?.admin ?? "";
|
|
133
149
|
const gateway = raw.images?.gateway ?? "";
|
|
150
|
+
const proxy = raw.images?.["mcp-auth-proxy"];
|
|
134
151
|
if (
|
|
135
152
|
typeof raw.version !== "string" ||
|
|
136
153
|
!ghcrImageDigest("admin", admin) ||
|
|
137
|
-
!ghcrImageDigest("gateway", gateway)
|
|
154
|
+
!ghcrImageDigest("gateway", gateway) ||
|
|
155
|
+
(proxy !== undefined && !ghcrImageDigest("mcp-auth-proxy", proxy))
|
|
138
156
|
) {
|
|
139
157
|
throw new Error(
|
|
140
158
|
`${path} is malformed (needs version + digest-pinned ghcr.io/caelo-cms images); fix it or re-run \`${selfCommand("upgrade", "--version", "<x.y.z>")}\`.`,
|
|
@@ -142,7 +160,7 @@ export function readRelease(caeloDir: string): SelfHostedRelease | null {
|
|
|
142
160
|
}
|
|
143
161
|
return {
|
|
144
162
|
version: raw.version,
|
|
145
|
-
images: { admin, gateway },
|
|
163
|
+
images: { admin, gateway, ...(proxy !== undefined ? { "mcp-auth-proxy": proxy } : {}) },
|
|
146
164
|
installedAt: raw.installedAt ?? "",
|
|
147
165
|
history: Array.isArray(raw.history) ? raw.history : [],
|
|
148
166
|
};
|
|
@@ -162,7 +180,7 @@ export function recordRelease(
|
|
|
162
180
|
const at = now.toISOString();
|
|
163
181
|
const record: SelfHostedRelease = {
|
|
164
182
|
version: next.version,
|
|
165
|
-
images:
|
|
183
|
+
images: recordedImages(next.images),
|
|
166
184
|
installedAt:
|
|
167
185
|
prior && prior.version === next.version && entry.outcome === "rolled-back"
|
|
168
186
|
? prior.installedAt
|
|
@@ -195,7 +213,7 @@ export type VerifiedRelease =
|
|
|
195
213
|
| { readonly ok: false; readonly error: string };
|
|
196
214
|
|
|
197
215
|
/**
|
|
198
|
-
* Resolve
|
|
216
|
+
* Resolve every release image of `tag` (RELEASE_SERVICES) in GHCR to digests,
|
|
199
217
|
* then cosign-verify each digest against the Caelo release workflow
|
|
200
218
|
* identity (unless `skipVerify`, the escape hatch for forks). The returned
|
|
201
219
|
* references are digest-pinned, so what was verified is exactly what runs.
|
|
@@ -209,7 +227,7 @@ export async function resolveVerifiedRelease(
|
|
|
209
227
|
readonly onProgress?: (message: string) => void;
|
|
210
228
|
},
|
|
211
229
|
): Promise<VerifiedRelease> {
|
|
212
|
-
const refs: Partial<Record<
|
|
230
|
+
const refs: Partial<Record<ReleaseService, string>> = {};
|
|
213
231
|
for (const service of RELEASE_SERVICES) {
|
|
214
232
|
deps.onProgress?.(`Resolving ${GHCR_REGISTRY}/${GHCR_NAMESPACE}/${service}:${tag}`);
|
|
215
233
|
const r = await resolveRegistryDigest(
|
|
@@ -218,6 +236,14 @@ export async function resolveVerifiedRelease(
|
|
|
218
236
|
tag,
|
|
219
237
|
deps.fetchImpl,
|
|
220
238
|
);
|
|
239
|
+
if (!r.ok && r.notFound && service === "mcp-auth-proxy") {
|
|
240
|
+
// #1328 — releases from before the MCP-Auth-Proxy ship no image of it,
|
|
241
|
+
// and they stay installable and rollback targets: their Compose file
|
|
242
|
+
// simply has no proxy (ReleaseImages models the absence). Only a
|
|
243
|
+
// registry verdict that the tag does not exist means that; an outage
|
|
244
|
+
// still fails below, never silently installs a release without it.
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
221
247
|
if (!r.ok) {
|
|
222
248
|
return {
|
|
223
249
|
ok: false,
|
|
@@ -228,10 +254,12 @@ export async function resolveVerifiedRelease(
|
|
|
228
254
|
}
|
|
229
255
|
refs[service] = ghcrImageRef(service, r.digest);
|
|
230
256
|
}
|
|
257
|
+
const proxy = refs["mcp-auth-proxy"];
|
|
231
258
|
const images: ReleaseImages = {
|
|
232
259
|
mode: "release",
|
|
233
260
|
admin: refs.admin ?? "",
|
|
234
261
|
gateway: refs.gateway ?? "",
|
|
262
|
+
...(proxy ? { "mcp-auth-proxy": proxy } : {}),
|
|
235
263
|
};
|
|
236
264
|
if (deps.skipVerify) return { ok: true, images };
|
|
237
265
|
|
|
@@ -246,13 +274,15 @@ export async function resolveVerifiedRelease(
|
|
|
246
274
|
};
|
|
247
275
|
}
|
|
248
276
|
for (const service of RELEASE_SERVICES) {
|
|
277
|
+
const ref = images[service];
|
|
278
|
+
if (ref === undefined) continue;
|
|
249
279
|
deps.onProgress?.(`Verifying the signature of ${service}`);
|
|
250
|
-
const v = await deps.runner.run(cosignVerifyArgv(
|
|
280
|
+
const v = await deps.runner.run(cosignVerifyArgv(ref));
|
|
251
281
|
if (v.exitCode !== 0) {
|
|
252
282
|
return {
|
|
253
283
|
ok: false,
|
|
254
284
|
error:
|
|
255
|
-
`cosign verify FAILED for ${
|
|
285
|
+
`cosign verify FAILED for ${ref}: its signature does not match the Caelo release workflow.\n` +
|
|
256
286
|
"Either the registry was compromised, the image is a fork's, or sigstore had an outage (retry).\n" +
|
|
257
287
|
`cosign: ${v.stderr.trim().slice(0, 500)}`,
|
|
258
288
|
};
|
|
@@ -26,6 +26,7 @@ const required = [
|
|
|
26
26
|
"POSTGRES_PORT",
|
|
27
27
|
"ADMIN_ROLE_PASSWORD",
|
|
28
28
|
"PUBLIC_ROLE_PASSWORD",
|
|
29
|
+
"PUBLIC_STAGING_ROLE_PASSWORD",
|
|
29
30
|
"ADMIN_DATABASE_URL",
|
|
30
31
|
"PUBLIC_DATABASE_URL",
|
|
31
32
|
] as const;
|
|
@@ -63,6 +64,7 @@ const psql: SuperuserPsql = async (script) => {
|
|
|
63
64
|
const passwords = {
|
|
64
65
|
admin: env.ADMIN_ROLE_PASSWORD as string,
|
|
65
66
|
public: env.PUBLIC_ROLE_PASSWORD as string,
|
|
67
|
+
publicStaging: env.PUBLIC_STAGING_ROLE_PASSWORD as string,
|
|
66
68
|
};
|
|
67
69
|
|
|
68
70
|
describe("self-hosted role convergence against Postgres (#613 review, #635)", () => {
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MPL-2.0
|
|
2
2
|
|
|
3
3
|
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { resolve } from "node:path";
|
|
4
6
|
import {
|
|
7
|
+
applyPreMigrationRoles,
|
|
5
8
|
convergeSelfHostedRoles,
|
|
6
9
|
decideUp,
|
|
10
|
+
PUBLIC_ROLE_LIMITS,
|
|
11
|
+
preMigrationRolesScript,
|
|
7
12
|
roleConvergenceScript,
|
|
8
13
|
selfHostedBootstrapScript,
|
|
9
14
|
} from "./self-hosted-roles.js";
|
|
10
15
|
|
|
11
|
-
const PASSWORDS = { admin: "a'pw", public: "p-pw" };
|
|
16
|
+
const PASSWORDS = { admin: "a'pw", public: "p-pw", publicStaging: "s'pw" };
|
|
12
17
|
|
|
13
18
|
describe("roleConvergenceScript (#613 review)", () => {
|
|
14
19
|
const script = roleConvergenceScript(PASSWORDS);
|
|
@@ -23,13 +28,13 @@ describe("roleConvergenceScript (#613 review)", () => {
|
|
|
23
28
|
expect(script).not.toContain("CREATE ROLE gateway_role");
|
|
24
29
|
});
|
|
25
30
|
|
|
26
|
-
it("drops the former gateway_role with what it holds in
|
|
31
|
+
it("drops the former gateway_role with what it holds in every database, and stops on the first error (#635, #1329)", () => {
|
|
27
32
|
expect(script.startsWith("\\set ON_ERROR_STOP on")).toBe(true);
|
|
28
33
|
const publicDrop = script.indexOf("\\connect cms_public");
|
|
29
34
|
const adminDrop = script.indexOf("\\connect cms_admin");
|
|
30
35
|
expect(publicDrop).toBeGreaterThan(-1);
|
|
31
36
|
expect(adminDrop).toBeGreaterThan(publicDrop);
|
|
32
|
-
expect(script.match(/DROP OWNED BY gateway_role/g)).toHaveLength(
|
|
37
|
+
expect(script.match(/DROP OWNED BY gateway_role/g)).toHaveLength(3);
|
|
33
38
|
expect(script.indexOf("THEN DROP ROLE gateway_role;")).toBeGreaterThan(adminDrop);
|
|
34
39
|
expect(script).not.toContain("caelo_grant_gateway_role");
|
|
35
40
|
});
|
|
@@ -126,3 +131,82 @@ describe("decideUp — `cms-provision up` never switches URLs onto unapplied pas
|
|
|
126
131
|
expect(d).toMatchObject({ kind: "proceed", markConverged: false });
|
|
127
132
|
});
|
|
128
133
|
});
|
|
134
|
+
|
|
135
|
+
describe("the staging database and its login (#1329)", () => {
|
|
136
|
+
const staging = [
|
|
137
|
+
"SELECT 'CREATE ROLE public_staging_role NOINHERIT LOGIN' WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'public_staging_role') \\gexec",
|
|
138
|
+
"SELECT 'CREATE DATABASE cms_public_staging OWNER admin_role' WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'cms_public_staging') \\gexec",
|
|
139
|
+
"REVOKE ALL ON DATABASE cms_public_staging FROM PUBLIC;",
|
|
140
|
+
"GRANT CONNECT ON DATABASE cms_public_staging TO public_staging_role, admin_role;",
|
|
141
|
+
"REVOKE ALL ON DATABASE cms_public_staging FROM public_role;",
|
|
142
|
+
"REVOKE ALL ON DATABASE cms_public FROM public_staging_role;",
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
it("are created on the first start, on every `up`, and before an upgrade's migrations", () => {
|
|
146
|
+
for (const script of [roleConvergenceScript(PASSWORDS), preMigrationRolesScript("s'pw")]) {
|
|
147
|
+
for (const st of staging) expect(script).toContain(st);
|
|
148
|
+
expect(script).toContain("ALTER ROLE public_staging_role WITH LOGIN PASSWORD 's''pw';");
|
|
149
|
+
}
|
|
150
|
+
const bootstrap = selfHostedBootstrapScript({ agent: false });
|
|
151
|
+
for (const st of staging) expect(bootstrap).toContain(st);
|
|
152
|
+
expect(bootstrap).toContain(
|
|
153
|
+
"ALTER ROLE public_staging_role WITH LOGIN PASSWORD :'public_staging_role_pw';",
|
|
154
|
+
);
|
|
155
|
+
expect(bootstrap).toContain('-v public_staging_role_pw="$PUBLIC_STAGING_ROLE_PASSWORD"');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("drops the former gateway_role's leftovers in the staging database too", () => {
|
|
159
|
+
expect(roleConvergenceScript(PASSWORDS)).toContain("\\connect cms_public_staging");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("the gateway logins' limits (#1338, H3; #1329)", () => {
|
|
164
|
+
const statements = ["public_role", "public_staging_role"].flatMap((role) => [
|
|
165
|
+
`ALTER ROLE ${role} CONNECTION LIMIT ${PUBLIC_ROLE_LIMITS.connectionLimit};`,
|
|
166
|
+
`ALTER ROLE ${role} SET statement_timeout = '${PUBLIC_ROLE_LIMITS.statementTimeout}';`,
|
|
167
|
+
`ALTER ROLE ${role} SET idle_in_transaction_session_timeout = '${PUBLIC_ROLE_LIMITS.idleInTransactionSessionTimeout}';`,
|
|
168
|
+
`ALTER ROLE ${role} SET work_mem = '${PUBLIC_ROLE_LIMITS.workMem}';`,
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
it("are set on the first start, on every `up`, and before an upgrade's migrations", () => {
|
|
172
|
+
for (const script of [
|
|
173
|
+
selfHostedBootstrapScript({ agent: false }),
|
|
174
|
+
roleConvergenceScript(PASSWORDS),
|
|
175
|
+
preMigrationRolesScript("s'pw"),
|
|
176
|
+
]) {
|
|
177
|
+
for (const st of statements) expect(script).toContain(st);
|
|
178
|
+
}
|
|
179
|
+
// The pre-migration script drops no role and touches no other database:
|
|
180
|
+
// only the staging database with its login, then the limits.
|
|
181
|
+
const lines = preMigrationRolesScript("s'pw").split("\n").filter(Boolean);
|
|
182
|
+
expect(lines[0]).toBe("\\set ON_ERROR_STOP on");
|
|
183
|
+
expect(lines.slice(-statements.length)).toEqual(statements);
|
|
184
|
+
expect(lines.some((l) => /DROP|\\connect/.test(l))).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("match the values packages/migrations/src/bootstrap.sh sets (dev, CI, Cloud SQL bootstrap)", () => {
|
|
188
|
+
const bootstrap = readFileSync(
|
|
189
|
+
resolve(import.meta.dir, "../../migrations/src/bootstrap.sh"),
|
|
190
|
+
"utf8",
|
|
191
|
+
);
|
|
192
|
+
for (const st of statements) expect(bootstrap).toContain(st);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("refuse to run against a stopped database", async () => {
|
|
196
|
+
let ran = false;
|
|
197
|
+
const r = await applyPreMigrationRoles({
|
|
198
|
+
stagingPassword: "s",
|
|
199
|
+
probe: async () => false,
|
|
200
|
+
psql: async () => {
|
|
201
|
+
ran = true;
|
|
202
|
+
return { exitCode: 0, stderr: "" };
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
expect(r).toEqual({
|
|
206
|
+
ok: false,
|
|
207
|
+
reachable: false,
|
|
208
|
+
error: "the Postgres container is not running",
|
|
209
|
+
});
|
|
210
|
+
expect(ran).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
});
|
package/src/self-hosted-roles.ts
CHANGED
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
* as the Postgres superuser BEFORE it writes the new Compose file: it
|
|
14
14
|
* creates missing roles and databases and sets each role's password to the
|
|
15
15
|
* configured one. It also drops the former `gateway_role` (#635: the
|
|
16
|
-
* gateway has no cms_admin login), with whatever it still holds
|
|
17
|
-
*
|
|
16
|
+
* gateway has no cms_admin login), with whatever it still holds, and
|
|
17
|
+
* (#1329) creates the staging database `cms_public_staging` with the
|
|
18
|
+
* staging gateway's login `public_staging_role` on an install from before
|
|
19
|
+
* it. Every statement is idempotent.
|
|
18
20
|
*
|
|
19
21
|
* Passwords travel on psql's stdin, never on argv.
|
|
20
22
|
*/
|
|
@@ -35,12 +37,82 @@ function literal(s: string): string {
|
|
|
35
37
|
export const AGENT_DB_ROLE = "caelo_agent";
|
|
36
38
|
|
|
37
39
|
/** The application roles bootstrap.sh and the convergence create. */
|
|
38
|
-
const APP_ROLES = ["admin_role", "public_role"] as const;
|
|
40
|
+
const APP_ROLES = ["admin_role", "public_role", "public_staging_role"] as const;
|
|
41
|
+
|
|
42
|
+
/** #1329 — the gateway logins that carry {@link PUBLIC_ROLE_LIMITS}: the live and the staging one. */
|
|
43
|
+
const GATEWAY_LOGINS = ["public_role", "public_staging_role"] as const;
|
|
39
44
|
type AppRole = (typeof APP_ROLES)[number];
|
|
40
45
|
|
|
46
|
+
/**
|
|
47
|
+
* #1338 (gateway hardening H3) — what bounds public_role, the gateway's
|
|
48
|
+
* login, on the database: at most this many connections, no statement or
|
|
49
|
+
* idle transaction that runs away, little sort memory per query. The same
|
|
50
|
+
* values as packages/migrations/src/bootstrap.sh and the cms_admin
|
|
51
|
+
* migration that sets them where admin_role may (Cloud SQL); the role audit
|
|
52
|
+
* (packages/migrations role-audit.ts) refuses an install without them.
|
|
53
|
+
* Self-hosted admin_role has no CREATEROLE, so the superuser applies them
|
|
54
|
+
* here — on the volume's first start (bootstrap.sh), on every `up`, and
|
|
55
|
+
* before the migrations of every upgrade ({@link preMigrationRolesScript}).
|
|
56
|
+
* Since #1329 the staging gateway's public_staging_role carries the same.
|
|
57
|
+
*/
|
|
58
|
+
export const PUBLIC_ROLE_LIMITS = {
|
|
59
|
+
connectionLimit: 30,
|
|
60
|
+
statementTimeout: "5s",
|
|
61
|
+
idleInTransactionSessionTimeout: "15s",
|
|
62
|
+
workMem: "1MB",
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
65
|
+
/** The statements that apply {@link PUBLIC_ROLE_LIMITS} to both gateway logins (idempotent). */
|
|
66
|
+
function publicRoleLimitsSql(): string[] {
|
|
67
|
+
const l = PUBLIC_ROLE_LIMITS;
|
|
68
|
+
return GATEWAY_LOGINS.flatMap((role) => [
|
|
69
|
+
`ALTER ROLE ${role} CONNECTION LIMIT ${l.connectionLimit};`,
|
|
70
|
+
`ALTER ROLE ${role} SET statement_timeout = '${l.statementTimeout}';`,
|
|
71
|
+
`ALTER ROLE ${role} SET idle_in_transaction_session_timeout = '${l.idleInTransactionSessionTimeout}';`,
|
|
72
|
+
`ALTER ROLE ${role} SET work_mem = '${l.workMem}';`,
|
|
73
|
+
]);
|
|
74
|
+
}
|
|
75
|
+
|
|
41
76
|
/** How a role's password appears in the SQL: a literal, or a psql variable. */
|
|
42
77
|
type PasswordSql = (role: AppRole | typeof AGENT_DB_ROLE) => string;
|
|
43
78
|
|
|
79
|
+
/**
|
|
80
|
+
* #1329 — the staging database and its gateway login, with each gateway
|
|
81
|
+
* login on its own database only (the same as bootstrap.sh and cms_admin
|
|
82
|
+
* migration 0530). Idempotent: an install from before #1329 gets both, an
|
|
83
|
+
* install that has them gets the password re-applied.
|
|
84
|
+
*/
|
|
85
|
+
function stagingDatabaseSql(password: PasswordSql): string[] {
|
|
86
|
+
return [
|
|
87
|
+
"SELECT 'CREATE ROLE public_staging_role NOINHERIT LOGIN' WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'public_staging_role') \\gexec",
|
|
88
|
+
`ALTER ROLE public_staging_role WITH LOGIN PASSWORD ${password("public_staging_role")};`,
|
|
89
|
+
"SELECT 'CREATE DATABASE cms_public_staging OWNER admin_role' WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'cms_public_staging') \\gexec",
|
|
90
|
+
"REVOKE ALL ON DATABASE cms_public_staging FROM PUBLIC;",
|
|
91
|
+
"GRANT CONNECT ON DATABASE cms_public_staging TO public_staging_role, admin_role;",
|
|
92
|
+
"REVOKE ALL ON DATABASE cms_public_staging FROM public_role;",
|
|
93
|
+
"REVOKE ALL ON DATABASE cms_public FROM public_staging_role;",
|
|
94
|
+
"REVOKE ALL ON DATABASE cms_admin FROM public_staging_role;",
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The psql script (superuser) the self-hosted upgrade runs BEFORE the
|
|
100
|
+
* migrations: the gateway logins' {@link PUBLIC_ROLE_LIMITS} (#1338) and
|
|
101
|
+
* (#1329) the staging database with its login. The migrations' role audit
|
|
102
|
+
* refuses either gateway login without its limits, and the
|
|
103
|
+
* cms_public_staging run needs the database; the full convergence must
|
|
104
|
+
* wait until after them (it drops roles a migration retired). Fails when
|
|
105
|
+
* public_role does not exist — the install's roles were never created.
|
|
106
|
+
*/
|
|
107
|
+
export function preMigrationRolesScript(stagingPassword: string): string {
|
|
108
|
+
return [
|
|
109
|
+
"\\set ON_ERROR_STOP on",
|
|
110
|
+
...stagingDatabaseSql(() => literal(stagingPassword)),
|
|
111
|
+
...publicRoleLimitsSql(),
|
|
112
|
+
"",
|
|
113
|
+
].join("\n");
|
|
114
|
+
}
|
|
115
|
+
|
|
44
116
|
/**
|
|
45
117
|
* The convergence SQL (psql, as the superuser, connected to any database).
|
|
46
118
|
* `password` renders each role's password; `agent` adds the agent's login.
|
|
@@ -52,6 +124,7 @@ function convergenceSql(password: PasswordSql, agent: boolean): string {
|
|
|
52
124
|
`SELECT 'CREATE ROLE ${role} NOINHERIT LOGIN' WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') \\gexec`,
|
|
53
125
|
`ALTER ROLE ${role} WITH LOGIN PASSWORD ${password(role)};`,
|
|
54
126
|
]),
|
|
127
|
+
...publicRoleLimitsSql(),
|
|
55
128
|
// The upgrader's role (migration 0267 creates it only with CREATEROLE,
|
|
56
129
|
// which the self-hosted admin_role lacks).
|
|
57
130
|
"SELECT 'CREATE ROLE platform_updater NOLOGIN' WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'platform_updater') \\gexec",
|
|
@@ -70,11 +143,15 @@ function convergenceSql(password: PasswordSql, agent: boolean): string {
|
|
|
70
143
|
"REVOKE ALL ON DATABASE cms_public FROM PUBLIC;",
|
|
71
144
|
"GRANT CONNECT ON DATABASE cms_admin TO admin_role;",
|
|
72
145
|
"GRANT CONNECT ON DATABASE cms_public TO public_role, admin_role;",
|
|
146
|
+
// #1329 — after the other two databases exist: its revokes name them.
|
|
147
|
+
...stagingDatabaseSql(password),
|
|
73
148
|
// #635 — the gateway's former cms_admin login goes, with whatever it
|
|
74
149
|
// still holds in either database (the superuser may drop what
|
|
75
150
|
// migration 0285 could only revoke).
|
|
76
151
|
"\\connect cms_public",
|
|
77
152
|
"DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP OWNED BY gateway_role; END IF; END $$;",
|
|
153
|
+
"\\connect cms_public_staging",
|
|
154
|
+
"DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP OWNED BY gateway_role; END IF; END $$;",
|
|
78
155
|
"\\connect cms_admin",
|
|
79
156
|
"DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP OWNED BY gateway_role; END IF; END $$;",
|
|
80
157
|
"DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP ROLE gateway_role; END IF; END $$;",
|
|
@@ -95,6 +172,7 @@ export function roleConvergenceScript(passwords: RolePasswords, agentPassword?:
|
|
|
95
172
|
const byRole: Record<AppRole, string> = {
|
|
96
173
|
admin_role: passwords.admin,
|
|
97
174
|
public_role: passwords.public,
|
|
175
|
+
public_staging_role: passwords.publicStaging,
|
|
98
176
|
};
|
|
99
177
|
return convergenceSql(
|
|
100
178
|
(role) => literal(role === AGENT_DB_ROLE ? (agentPassword ?? "") : byRole[role]),
|
|
@@ -106,6 +184,7 @@ export function roleConvergenceScript(passwords: RolePasswords, agentPassword?:
|
|
|
106
184
|
const PASSWORD_ENV: Record<AppRole | typeof AGENT_DB_ROLE, string> = {
|
|
107
185
|
admin_role: "ADMIN_ROLE_PASSWORD",
|
|
108
186
|
public_role: "PUBLIC_ROLE_PASSWORD",
|
|
187
|
+
public_staging_role: "PUBLIC_STAGING_ROLE_PASSWORD",
|
|
109
188
|
[AGENT_DB_ROLE]: "AGENT_ROLE_PASSWORD",
|
|
110
189
|
};
|
|
111
190
|
|
|
@@ -143,6 +222,25 @@ ${convergenceSql((role) => `:'${role}_pw'`, opts.agent)}CAELO_SQL
|
|
|
143
222
|
/** Runs a psql script as the superuser; resolves to psql's exit code + stderr. */
|
|
144
223
|
export type SuperuserPsql = (script: string) => Promise<{ exitCode: number; stderr: string }>;
|
|
145
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Apply {@link preMigrationRolesScript} through the running Postgres
|
|
227
|
+
* container. Unlike {@link convergeSelfHostedRoles} a stopped database is a
|
|
228
|
+
* failure: the upgrade runs it right before migrations that need it.
|
|
229
|
+
*/
|
|
230
|
+
export async function applyPreMigrationRoles(deps: {
|
|
231
|
+
readonly probe: () => Promise<boolean>;
|
|
232
|
+
readonly psql: SuperuserPsql;
|
|
233
|
+
/** #1329 — public_staging_role's password from `.caelo/config.json`. */
|
|
234
|
+
readonly stagingPassword: string;
|
|
235
|
+
}): Promise<ConvergeOutcome> {
|
|
236
|
+
if (!(await deps.probe())) {
|
|
237
|
+
return { ok: false, reachable: false, error: "the Postgres container is not running" };
|
|
238
|
+
}
|
|
239
|
+
const r = await deps.psql(preMigrationRolesScript(deps.stagingPassword));
|
|
240
|
+
if (r.exitCode === 0) return { ok: true };
|
|
241
|
+
return { ok: false, reachable: true, error: r.stderr.trim() || `psql exited ${r.exitCode}` };
|
|
242
|
+
}
|
|
243
|
+
|
|
146
244
|
export type ConvergeOutcome =
|
|
147
245
|
| { readonly ok: true }
|
|
148
246
|
/** `reachable: false` — the database is not running, nothing was attempted. */
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #1140 — the self-hosted Pulumi stack and the CLI render the same Compose
|
|
5
|
+
* file, so the scheduler interval `init` recorded survives a `pulumi up`,
|
|
6
|
+
* and a disagreeing Pulumi key is refused instead of winning silently.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterAll, describe, expect, it } from "bun:test";
|
|
10
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
recordedSchedulerIntervalMinutes,
|
|
15
|
+
selfHostedSchedulerIntervalMinutes,
|
|
16
|
+
} from "./self-hosted-scheduler-interval.js";
|
|
17
|
+
|
|
18
|
+
const dir = mkdtempSync(join(tmpdir(), "caelo-scheduler-interval-"));
|
|
19
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
20
|
+
|
|
21
|
+
function config(name: string, body: string): string {
|
|
22
|
+
const path = join(dir, name);
|
|
23
|
+
writeFileSync(path, body);
|
|
24
|
+
return path;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("recordedSchedulerIntervalMinutes", () => {
|
|
28
|
+
it("reads what init recorded", () => {
|
|
29
|
+
expect(
|
|
30
|
+
recordedSchedulerIntervalMinutes(config("a.json", '{"schedulerIntervalMinutes":2}')),
|
|
31
|
+
).toBe(2);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("is undefined without a config.json or without the key", () => {
|
|
35
|
+
expect(recordedSchedulerIntervalMinutes(join(dir, "missing.json"))).toBeUndefined();
|
|
36
|
+
expect(recordedSchedulerIntervalMinutes(config("b.json", '{"domain":"x"}'))).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("refuses a broken file or an out-of-range value", () => {
|
|
40
|
+
expect(() => recordedSchedulerIntervalMinutes(config("c.json", "{"))).toThrow("not valid JSON");
|
|
41
|
+
expect(() =>
|
|
42
|
+
recordedSchedulerIntervalMinutes(config("d.json", '{"schedulerIntervalMinutes":30}')),
|
|
43
|
+
).toThrow("between 1 and 15");
|
|
44
|
+
expect(() =>
|
|
45
|
+
recordedSchedulerIntervalMinutes(config("e.json", '{"schedulerIntervalMinutes":"2"}')),
|
|
46
|
+
).toThrow("not a number");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("selfHostedSchedulerIntervalMinutes", () => {
|
|
51
|
+
it("keeps the recorded interval when the Pulumi key is unset", () => {
|
|
52
|
+
expect(selfHostedSchedulerIntervalMinutes(undefined, 2, "config.json")).toBe(2);
|
|
53
|
+
expect(selfHostedSchedulerIntervalMinutes(undefined, undefined, "config.json")).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("takes the Pulumi key on a Pulumi-only install, or when both agree", () => {
|
|
57
|
+
expect(selfHostedSchedulerIntervalMinutes(3, undefined, "config.json")).toBe(3);
|
|
58
|
+
expect(selfHostedSchedulerIntervalMinutes(2, 2, "config.json")).toBe(2);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("refuses a Pulumi key that disagrees with the record", () => {
|
|
62
|
+
expect(() => selfHostedSchedulerIntervalMinutes(5, 2, "config.json")).toThrow(
|
|
63
|
+
"config.json records 2",
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #1140 — the self-hosted scheduler interval has two places it can be set:
|
|
5
|
+
* `cms-provision init --scheduler-interval-minutes` records it in
|
|
6
|
+
* `.caelo/config.json` (which every CLI render — `up`, `upgrade`, agent
|
|
7
|
+
* apply — reads), and the Pulumi stack has the config key
|
|
8
|
+
* `caelo-self-hosted:schedulerIntervalMinutes`. Both rewrite the same
|
|
9
|
+
* `docker-compose.yml`, so if each read only its own source a `pulumi up`
|
|
10
|
+
* over a CLI install would silently put the interval back to the default,
|
|
11
|
+
* and the next CLI `upgrade` would undo a Pulumi-only value the same way.
|
|
12
|
+
*
|
|
13
|
+
* The recorded `.caelo/config.json` is the install's one record (the CLI
|
|
14
|
+
* renders from nothing else), so the stack takes the interval from it; the
|
|
15
|
+
* Pulumi key may restate it but a different value is refused rather than
|
|
16
|
+
* one side winning silently (CLAUDE.md §2: no fallbacks).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
import { parseSchedulerIntervalMinutes } from "./stack-contract.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Read the interval `init` recorded in a `.caelo/config.json`.
|
|
24
|
+
*
|
|
25
|
+
* @param configPath - the install's `config.json`.
|
|
26
|
+
* @returns the recorded minutes, or undefined when there is no config.json
|
|
27
|
+
* (a Pulumi-only install) or it records no interval (the default).
|
|
28
|
+
* @throws when the file is not JSON or the recorded value is out of bounds.
|
|
29
|
+
*/
|
|
30
|
+
export function recordedSchedulerIntervalMinutes(configPath: string): number | undefined {
|
|
31
|
+
if (!existsSync(configPath)) return undefined;
|
|
32
|
+
let parsed: unknown;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
35
|
+
} catch (e) {
|
|
36
|
+
throw new Error(`${configPath} is not valid JSON: ${(e as Error).message}`);
|
|
37
|
+
}
|
|
38
|
+
const raw =
|
|
39
|
+
parsed !== null && typeof parsed === "object"
|
|
40
|
+
? (parsed as { schedulerIntervalMinutes?: unknown }).schedulerIntervalMinutes
|
|
41
|
+
: undefined;
|
|
42
|
+
if (raw === undefined) return undefined;
|
|
43
|
+
if (typeof raw !== "number") {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`${configPath}: schedulerIntervalMinutes ${JSON.stringify(raw)} is not a number of minutes`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return parseSchedulerIntervalMinutes(raw, `${configPath}: schedulerIntervalMinutes`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The interval the self-hosted Pulumi stack renders into the Compose file.
|
|
53
|
+
*
|
|
54
|
+
* @param pulumiValue - `caelo-self-hosted:schedulerIntervalMinutes`, if set.
|
|
55
|
+
* @param recorded - what {@link recordedSchedulerIntervalMinutes} read.
|
|
56
|
+
* @param configPath - named in the refusal.
|
|
57
|
+
* @returns the minutes to render; undefined means neither source sets one
|
|
58
|
+
* (the generator's default).
|
|
59
|
+
* @throws when both are set and differ.
|
|
60
|
+
*/
|
|
61
|
+
export function selfHostedSchedulerIntervalMinutes(
|
|
62
|
+
pulumiValue: number | undefined,
|
|
63
|
+
recorded: number | undefined,
|
|
64
|
+
configPath: string,
|
|
65
|
+
): number | undefined {
|
|
66
|
+
if (pulumiValue === undefined) return recorded;
|
|
67
|
+
const minutes = parseSchedulerIntervalMinutes(
|
|
68
|
+
pulumiValue,
|
|
69
|
+
"caelo-self-hosted:schedulerIntervalMinutes",
|
|
70
|
+
);
|
|
71
|
+
if (recorded !== undefined && recorded !== minutes) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`caelo-self-hosted:schedulerIntervalMinutes is ${minutes} but ${configPath} records ${recorded}; ` +
|
|
74
|
+
"the CLI renders the Compose file from config.json, so the two must agree. " +
|
|
75
|
+
`Set the Pulumi key to ${recorded}, or remove it (\`pulumi config rm caelo-self-hosted:schedulerIntervalMinutes\`) to use the recorded value.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return minutes;
|
|
79
|
+
}
|