@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
package/src/compose.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* P14 — production docker-compose.yml generator.
|
|
5
5
|
*
|
|
6
|
-
* Emits the production stack: Postgres + Caddy + the
|
|
7
|
-
* (admin, gateway). Idempotent: same
|
|
8
|
-
* byte-identical output. The CLI's `init` / `up` / `upgrade` sub-commands
|
|
6
|
+
* Emits the production stack: Postgres + Caddy + the Caelo services
|
|
7
|
+
* (admin, gateway and — #1328 — the MCP-Auth-Proxy). Idempotent: same
|
|
8
|
+
* input → byte-identical output. The CLI's `init` / `up` / `upgrade` sub-commands
|
|
9
9
|
* write this to `.caelo/docker-compose.yml`.
|
|
10
10
|
*
|
|
11
11
|
* #696 — the admin and gateway run the PREBUILT, cosign-verified release
|
|
@@ -27,23 +27,39 @@
|
|
|
27
27
|
* every fresh `docker compose up` fail.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import { CADDY_REDIRECTS_FILE } from "@caelo-cms/shared";
|
|
30
|
+
import { CADDY_REDIRECTS_FILE, SELF_HOSTED_CA_FILE, verifyFullQuery } from "@caelo-cms/shared";
|
|
31
31
|
import { caddyRedirectReloadCommand } from "./caddy-redirects.js";
|
|
32
32
|
import { DEFAULT_MAX_CONNECTIONS, stackPoolBudget } from "./pool-budget-stack.js";
|
|
33
|
+
import {
|
|
34
|
+
SELF_HOSTED_DB_HOST,
|
|
35
|
+
SELF_HOSTED_TLS_DIR,
|
|
36
|
+
SELF_HOSTED_TLS_FILES,
|
|
37
|
+
} from "./self-hosted-db-tls.js";
|
|
33
38
|
import {
|
|
34
39
|
ADMIN_MEMORY_DEFAULT,
|
|
40
|
+
GATEWAY_ENVIRONMENT_ENV,
|
|
35
41
|
GATEWAY_TRUSTED_PROXY_HOPS,
|
|
42
|
+
MCP_AUTH_PROXY_TRUSTED_PROXY_HOPS,
|
|
36
43
|
memoryQuantityMiB,
|
|
44
|
+
parseSchedulerIntervalMinutes,
|
|
37
45
|
publicSiteUrl,
|
|
46
|
+
REMOTE_MCP_MODE,
|
|
47
|
+
REMOTE_MCP_MODE_ENV,
|
|
38
48
|
TRUSTED_PROXY_HOPS_ENV,
|
|
39
49
|
} from "./stack-contract.js";
|
|
40
50
|
import { ghcrRepository, isImageDigest, type ReleaseService } from "./upgrade/release-images.js";
|
|
41
51
|
|
|
42
|
-
/** The
|
|
52
|
+
/** The release images, each `ghcr.io/caelo-cms/<svc>@sha256:…`. */
|
|
43
53
|
export interface ReleaseImages {
|
|
44
54
|
readonly mode: "release";
|
|
45
55
|
readonly admin: string;
|
|
46
56
|
readonly gateway: string;
|
|
57
|
+
/**
|
|
58
|
+
* #1328 — the MCP-Auth-Proxy. Absent only for a release recorded before
|
|
59
|
+
* the proxy shipped (a rollback target in release.json): its Compose file
|
|
60
|
+
* then has no proxy service, exactly as that release ran.
|
|
61
|
+
*/
|
|
62
|
+
readonly "mcp-auth-proxy"?: string;
|
|
47
63
|
}
|
|
48
64
|
|
|
49
65
|
/**
|
|
@@ -70,6 +86,8 @@ export const COMPOSE_POOL_BUDGET = stackPoolBudget("self-hosted", {
|
|
|
70
86
|
maxConnections: DEFAULT_MAX_CONNECTIONS["self-hosted"],
|
|
71
87
|
adminMaxInstances: 1,
|
|
72
88
|
gatewayMaxInstances: 1,
|
|
89
|
+
// #1329 — one staging gateway container (P3b-2) next to the live one.
|
|
90
|
+
stagingGatewayMaxInstances: 1,
|
|
73
91
|
});
|
|
74
92
|
|
|
75
93
|
/** The budget's env as Compose `environment:` lines for `service`. */
|
|
@@ -102,11 +120,20 @@ export const MEDIA_ROOT = "/data/media";
|
|
|
102
120
|
/** The gateway's HTTP port inside the Compose network. */
|
|
103
121
|
export const COMPOSE_GATEWAY_PORT = 8090;
|
|
104
122
|
|
|
123
|
+
/** #1328 — the MCP-Auth-Proxy's HTTP port inside the Compose network. */
|
|
124
|
+
export const COMPOSE_MCP_AUTH_PROXY_PORT = 8095;
|
|
125
|
+
|
|
126
|
+
/** #1328 — the Compose service name of the MCP-Auth-Proxy. */
|
|
127
|
+
export const COMPOSE_MCP_AUTH_PROXY_SERVICE = "caelo-mcp-auth-proxy";
|
|
128
|
+
|
|
105
129
|
/**
|
|
106
|
-
* The Compose services that
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
130
|
+
* The Compose services that touch the databases — what `backup` and
|
|
131
|
+
* `restore` stop while they read or rewrite them. Workers run inside the
|
|
132
|
+
* admin (CLAUDE.md §11.B Tier 5); there is no orchestrator or runner
|
|
133
|
+
* service. The MCP-Auth-Proxy (#1328) is deliberately not here: it holds no
|
|
134
|
+
* database login, so it keeps running, and a release from before it has no
|
|
135
|
+
* such service for `up` to start. Its health is checked wherever the
|
|
136
|
+
* install starts (self-hosted-start.ts) or rolls (self-hosted-upgrade.ts).
|
|
110
137
|
*/
|
|
111
138
|
export const COMPOSE_APP_SERVICES = ["caelo-admin", "caelo-gateway"] as const;
|
|
112
139
|
|
|
@@ -117,6 +144,7 @@ export const COMPOSE_APP_SERVICES = ["caelo-admin", "caelo-gateway"] as const;
|
|
|
117
144
|
*/
|
|
118
145
|
export const COMPOSE_ADMIN_UPSTREAM = `caelo-admin:${COMPOSE_ADMIN_PORT}`;
|
|
119
146
|
export const COMPOSE_GATEWAY_UPSTREAM = `caelo-gateway:${COMPOSE_GATEWAY_PORT}`;
|
|
147
|
+
export const COMPOSE_MCP_AUTH_PROXY_UPSTREAM = `${COMPOSE_MCP_AUTH_PROXY_SERVICE}:${COMPOSE_MCP_AUTH_PROXY_PORT}`;
|
|
120
148
|
|
|
121
149
|
/**
|
|
122
150
|
* The Caddy image, pinned to the release the generated Caddyfile is
|
|
@@ -192,6 +220,8 @@ export function ghcrImageDigest(service: ReleaseService, ref: string): string |
|
|
|
192
220
|
export interface RolePasswords {
|
|
193
221
|
readonly admin: string;
|
|
194
222
|
readonly public: string;
|
|
223
|
+
/** #1329 — public_staging_role's, the staging gateway's login on cms_public_staging. */
|
|
224
|
+
readonly publicStaging: string;
|
|
195
225
|
}
|
|
196
226
|
|
|
197
227
|
export interface ComposeSpec {
|
|
@@ -213,6 +243,15 @@ export interface ComposeSpec {
|
|
|
213
243
|
* the gateway never gets it.
|
|
214
244
|
*/
|
|
215
245
|
readonly internalSecret: string;
|
|
246
|
+
/**
|
|
247
|
+
* #1338 — SHA-256 (hex) of the database server certificate the Postgres
|
|
248
|
+
* service mounts (`.caelo/postgres-tls/server.crt`). It is part of the
|
|
249
|
+
* service's environment so a renewed certificate changes the service's
|
|
250
|
+
* definition and `docker compose up -d` recreates Postgres: a bind-mounted
|
|
251
|
+
* file whose content changes does not, and Postgres would keep serving
|
|
252
|
+
* the old certificate until verify-full clients refuse it.
|
|
253
|
+
*/
|
|
254
|
+
readonly dbTlsServerCertSha256: string;
|
|
216
255
|
readonly anthropicApiKey?: string;
|
|
217
256
|
readonly resendApiKey?: string;
|
|
218
257
|
readonly diskSize: string;
|
|
@@ -223,8 +262,14 @@ export interface ComposeSpec {
|
|
|
223
262
|
* busy host never OOM-kills the admin at the limit.
|
|
224
263
|
*/
|
|
225
264
|
readonly adminMemory?: string;
|
|
226
|
-
/** Which
|
|
265
|
+
/** Which Caelo images run (see {@link ComposeImages}). */
|
|
227
266
|
readonly images: ComposeImages;
|
|
267
|
+
/**
|
|
268
|
+
* #1140 — the `schedulerIntervalMinutes` knob: how often the scheduler
|
|
269
|
+
* container calls the admin's tick (1–15, default 5; stack-contract.ts
|
|
270
|
+
* SCHEDULER_INTERVAL_MINUTES).
|
|
271
|
+
*/
|
|
272
|
+
readonly schedulerIntervalMinutes?: number;
|
|
228
273
|
/**
|
|
229
274
|
* The host ports Caddy listens on (`80` / `443` for a public install —
|
|
230
275
|
* ACME's HTTP and TLS-ALPN challenges need exactly those).
|
|
@@ -274,6 +319,11 @@ export function generateDockerCompose(spec: ComposeSpec): string {
|
|
|
274
319
|
if (spec.internalSecret.length < 32) {
|
|
275
320
|
throw new Error("internalSecret is shorter than 32 characters (.caelo/internal-secret.json)");
|
|
276
321
|
}
|
|
322
|
+
if (!/^[0-9a-f]{64}$/.test(spec.dbTlsServerCertSha256)) {
|
|
323
|
+
throw new Error(
|
|
324
|
+
"dbTlsServerCertSha256 is not a SHA-256 hex digest of .caelo/postgres-tls/server.crt",
|
|
325
|
+
);
|
|
326
|
+
}
|
|
277
327
|
return `# SPDX-License-Identifier: MPL-2.0
|
|
278
328
|
# Generated by cms-provision — do not edit; re-run \`bunx cms-provision up\`.
|
|
279
329
|
# Domain: ${spec.domain}
|
|
@@ -281,10 +331,16 @@ export function generateDockerCompose(spec: ComposeSpec): string {
|
|
|
281
331
|
name: ${spec.project}
|
|
282
332
|
|
|
283
333
|
services:
|
|
284
|
-
|
|
334
|
+
${SELF_HOSTED_DB_HOST}:
|
|
285
335
|
image: postgres:16-alpine
|
|
286
|
-
#
|
|
287
|
-
|
|
336
|
+
# #1338 — TLS with the install's own certificate for the host name
|
|
337
|
+
# \`${SELF_HOSTED_DB_HOST}\` (self-hosted-db-tls.ts); every client connects with
|
|
338
|
+
# verify-full. Postgres refuses a key file it does not own, and a bind
|
|
339
|
+
# mount keeps the host's owner, so the entrypoint copies the pair to a
|
|
340
|
+
# container-local path owned by postgres first.
|
|
341
|
+
entrypoint: ${POSTGRES_TLS_ENTRYPOINT}
|
|
342
|
+
# The pool budget (COMPOSE_POOL_BUDGET) is computed from max_connections.
|
|
343
|
+
command: ["postgres", "-c", "max_connections=${DEFAULT_MAX_CONNECTIONS["self-hosted"]}", "-c", "ssl=on", "-c", "ssl_cert_file=${POSTGRES_TLS_RUNTIME_DIR}/server.crt", "-c", "ssl_key_file=${POSTGRES_TLS_RUNTIME_DIR}/server.key", "-c", "ssl_min_protocol_version=TLSv1.2"]
|
|
288
344
|
restart: unless-stopped
|
|
289
345
|
environment:
|
|
290
346
|
POSTGRES_PASSWORD: "${escapeYaml(spec.postgresPassword)}"
|
|
@@ -292,9 +348,14 @@ services:
|
|
|
292
348
|
POSTGRES_DB: caelo
|
|
293
349
|
ADMIN_ROLE_PASSWORD: "${escapeYaml(spec.rolePasswords.admin)}"
|
|
294
350
|
PUBLIC_ROLE_PASSWORD: "${escapeYaml(spec.rolePasswords.public)}"
|
|
351
|
+
PUBLIC_STAGING_ROLE_PASSWORD: "${escapeYaml(spec.rolePasswords.publicStaging)}"
|
|
352
|
+
# Recreates Postgres when its certificate is renewed (see the spec).
|
|
353
|
+
CAELO_DB_TLS_SERVER_CERT_SHA256: "${spec.dbTlsServerCertSha256}"
|
|
295
354
|
${postgresAgent(spec.agent)} volumes:
|
|
296
355
|
- caelo-pg:/var/lib/postgresql/data
|
|
297
356
|
- ./bootstrap.sh:/docker-entrypoint-initdb.d/00_bootstrap.sh:ro
|
|
357
|
+
- ./${SELF_HOSTED_TLS_DIR}/${SELF_HOSTED_TLS_FILES.serverCert}:${POSTGRES_TLS_MOUNT_DIR}/server.crt:ro
|
|
358
|
+
- ./${SELF_HOSTED_TLS_DIR}/${SELF_HOSTED_TLS_FILES.serverKey}:${POSTGRES_TLS_MOUNT_DIR}/server.key:ro
|
|
298
359
|
healthcheck:
|
|
299
360
|
test: ["CMD-SHELL", "pg_isready -U caelo"]
|
|
300
361
|
interval: 10s
|
|
@@ -317,7 +378,8 @@ ${postgresAgent(spec.agent)} volumes:
|
|
|
317
378
|
${adminService(spec, adminMemoryMiB, env)}
|
|
318
379
|
|
|
319
380
|
${gatewayService(spec)}
|
|
320
|
-
|
|
381
|
+
${mcpAuthProxyService(spec.images)}
|
|
382
|
+
${schedulerService(spec)}
|
|
321
383
|
volumes:
|
|
322
384
|
caelo-pg:
|
|
323
385
|
caelo-caddy-data:
|
|
@@ -369,10 +431,11 @@ function serviceRuntime(
|
|
|
369
431
|
...volumes(["../..:/app", ...mounts]),
|
|
370
432
|
].join("\n");
|
|
371
433
|
}
|
|
372
|
-
const
|
|
434
|
+
const ref = images[service] ?? "";
|
|
435
|
+
const digest = ghcrImageDigest(service, ref);
|
|
373
436
|
if (!digest) {
|
|
374
437
|
throw new Error(
|
|
375
|
-
`${service} image ${JSON.stringify(
|
|
438
|
+
`${service} image ${JSON.stringify(ref)} is not ${ghcrRepository(service)}@sha256:<digest>`,
|
|
376
439
|
);
|
|
377
440
|
}
|
|
378
441
|
// The image's own CMD starts the service; no source is mounted.
|
|
@@ -398,6 +461,7 @@ ${serviceRuntime(
|
|
|
398
461
|
`./${BOOTSTRAP_DIR}:/app/.caelo`,
|
|
399
462
|
`./output:${SITE_OUTPUT_ROOT}`,
|
|
400
463
|
`./${MEDIA_DIR}:${MEDIA_ROOT}`,
|
|
464
|
+
DATABASE_CA_MOUNT,
|
|
401
465
|
],
|
|
402
466
|
)}
|
|
403
467
|
restart: unless-stopped
|
|
@@ -407,6 +471,8 @@ ${serviceRuntime(
|
|
|
407
471
|
environment:
|
|
408
472
|
ADMIN_DATABASE_URL: "${dbUrl("admin_role", spec.rolePasswords.admin, "cms_admin")}"
|
|
409
473
|
PUBLIC_ADMIN_DATABASE_URL: "${dbUrl("admin_role", spec.rolePasswords.admin, "cms_public")}"
|
|
474
|
+
# #1329 — the staging database's plugin schemas and projection.
|
|
475
|
+
PUBLIC_STAGING_ADMIN_DATABASE_URL: "${dbUrl("admin_role", spec.rolePasswords.admin, "cms_public_staging")}"
|
|
410
476
|
CAELO_SECRET_KEK: "${escapeYaml(spec.caeloSecretKek)}"
|
|
411
477
|
CAELO_INTERNAL_SECRET: "${escapeYaml(spec.internalSecret)}"
|
|
412
478
|
CAELO_SITE_URL: "${escapeYaml(selfHostedSiteUrl(spec.domain, spec.ports.https))}"
|
|
@@ -422,7 +488,7 @@ ${env("RESEND_API_KEY", spec.resendApiKey)}`;
|
|
|
422
488
|
|
|
423
489
|
function gatewayService(spec: ComposeSpec): string {
|
|
424
490
|
return ` caelo-gateway:
|
|
425
|
-
${serviceRuntime(spec.images, "gateway", "bun run apps/api-gateway/src/server.ts")}
|
|
491
|
+
${serviceRuntime(spec.images, "gateway", "bun run apps/api-gateway/src/server.ts", [DATABASE_CA_MOUNT])}
|
|
426
492
|
restart: unless-stopped
|
|
427
493
|
depends_on:
|
|
428
494
|
postgres: { condition: service_healthy }
|
|
@@ -430,23 +496,126 @@ ${serviceRuntime(spec.images, "gateway", "bun run apps/api-gateway/src/server.ts
|
|
|
430
496
|
# public_role on cms_public is its only database login (#613, #635,
|
|
431
497
|
# CLAUDE.md §2: the public side has no path to the authoring database).
|
|
432
498
|
PUBLIC_DATABASE_URL: "${dbUrl("public_role", spec.rolePasswords.public, "cms_public")}"
|
|
499
|
+
# #1329 — the live site's gateway: it refuses anything of the staging data.
|
|
500
|
+
${GATEWAY_ENVIRONMENT_ENV}: "live"
|
|
433
501
|
GATEWAY_PORT: "${COMPOSE_GATEWAY_PORT}"
|
|
434
502
|
# Caddy is the one proxy in front (client-ip.ts in the gateway).
|
|
435
503
|
${TRUSTED_PROXY_HOPS_ENV}: "${GATEWAY_TRUSTED_PROXY_HOPS["self-hosted"]}"
|
|
436
504
|
${poolEnvLines("gateway")}
|
|
437
|
-
# #806 — no remote MCP here: it rides Google IAP, which this install
|
|
438
|
-
# does not have. CAELO_ADMIN_URL / CAELO_MCP_PUBLIC_URL /
|
|
439
|
-
# CAELO_OPERATOR_OAUTH_CLIENT_FILE stay unset, so the gateway's /api/mcp/*
|
|
440
|
-
# answers 503 remote_mcp_not_configured and Settings → MCP says
|
|
441
|
-
# "not available on this install".
|
|
442
505
|
NODE_ENV: production`;
|
|
443
506
|
}
|
|
444
507
|
|
|
445
|
-
/**
|
|
508
|
+
/**
|
|
509
|
+
* #1328 — the MCP-Auth-Proxy: remote MCP's sign-in broker, its own
|
|
510
|
+
* container so it never shares a process with visitor handlers. Remote MCP
|
|
511
|
+
* needs the admin behind Google IAP, which a Compose install does not have,
|
|
512
|
+
* so it runs with `CAELO_REMOTE_MCP=unavailable` and answers every request
|
|
513
|
+
* but its health check with one fixed 404 (E14 a of #763: the service
|
|
514
|
+
* exists on every provider, its attack surface one branch). Caddy serves it
|
|
515
|
+
* on its own host (`mcp.<domain>`, caddy.ts), never on the site's origin.
|
|
516
|
+
*
|
|
517
|
+
* It gets no database URL, no secret and no mount: there is nothing it
|
|
518
|
+
* could read. The container is locked down on top — read-only root
|
|
519
|
+
* filesystem, every capability dropped, no privilege escalation — because
|
|
520
|
+
* it is the Caelo service that holds operators' Google tokens wherever
|
|
521
|
+
* remote MCP runs. Empty for a release recorded before the proxy shipped
|
|
522
|
+
* ({@link ReleaseImages}).
|
|
523
|
+
*/
|
|
524
|
+
function mcpAuthProxyService(images: ComposeImages): string {
|
|
525
|
+
if (images.mode === "release" && images["mcp-auth-proxy"] === undefined) return "";
|
|
526
|
+
return `
|
|
527
|
+
${COMPOSE_MCP_AUTH_PROXY_SERVICE}:
|
|
528
|
+
${serviceRuntime(images, "mcp-auth-proxy", "bun run apps/mcp-auth-proxy/src/server.ts")}
|
|
529
|
+
restart: unless-stopped
|
|
530
|
+
read_only: true
|
|
531
|
+
tmpfs: ["/tmp"]
|
|
532
|
+
cap_drop: ["ALL"]
|
|
533
|
+
security_opt: ["no-new-privileges:true"]
|
|
534
|
+
environment:
|
|
535
|
+
CAELO_PROVIDER: "self-hosted"
|
|
536
|
+
${REMOTE_MCP_MODE_ENV}: "${REMOTE_MCP_MODE["self-hosted"]}"
|
|
537
|
+
MCP_AUTH_PROXY_PORT: "${COMPOSE_MCP_AUTH_PROXY_PORT}"
|
|
538
|
+
# Caddy is the one proxy in front (@caelo-cms/shared client-address.ts).
|
|
539
|
+
${TRUSTED_PROXY_HOPS_ENV}: "${MCP_AUTH_PROXY_TRUSTED_PROXY_HOPS["self-hosted"]}"
|
|
540
|
+
NODE_ENV: production
|
|
541
|
+
`;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** #1140 — the Compose service that calls the admin's scheduler tick. */
|
|
545
|
+
export const COMPOSE_SCHEDULER_SERVICE = "caelo-scheduler";
|
|
546
|
+
|
|
547
|
+
/** Where the admin image (and the dev checkout) ships the scheduler clock. */
|
|
548
|
+
export const SCHEDULER_CLOCK_SCRIPT = "packages/admin-core/src/scheduler/clock.ts";
|
|
549
|
+
|
|
550
|
+
/** The admin's tick endpoint on the Compose network. */
|
|
551
|
+
export const COMPOSE_SCHEDULER_TICK_URL = `http://${COMPOSE_ADMIN_UPSTREAM}/api/internal/scheduler/tick`;
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* #1140 — the scheduler: the self-hosted counterpart of the Cloud Scheduler
|
|
555
|
+
* job on Google Cloud. Every `schedulerIntervalMinutes` it POSTs the admin's
|
|
556
|
+
* `/api/internal/scheduler/tick` with an internal token (scope
|
|
557
|
+
* `scheduler.tick`); the admin runs whatever its due-table says is due. The
|
|
558
|
+
* clock only calls, it does no work itself, so the admin's background work
|
|
559
|
+
* runs inside a request on every provider.
|
|
560
|
+
*
|
|
561
|
+
* It runs the admin image with another entry point (dependency-free, node
|
|
562
|
+
* built-ins only) and holds only the internal secret it signs with: no
|
|
563
|
+
* database URL, no KEK, no mount, read-only root filesystem, every
|
|
564
|
+
* capability dropped. A rollback to a release from before #1140 renders it
|
|
565
|
+
* too; that admin image has no clock script, so the container exits and
|
|
566
|
+
* Docker restarts it with back-off — that release runs its work in-process.
|
|
567
|
+
*/
|
|
568
|
+
function schedulerService(spec: ComposeSpec): string {
|
|
569
|
+
const minutes = parseSchedulerIntervalMinutes(spec.schedulerIntervalMinutes);
|
|
570
|
+
const runtime =
|
|
571
|
+
spec.images.mode === "dev"
|
|
572
|
+
? serviceRuntime(spec.images, "admin", `bun run ${SCHEDULER_CLOCK_SCRIPT}`)
|
|
573
|
+
: [
|
|
574
|
+
serviceRuntime(spec.images, "admin", ""),
|
|
575
|
+
` command: ["bun", "--bun", "/app/${SCHEDULER_CLOCK_SCRIPT}"]`,
|
|
576
|
+
].join("\n");
|
|
577
|
+
return `
|
|
578
|
+
${COMPOSE_SCHEDULER_SERVICE}:
|
|
579
|
+
${runtime}
|
|
580
|
+
restart: unless-stopped
|
|
581
|
+
read_only: true
|
|
582
|
+
tmpfs: ["/tmp"]
|
|
583
|
+
cap_drop: ["ALL"]
|
|
584
|
+
security_opt: ["no-new-privileges:true"]
|
|
585
|
+
depends_on: [caelo-admin]
|
|
586
|
+
environment:
|
|
587
|
+
CAELO_INTERNAL_SECRET: "${escapeYaml(spec.internalSecret)}"
|
|
588
|
+
CAELO_SCHEDULER_TICK_URL: "${COMPOSE_SCHEDULER_TICK_URL}"
|
|
589
|
+
CAELO_SCHEDULER_INTERVAL_MINUTES: "${minutes}"
|
|
590
|
+
NODE_ENV: production
|
|
591
|
+
`;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* A compose-network database URL, password URL-encoded and YAML-escaped.
|
|
596
|
+
* #1338 — verify-full against the install's own CA (self-hosted-db-tls.ts),
|
|
597
|
+
* mounted at shared `SELF_HOSTED_CA_FILE` ({@link DATABASE_CA_MOUNT}).
|
|
598
|
+
*/
|
|
446
599
|
function dbUrl(role: string, password: string, database: string): string {
|
|
447
|
-
return escapeYaml(
|
|
600
|
+
return escapeYaml(
|
|
601
|
+
`postgres://${role}:${encodeURIComponent(password)}@${SELF_HOSTED_DB_HOST}:5432/${database}?${verifyFullQuery(SELF_HOSTED_CA_FILE)}`,
|
|
602
|
+
);
|
|
448
603
|
}
|
|
449
604
|
|
|
605
|
+
/** Where the server certificate + key are bind-mounted into Postgres (host-owned). */
|
|
606
|
+
const POSTGRES_TLS_MOUNT_DIR = "/caelo-tls";
|
|
607
|
+
/** Where the entrypoint copies them, owned by postgres (Postgres refuses others' keys). */
|
|
608
|
+
const POSTGRES_TLS_RUNTIME_DIR = "/var/lib/postgresql/caelo-tls";
|
|
609
|
+
/** The Postgres entrypoint: copy the TLS pair into place, then the image's own entrypoint. */
|
|
610
|
+
const POSTGRES_TLS_ENTRYPOINT = JSON.stringify([
|
|
611
|
+
"/bin/sh",
|
|
612
|
+
"-c",
|
|
613
|
+
`install -d -o postgres -g postgres -m 0700 ${POSTGRES_TLS_RUNTIME_DIR} && install -o postgres -g postgres -m 0644 ${POSTGRES_TLS_MOUNT_DIR}/server.crt ${POSTGRES_TLS_RUNTIME_DIR}/server.crt && install -o postgres -g postgres -m 0600 ${POSTGRES_TLS_MOUNT_DIR}/server.key ${POSTGRES_TLS_RUNTIME_DIR}/server.key && exec docker-entrypoint.sh "$$@"`,
|
|
614
|
+
"--",
|
|
615
|
+
]);
|
|
616
|
+
/** The install's database CA, read-only, where every verify-full URL names it. */
|
|
617
|
+
const DATABASE_CA_MOUNT = `./${SELF_HOSTED_TLS_DIR}/${SELF_HOSTED_TLS_FILES.caCert}:${SELF_HOSTED_CA_FILE}:ro`;
|
|
618
|
+
|
|
450
619
|
function escapeYaml(s: string): string {
|
|
451
620
|
// Conservative escape for double-quoted YAML strings.
|
|
452
621
|
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
@@ -44,7 +44,14 @@ export const ConvergeCheckpointSchema = InstallJobStatusSchema.extend({
|
|
|
44
44
|
backupId: z.string().min(1),
|
|
45
45
|
})
|
|
46
46
|
.optional(),
|
|
47
|
-
priorRevisions: z
|
|
47
|
+
priorRevisions: z
|
|
48
|
+
.object({
|
|
49
|
+
admin: z.string().min(1),
|
|
50
|
+
gateway: z.string().min(1),
|
|
51
|
+
// #1328 — absent in a checkpoint an older release wrote.
|
|
52
|
+
"mcp-auth-proxy": z.string().min(1).optional(),
|
|
53
|
+
})
|
|
54
|
+
.optional(),
|
|
48
55
|
});
|
|
49
56
|
/**
|
|
50
57
|
* The ops-bucket prefix of the converge job's own files — outside the
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* converge-db-trust.ts (#1373) against a fake gcloud and a fake Cloud SQL
|
|
5
|
+
* Admin API: which host the converge job dials before the database TLS
|
|
6
|
+
* step, and whose certificate it accepts there.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
convergeDatabaseTarget,
|
|
12
|
+
normalizedSha1,
|
|
13
|
+
serverIdentityProblem,
|
|
14
|
+
} from "./converge-db-trust.js";
|
|
15
|
+
import type { GcloudResult } from "./gcloud.js";
|
|
16
|
+
import type { HttpFetch } from "./runtime-secrets.js";
|
|
17
|
+
|
|
18
|
+
const DNS = "abc123.p-label.europe-west1.sql-psa.goog";
|
|
19
|
+
const IP = "10.20.0.3";
|
|
20
|
+
const PLACEMENT = { projectId: "p", region: "europe-west1" };
|
|
21
|
+
const OWN_CA = "-----BEGIN CERTIFICATE-----\nper-instance\n-----END CERTIFICATE-----";
|
|
22
|
+
const SHARED_BUNDLE = "-----BEGIN CERTIFICATE-----\nshared\n-----END CERTIFICATE-----";
|
|
23
|
+
const ACTIVE = "a212b6cf51b3736b34a5d4a3194b200db6b3532a";
|
|
24
|
+
const OLD = "0123456789abcdef0123456789abcdef01234567";
|
|
25
|
+
|
|
26
|
+
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
27
|
+
const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
|
|
28
|
+
|
|
29
|
+
/** The admin service as `gcloud run services describe --format=json` prints it. */
|
|
30
|
+
function adminService(host: string): string {
|
|
31
|
+
return JSON.stringify({
|
|
32
|
+
spec: {
|
|
33
|
+
template: {
|
|
34
|
+
metadata: {
|
|
35
|
+
annotations: {
|
|
36
|
+
"run.googleapis.com/network-interfaces": JSON.stringify([
|
|
37
|
+
{ network: "net", subnetwork: "sub" },
|
|
38
|
+
]),
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
spec: {
|
|
42
|
+
containers: [
|
|
43
|
+
{
|
|
44
|
+
image: "img@sha256:x",
|
|
45
|
+
env: [
|
|
46
|
+
{
|
|
47
|
+
name: "ADMIN_DATABASE_URL",
|
|
48
|
+
value: `postgresql://admin_role@${host}:5432/cms_admin?sslmode=require`,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "ADMIN_DATABASE_PASSWORD",
|
|
52
|
+
valueFrom: { secretKeyRef: { name: "pw", key: "latest" } },
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function gcloud(opts: { host: string; instance?: object; describeFails?: boolean }) {
|
|
64
|
+
const calls: string[] = [];
|
|
65
|
+
const run = async (args: string[]) => {
|
|
66
|
+
const line = args.join(" ");
|
|
67
|
+
calls.push(line);
|
|
68
|
+
if (line.startsWith("run services list")) return ok("caelo-production-admin-abc1234\n");
|
|
69
|
+
if (line.startsWith("run services describe")) return ok(adminService(opts.host));
|
|
70
|
+
if (line.startsWith("sql instances list")) return ok("caelo-production-pg-1\n");
|
|
71
|
+
if (line.startsWith("sql instances describe")) {
|
|
72
|
+
return opts.describeFails ? fail("PERMISSION_DENIED") : ok(JSON.stringify(opts.instance));
|
|
73
|
+
}
|
|
74
|
+
if (line.startsWith("auth print-access-token")) return ok("ya29.token\n");
|
|
75
|
+
return fail(`unexpected: ${line}`);
|
|
76
|
+
};
|
|
77
|
+
return { run, calls };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function api(body: object, status = 200): HttpFetch {
|
|
81
|
+
return async () => ({ ok: status < 300, status, text: async () => JSON.stringify(body) });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const perInstance = {
|
|
85
|
+
serverCaCert: { cert: OWN_CA },
|
|
86
|
+
settings: { ipConfiguration: { serverCaMode: "GOOGLE_MANAGED_INTERNAL_CA" } },
|
|
87
|
+
};
|
|
88
|
+
const shared = {
|
|
89
|
+
serverCaCert: { cert: OWN_CA },
|
|
90
|
+
settings: { ipConfiguration: { serverCaMode: "GOOGLE_MANAGED_CAS_CA" } },
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
describe("convergeDatabaseTarget", () => {
|
|
94
|
+
it("an install on the DNS name connects with verify-full and asks Cloud SQL nothing", async () => {
|
|
95
|
+
const g = gcloud({ host: DNS });
|
|
96
|
+
expect(await convergeDatabaseTarget(PLACEMENT, { run: g.run })).toEqual({
|
|
97
|
+
kind: "dns",
|
|
98
|
+
host: DNS,
|
|
99
|
+
});
|
|
100
|
+
expect(g.calls.some((c) => c.startsWith("sql "))).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("on the IP with the per-instance CA it trusts exactly that CA", async () => {
|
|
104
|
+
const g = gcloud({ host: IP, instance: perInstance });
|
|
105
|
+
const target = await convergeDatabaseTarget(PLACEMENT, {
|
|
106
|
+
run: g.run,
|
|
107
|
+
http: async () => {
|
|
108
|
+
throw new Error("the per-instance CA needs no certificate list");
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
expect(target).toEqual({ kind: "ip", host: IP, caPem: OWN_CA, pinnedSha1: null });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("on the IP with the shared CA active it trusts the shared bundle and pins the instance's certificates", async () => {
|
|
115
|
+
const g = gcloud({ host: IP, instance: shared });
|
|
116
|
+
const target = await convergeDatabaseTarget(PLACEMENT, {
|
|
117
|
+
run: g.run,
|
|
118
|
+
http: api({ activeVersion: ACTIVE, serverCerts: [{ sha1Fingerprint: ACTIVE }] }),
|
|
119
|
+
readSharedCaBundle: () => SHARED_BUNDLE,
|
|
120
|
+
});
|
|
121
|
+
expect(target).toEqual({ kind: "ip", host: IP, caPem: SHARED_BUNDLE, pinnedSha1: [ACTIVE] });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("moved to the shared CA but not rotated in: pinned to the one certificate the API names active", async () => {
|
|
125
|
+
const g = gcloud({ host: IP, instance: shared });
|
|
126
|
+
const target = await convergeDatabaseTarget(PLACEMENT, {
|
|
127
|
+
run: g.run,
|
|
128
|
+
http: api({ activeVersion: OLD.toUpperCase(), serverCerts: [] }),
|
|
129
|
+
readSharedCaBundle: () => SHARED_BUNDLE,
|
|
130
|
+
});
|
|
131
|
+
expect(target).toEqual({
|
|
132
|
+
kind: "ip",
|
|
133
|
+
host: IP,
|
|
134
|
+
caPem: `${OWN_CA}\n${SHARED_BUNDLE}`,
|
|
135
|
+
pinnedSha1: [OLD],
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("shared CA: a listing that does not positively name the active certificate is refused (fail closed)", async () => {
|
|
140
|
+
const answers: { body: unknown; why: RegExp }[] = [
|
|
141
|
+
{ body: {}, why: /does not name the active certificate/ },
|
|
142
|
+
{ body: { serverCerts: [{ sha1Fingerprint: ACTIVE }] }, why: /does not name/ },
|
|
143
|
+
{ body: { activeVersion: "", serverCerts: [] }, why: /does not name/ },
|
|
144
|
+
{ body: { activeVersion: "old-per-instance", serverCerts: [] }, why: /does not name/ },
|
|
145
|
+
{
|
|
146
|
+
body: { activeVersion: ACTIVE, serverCerts: [{ sha1Fingerprint: "nope" }] },
|
|
147
|
+
why: /does not name/,
|
|
148
|
+
},
|
|
149
|
+
{ body: { activeVersion: ACTIVE, serverCerts: [{}] }, why: /does not name/ },
|
|
150
|
+
{ body: { activeVersion: ACTIVE, serverCerts: "x" }, why: /does not name/ },
|
|
151
|
+
];
|
|
152
|
+
for (const a of answers) {
|
|
153
|
+
await expect(
|
|
154
|
+
convergeDatabaseTarget(PLACEMENT, {
|
|
155
|
+
run: gcloud({ host: IP, instance: shared }).run,
|
|
156
|
+
http: api(a.body as object),
|
|
157
|
+
readSharedCaBundle: () => SHARED_BUNDLE,
|
|
158
|
+
}),
|
|
159
|
+
).rejects.toThrow(a.why);
|
|
160
|
+
}
|
|
161
|
+
const notJson: HttpFetch = async () => ({ ok: true, status: 200, text: async () => "<html>" });
|
|
162
|
+
await expect(
|
|
163
|
+
convergeDatabaseTarget(PLACEMENT, {
|
|
164
|
+
run: gcloud({ host: IP, instance: shared }).run,
|
|
165
|
+
http: notJson,
|
|
166
|
+
readSharedCaBundle: () => SHARED_BUNDLE,
|
|
167
|
+
}),
|
|
168
|
+
).rejects.toThrow(/answered no JSON/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("refuses a CA mode it cannot reason about", async () => {
|
|
172
|
+
await expect(
|
|
173
|
+
convergeDatabaseTarget(PLACEMENT, {
|
|
174
|
+
run: gcloud({
|
|
175
|
+
host: IP,
|
|
176
|
+
instance: {
|
|
177
|
+
serverCaCert: { cert: OWN_CA },
|
|
178
|
+
settings: { ipConfiguration: { serverCaMode: "CUSTOMER_MANAGED_CAS_CA" } },
|
|
179
|
+
},
|
|
180
|
+
}).run,
|
|
181
|
+
}),
|
|
182
|
+
).rejects.toThrow(/CUSTOMER_MANAGED_CAS_CA/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("an instance that names no CA mode is on the per-instance default", async () => {
|
|
186
|
+
const g = gcloud({ host: IP, instance: { serverCaCert: { cert: OWN_CA } } });
|
|
187
|
+
expect(await convergeDatabaseTarget(PLACEMENT, { run: g.run })).toEqual({
|
|
188
|
+
kind: "ip",
|
|
189
|
+
host: IP,
|
|
190
|
+
caPem: OWN_CA,
|
|
191
|
+
pinnedSha1: null,
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("refuses to connect when it cannot tell whom to trust", async () => {
|
|
196
|
+
await expect(
|
|
197
|
+
convergeDatabaseTarget(PLACEMENT, { run: gcloud({ host: IP, describeFails: true }).run }),
|
|
198
|
+
).rejects.toThrow("PERMISSION_DENIED");
|
|
199
|
+
await expect(
|
|
200
|
+
convergeDatabaseTarget(PLACEMENT, {
|
|
201
|
+
run: gcloud({
|
|
202
|
+
host: IP,
|
|
203
|
+
instance: {
|
|
204
|
+
settings: { ipConfiguration: { serverCaMode: "GOOGLE_MANAGED_INTERNAL_CA" } },
|
|
205
|
+
},
|
|
206
|
+
}).run,
|
|
207
|
+
}),
|
|
208
|
+
).rejects.toThrow("names no server CA certificate");
|
|
209
|
+
await expect(
|
|
210
|
+
convergeDatabaseTarget(PLACEMENT, {
|
|
211
|
+
run: gcloud({ host: IP, instance: shared }).run,
|
|
212
|
+
http: api({ error: "denied" }, 403),
|
|
213
|
+
}),
|
|
214
|
+
).rejects.toThrow("HTTP 403");
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("serverIdentityProblem", () => {
|
|
219
|
+
const pinned = { kind: "ip", host: IP, caPem: SHARED_BUNDLE, pinnedSha1: [ACTIVE] } as const;
|
|
220
|
+
|
|
221
|
+
it("accepts a pinned certificate in node:tls's colon notation", () => {
|
|
222
|
+
const colons = ACTIVE.toUpperCase().replace(/(..)(?!$)/g, "$1:");
|
|
223
|
+
expect(serverIdentityProblem(pinned, colons)).toBeNull();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("refuses a certificate the instance does not list", () => {
|
|
227
|
+
expect(serverIdentityProblem(pinned, "00:11")).toContain("does not list");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("without a pin the verified chain to the instance's own CA decides", () => {
|
|
231
|
+
expect(serverIdentityProblem({ ...pinned, pinnedSha1: null }, "00:11")).toBeNull();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("normalizes fingerprints", () => {
|
|
235
|
+
expect(normalizedSha1("AB:cd:01")).toBe("abcd01");
|
|
236
|
+
});
|
|
237
|
+
});
|