@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,305 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #1328 — the services an upgrade to the MCP-Auth-Proxy release creates on
|
|
5
|
+
* installs whose stack predates them (mcp-auth-proxy-setup.ts): the proxy's
|
|
6
|
+
* account and its two secret grants, the gcp-firebase data gateway copied
|
|
7
|
+
* from the old gateway service, and the gcp proxy service.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from "bun:test";
|
|
11
|
+
import type { GcloudResult } from "./gcloud.js";
|
|
12
|
+
import type { GcloudRunner } from "./gcloud-retry.js";
|
|
13
|
+
import {
|
|
14
|
+
cloneServiceSpec,
|
|
15
|
+
ensureReleaseServices,
|
|
16
|
+
gcpProxyDeployArgs,
|
|
17
|
+
type ReleaseServicesTarget,
|
|
18
|
+
} from "./mcp-auth-proxy-setup.js";
|
|
19
|
+
import { MCP_AUTH_PROXY_TIMEOUT_SECONDS } from "./stack-contract.js";
|
|
20
|
+
|
|
21
|
+
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
22
|
+
const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
|
|
23
|
+
|
|
24
|
+
/** Answers by the longest matching command prefix; anything else is an error. */
|
|
25
|
+
function fakeGcloud(answers: Record<string, GcloudResult>) {
|
|
26
|
+
const calls: string[] = [];
|
|
27
|
+
const run: GcloudRunner = async (args) => {
|
|
28
|
+
const line = args.join(" ");
|
|
29
|
+
calls.push(line);
|
|
30
|
+
const key = Object.keys(answers)
|
|
31
|
+
.filter((k) => line.startsWith(k))
|
|
32
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
33
|
+
return key ? (answers[key] as GcloudResult) : fail(`unexpected: ${line}`);
|
|
34
|
+
};
|
|
35
|
+
return { run, calls };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const IMAGE = "europe-west1-docker.pkg.dev/caelo-website/caelo-cms-images";
|
|
39
|
+
const PROXY_SA = "caelo-production-mcp-proxy-sa@acme.iam.gserviceaccount.com";
|
|
40
|
+
|
|
41
|
+
const firebase: ReleaseServicesTarget = {
|
|
42
|
+
provider: "gcp-firebase",
|
|
43
|
+
projectId: "acme",
|
|
44
|
+
region: "europe-west1",
|
|
45
|
+
env: "production",
|
|
46
|
+
domain: "acme.com",
|
|
47
|
+
mcpAuthProxyImageRef: `${IMAGE}/mcp-auth-proxy@sha256:${"c".repeat(64)}`,
|
|
48
|
+
};
|
|
49
|
+
const gcp: ReleaseServicesTarget = { ...firebase, provider: "gcp" };
|
|
50
|
+
|
|
51
|
+
/** The old gateway service as `gcloud run services describe --format=json` shows it. */
|
|
52
|
+
const OLD_GATEWAY = {
|
|
53
|
+
apiVersion: "serving.knative.dev/v1",
|
|
54
|
+
kind: "Service",
|
|
55
|
+
metadata: {
|
|
56
|
+
name: "caelo-production-gateway-bbb",
|
|
57
|
+
namespace: "123456",
|
|
58
|
+
uid: "u-1",
|
|
59
|
+
resourceVersion: "AAY",
|
|
60
|
+
generation: 42,
|
|
61
|
+
creationTimestamp: "2026-01-01T00:00:00Z",
|
|
62
|
+
selfLink:
|
|
63
|
+
"/apis/serving.knative.dev/v1/namespaces/123456/services/caelo-production-gateway-bbb",
|
|
64
|
+
labels: {
|
|
65
|
+
"cloud.googleapis.com/location": "europe-west1",
|
|
66
|
+
"goog-terraform-provisioned": "true",
|
|
67
|
+
team: "web",
|
|
68
|
+
},
|
|
69
|
+
annotations: {
|
|
70
|
+
"run.googleapis.com/ingress": "all",
|
|
71
|
+
"run.googleapis.com/ingress-status": "all",
|
|
72
|
+
"serving.knative.dev/creator": "someone@acme.com",
|
|
73
|
+
"serving.knative.dev/lastModifier": "someone@acme.com",
|
|
74
|
+
"run.googleapis.com/operation-id": "op-1",
|
|
75
|
+
"run.googleapis.com/client-name": "gcloud",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
spec: {
|
|
79
|
+
template: {
|
|
80
|
+
metadata: {
|
|
81
|
+
name: "caelo-production-gateway-bbb-00042-abc",
|
|
82
|
+
labels: {
|
|
83
|
+
"client.knative.dev/nonce": "n",
|
|
84
|
+
"run.googleapis.com/startupProbeType": "Default",
|
|
85
|
+
},
|
|
86
|
+
annotations: {
|
|
87
|
+
"autoscaling.knative.dev/maxScale": "10",
|
|
88
|
+
"run.googleapis.com/network-interfaces": '[{"network":"caelo-production-vpc"}]',
|
|
89
|
+
"run.googleapis.com/vpc-access-egress": "private-ranges-only",
|
|
90
|
+
"run.googleapis.com/client-name": "gcloud",
|
|
91
|
+
"run.googleapis.com/client-version": "500.0.0",
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
spec: {
|
|
95
|
+
serviceAccountName: "caelo-production-gateway-sa@acme.iam.gserviceaccount.com",
|
|
96
|
+
timeoutSeconds: 3600,
|
|
97
|
+
containers: [
|
|
98
|
+
{
|
|
99
|
+
image: `${IMAGE}/gateway@sha256:${"b".repeat(64)}`,
|
|
100
|
+
env: [
|
|
101
|
+
{ name: "CAELO_PROVIDER", value: "gcp-firebase" },
|
|
102
|
+
{
|
|
103
|
+
name: "PUBLIC_DATABASE_PASSWORD",
|
|
104
|
+
valueFrom: {
|
|
105
|
+
secretKeyRef: { name: "caelo-production-public-role-password", key: "latest" },
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
traffic: [{ revisionName: "caelo-production-gateway-bbb-00041-old", percent: 100 }],
|
|
114
|
+
},
|
|
115
|
+
status: {
|
|
116
|
+
url: "https://caelo-production-gateway-bbb-xyz.a.run.app",
|
|
117
|
+
latestReadyRevisionName: "x",
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
describe("cloneServiceSpec", () => {
|
|
122
|
+
const clone = JSON.parse(
|
|
123
|
+
cloneServiceSpec(JSON.stringify(OLD_GATEWAY), "caelo-production-data-gateway"),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
it("names the copy and drops the source's identity, status and tool stamps", () => {
|
|
127
|
+
expect(clone.metadata).toEqual({
|
|
128
|
+
name: "caelo-production-data-gateway",
|
|
129
|
+
labels: { team: "web" },
|
|
130
|
+
annotations: { "run.googleapis.com/ingress": "all" },
|
|
131
|
+
});
|
|
132
|
+
expect(clone.status).toBeUndefined();
|
|
133
|
+
expect(clone.spec.template.metadata.name).toBeUndefined();
|
|
134
|
+
expect(clone.spec.template.metadata.labels).toEqual({});
|
|
135
|
+
expect(clone.spec.template.metadata.annotations).toEqual({
|
|
136
|
+
"autoscaling.knative.dev/maxScale": "10",
|
|
137
|
+
"run.googleapis.com/network-interfaces": '[{"network":"caelo-production-vpc"}]',
|
|
138
|
+
"run.googleapis.com/vpc-access-egress": "private-ranges-only",
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("keeps the configuration — image, env, secrets, account, timeout — and routes all traffic to the new revision", () => {
|
|
143
|
+
expect(clone.spec.template.spec).toEqual(OLD_GATEWAY.spec.template.spec);
|
|
144
|
+
expect(clone.spec.traffic).toEqual([{ percent: 100, latestRevision: true }]);
|
|
145
|
+
expect(clone.apiVersion).toBe("serving.knative.dev/v1");
|
|
146
|
+
expect(clone.kind).toBe("Service");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("refuses to copy a service that runs no gateway image (the proxy the old service became)", () => {
|
|
150
|
+
const proxy = structuredClone(OLD_GATEWAY);
|
|
151
|
+
(proxy.spec.template.spec.containers[0] as { image: string }).image =
|
|
152
|
+
`${IMAGE}/mcp-auth-proxy@sha256:${"c".repeat(64)}`;
|
|
153
|
+
expect(() => cloneServiceSpec(JSON.stringify(proxy), "x")).toThrow("not a Caelo gateway image");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("gcpProxyDeployArgs", () => {
|
|
158
|
+
it("deploys the proxy load-balancer-only, public, at zero instances, without a VPC, with its two secrets", () => {
|
|
159
|
+
const args = gcpProxyDeployArgs(gcp);
|
|
160
|
+
expect(args.slice(0, 3)).toEqual(["run", "deploy", "caelo-production-mcp-auth-proxy"]);
|
|
161
|
+
expect(args).toContain(`--image=${gcp.mcpAuthProxyImageRef}`);
|
|
162
|
+
expect(args).toContain(`--service-account=${PROXY_SA}`);
|
|
163
|
+
expect(args).toContain("--ingress=internal-and-cloud-load-balancing");
|
|
164
|
+
expect(args).toContain("--allow-unauthenticated");
|
|
165
|
+
expect(args).toContain("--min-instances=0");
|
|
166
|
+
expect(args).toContain("--cpu-throttling");
|
|
167
|
+
expect(args).toContain(`--timeout=${MCP_AUTH_PROXY_TIMEOUT_SECONDS}`);
|
|
168
|
+
expect(args.some((a) => a.startsWith("--network") || a.startsWith("--vpc"))).toBe(false);
|
|
169
|
+
expect(args).toContain(
|
|
170
|
+
"--set-env-vars=CAELO_PROVIDER=gcp,CAELO_REMOTE_MCP=available,CAELO_TRUSTED_PROXY_HOPS=2,CAELO_OPERATOR_OAUTH_CLIENT_FILE=/secrets/operator-oauth-client/client.json,CAELO_ADMIN_URL=https://admin.acme.com,CAELO_MCP_PUBLIC_URL=https://acme.com",
|
|
171
|
+
);
|
|
172
|
+
expect(args).toContain(
|
|
173
|
+
"--set-secrets=CAELO_MCP_OAUTH_SEAL_KEY=caelo-production-mcp-oauth-seal-key:latest,/secrets/operator-oauth-client/client.json=caelo-production-operator-oauth-client:latest",
|
|
174
|
+
);
|
|
175
|
+
// Nothing that would hand it a database: no URL, no password.
|
|
176
|
+
expect(args.join(" ")).not.toMatch(/DATABASE|public-role-password|postgres-password/);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
/** The account and its two grants, as a fresh install answers them. */
|
|
181
|
+
const accountAndGrants = {
|
|
182
|
+
[`iam service-accounts describe ${PROXY_SA}`]: fail("NOT_FOUND: does not exist"),
|
|
183
|
+
"iam service-accounts create caelo-production-mcp-proxy-sa": ok(),
|
|
184
|
+
"secrets add-iam-policy-binding": ok(),
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
describe("ensureReleaseServices", () => {
|
|
188
|
+
it("gcp-firebase: creates the account, grants exactly its two secrets and copies the old gateway", async () => {
|
|
189
|
+
const written: string[] = [];
|
|
190
|
+
let cleaned = false;
|
|
191
|
+
const { run, calls } = fakeGcloud({
|
|
192
|
+
...accountAndGrants,
|
|
193
|
+
"run services describe caelo-production-data-gateway": fail("NOT_FOUND: could not be found"),
|
|
194
|
+
"run services list": ok("caelo-production-gateway-bbb\n"),
|
|
195
|
+
"run services describe caelo-production-gateway-bbb": ok(JSON.stringify(OLD_GATEWAY)),
|
|
196
|
+
"run services replace": ok(),
|
|
197
|
+
});
|
|
198
|
+
const outcomes = await ensureReleaseServices(firebase, {
|
|
199
|
+
run,
|
|
200
|
+
writeSpecFile: (json) => {
|
|
201
|
+
written.push(json);
|
|
202
|
+
return {
|
|
203
|
+
path: "/tmp/spec.json",
|
|
204
|
+
cleanup: () => {
|
|
205
|
+
cleaned = true;
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
expect(outcomes.map((o) => o.status)).toEqual(["applied", "present", "present", "applied"]);
|
|
211
|
+
expect(calls.filter((c) => c.startsWith("secrets add-iam-policy-binding"))).toEqual([
|
|
212
|
+
`secrets add-iam-policy-binding caelo-production-mcp-oauth-seal-key --project=acme --member=serviceAccount:${PROXY_SA} --role=roles/secretmanager.secretAccessor --condition=None --quiet`,
|
|
213
|
+
`secrets add-iam-policy-binding caelo-production-operator-oauth-client --project=acme --member=serviceAccount:${PROXY_SA} --role=roles/secretmanager.secretAccessor --condition=None --quiet`,
|
|
214
|
+
]);
|
|
215
|
+
// Never a project role.
|
|
216
|
+
expect(calls.some((c) => c.startsWith("projects add-iam-policy-binding"))).toBe(false);
|
|
217
|
+
// The copy's source is the service the proxy prefix finds — the old gateway.
|
|
218
|
+
expect(calls).toContain(
|
|
219
|
+
"run services list --region europe-west1 --project acme --filter metadata.name~^caelo-production-gateway --format=value(metadata.name)",
|
|
220
|
+
);
|
|
221
|
+
expect(JSON.parse(written[0] as string).metadata.name).toBe("caelo-production-data-gateway");
|
|
222
|
+
expect(calls).toContain(
|
|
223
|
+
"run services replace /tmp/spec.json --region=europe-west1 --project=acme --quiet",
|
|
224
|
+
);
|
|
225
|
+
expect(cleaned).toBe(true);
|
|
226
|
+
// No new image is deployed: the copy runs what serves today.
|
|
227
|
+
expect(calls.some((c) => c.startsWith("run deploy"))).toBe(false);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("gcp-firebase: a data gateway that exists already is left alone", async () => {
|
|
231
|
+
const { run, calls } = fakeGcloud({
|
|
232
|
+
[`iam service-accounts describe ${PROXY_SA}`]: ok(`${PROXY_SA}\n`),
|
|
233
|
+
"secrets add-iam-policy-binding": ok(),
|
|
234
|
+
"run services describe caelo-production-data-gateway": ok("caelo-production-data-gateway\n"),
|
|
235
|
+
});
|
|
236
|
+
const outcomes = await ensureReleaseServices(firebase, { run });
|
|
237
|
+
expect(outcomes.map((o) => o.status)).toEqual(["present", "present", "present", "present"]);
|
|
238
|
+
expect(calls.some((c) => c.includes("replace") || c.includes("create"))).toBe(false);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("gcp-firebase: refuses to copy when the old service already runs the proxy image", async () => {
|
|
242
|
+
const proxy = structuredClone(OLD_GATEWAY);
|
|
243
|
+
(proxy.spec.template.spec.containers[0] as { image: string }).image =
|
|
244
|
+
`${IMAGE}/mcp-auth-proxy@sha256:${"c".repeat(64)}`;
|
|
245
|
+
const { run, calls } = fakeGcloud({
|
|
246
|
+
...accountAndGrants,
|
|
247
|
+
"run services describe caelo-production-data-gateway": fail("NOT_FOUND: could not be found"),
|
|
248
|
+
"run services list": ok("caelo-production-gateway-bbb\n"),
|
|
249
|
+
"run services describe caelo-production-gateway-bbb": ok(JSON.stringify(proxy)),
|
|
250
|
+
});
|
|
251
|
+
const outcomes = await ensureReleaseServices(firebase, { run });
|
|
252
|
+
const last = outcomes.at(-1);
|
|
253
|
+
expect(last?.status).toBe("failed");
|
|
254
|
+
expect(last?.error).toContain("Re-run the installer");
|
|
255
|
+
expect(calls.some((c) => c.startsWith("run services replace"))).toBe(false);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("gcp: deploys the proxy service with the release's image when it is missing", async () => {
|
|
259
|
+
const { run, calls } = fakeGcloud({
|
|
260
|
+
...accountAndGrants,
|
|
261
|
+
"run services describe caelo-production-mcp-auth-proxy": fail(
|
|
262
|
+
"NOT_FOUND: could not be found",
|
|
263
|
+
),
|
|
264
|
+
"run deploy caelo-production-mcp-auth-proxy": ok(),
|
|
265
|
+
});
|
|
266
|
+
const outcomes = await ensureReleaseServices(gcp, { run });
|
|
267
|
+
expect(outcomes.map((o) => o.status)).toEqual(["applied", "present", "present", "applied"]);
|
|
268
|
+
expect(calls).toContain(gcpProxyDeployArgs(gcp).join(" "));
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("gcp: an existing proxy service is not deployed again", async () => {
|
|
272
|
+
const { run, calls } = fakeGcloud({
|
|
273
|
+
[`iam service-accounts describe ${PROXY_SA}`]: ok(`${PROXY_SA}\n`),
|
|
274
|
+
"secrets add-iam-policy-binding": ok(),
|
|
275
|
+
"run services describe caelo-production-mcp-auth-proxy": ok(
|
|
276
|
+
"caelo-production-mcp-auth-proxy\n",
|
|
277
|
+
),
|
|
278
|
+
});
|
|
279
|
+
const outcomes = await ensureReleaseServices(gcp, { run });
|
|
280
|
+
expect(outcomes.at(-1)?.status).toBe("present");
|
|
281
|
+
expect(calls.some((c) => c.startsWith("run deploy"))).toBe(false);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("stops before any service when a grant fails (Cloud Run would refuse the revision)", async () => {
|
|
285
|
+
const { run, calls } = fakeGcloud({
|
|
286
|
+
...accountAndGrants,
|
|
287
|
+
"secrets add-iam-policy-binding caelo-production-operator-oauth-client": fail(
|
|
288
|
+
"PERMISSION_DENIED: secretmanager.secrets.setIamPolicy",
|
|
289
|
+
),
|
|
290
|
+
});
|
|
291
|
+
const outcomes = await ensureReleaseServices(gcp, { run });
|
|
292
|
+
expect(outcomes.map((o) => o.status)).toEqual(["applied", "present", "failed"]);
|
|
293
|
+
expect(calls.some((c) => c.startsWith("run "))).toBe(false);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("stops at once when the account cannot be read", async () => {
|
|
297
|
+
const { run, calls } = fakeGcloud({
|
|
298
|
+
[`iam service-accounts describe ${PROXY_SA}`]: fail("PERMISSION_DENIED"),
|
|
299
|
+
});
|
|
300
|
+
const outcomes = await ensureReleaseServices(firebase, { run });
|
|
301
|
+
expect(outcomes).toHaveLength(1);
|
|
302
|
+
expect(outcomes[0]?.status).toBe("failed");
|
|
303
|
+
expect(calls).toHaveLength(1);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #1328 — the Cloud Run services an upgrade to the MCP-Auth-Proxy release
|
|
5
|
+
* must find before it plans its rolls, created through gcloud on installs
|
|
6
|
+
* whose stack predates them (`cms-provision upgrade` and the converge job
|
|
7
|
+
* never run Pulumi). Run as the operator's or the provisioner's identity,
|
|
8
|
+
* before the upgrade's plan: each step only ADDS a service, account or grant
|
|
9
|
+
* that serves no traffic yet, so a failure leaves the running install as it
|
|
10
|
+
* was (converge release 1 of the hardening plan, #763, is additive).
|
|
11
|
+
*
|
|
12
|
+
* - Both providers: the proxy's own service account and its
|
|
13
|
+
* `secretAccessor` on exactly its two secrets — before any service runs
|
|
14
|
+
* as it, because Cloud Run checks a revision's secret access at deploy.
|
|
15
|
+
* - `gcp-firebase` (E13 a): the visitor gateway's new service
|
|
16
|
+
* `caelo-<env>-data-gateway`, a copy of the service that was the gateway
|
|
17
|
+
* (same image, env, secrets, VPC, account), so the app-roll can then roll
|
|
18
|
+
* it like any other service and turn the old one into the proxy. Copied,
|
|
19
|
+
* not built from the contract, so the health baseline sees the release
|
|
20
|
+
* that serves today on all three services.
|
|
21
|
+
* - `gcp`: the proxy's own service `caelo-<env>-mcp-auth-proxy`, deployed
|
|
22
|
+
* with the release's proxy image (there is no older proxy to copy). Its
|
|
23
|
+
* load-balancer part is remote-mcp.ts `ensureRemoteMcpEdge`, later in
|
|
24
|
+
* the converge.
|
|
25
|
+
*
|
|
26
|
+
* Fresh installs get the same from their stack (gcp-firebase) or from the
|
|
27
|
+
* wizard after the first `pulumi up` (gcp, like the jobs: CLI-owned).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { tmpdir } from "node:os";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import { gcloud as defaultGcloud } from "./gcloud.js";
|
|
34
|
+
import type { GcloudRunner } from "./gcloud-retry.js";
|
|
35
|
+
import { gcpSecretId, mcpAuthProxyServiceAccountEmail } from "./gcp-names.js";
|
|
36
|
+
import { type EnsureOutcome, ensureMcpAuthProxyServiceAccount } from "./runtime-secrets.js";
|
|
37
|
+
import {
|
|
38
|
+
type CloudRunEnvVar,
|
|
39
|
+
cloudRunServicePrefix,
|
|
40
|
+
DEFAULT_MCP_AUTH_PROXY_MAX_INSTANCES,
|
|
41
|
+
fixedCloudRunServiceName,
|
|
42
|
+
type GcpProvider,
|
|
43
|
+
MCP_AUTH_PROXY_TIMEOUT_SECONDS,
|
|
44
|
+
mcpAuthProxyEnvContract,
|
|
45
|
+
OPERATOR_OAUTH_CLIENT_FILE,
|
|
46
|
+
remoteMcpIdentity,
|
|
47
|
+
remoteMcpIdentityEnv,
|
|
48
|
+
secretFilePath,
|
|
49
|
+
serviceSecrets,
|
|
50
|
+
} from "./stack-contract.js";
|
|
51
|
+
import { listFlag } from "./stack-converge.js";
|
|
52
|
+
import { lookupFailure, resolveGcpResourceName } from "./upgrade/gcp-lookup.js";
|
|
53
|
+
|
|
54
|
+
/** The install the services are ensured on. */
|
|
55
|
+
export interface ReleaseServicesTarget {
|
|
56
|
+
readonly provider: GcpProvider;
|
|
57
|
+
readonly projectId: string;
|
|
58
|
+
readonly region: string;
|
|
59
|
+
/** Pulumi stack name (`production`). */
|
|
60
|
+
readonly env: string;
|
|
61
|
+
readonly domain: string;
|
|
62
|
+
/** The release's digest-pinned MCP-Auth-Proxy image (`…/mcp-auth-proxy@sha256:…`). */
|
|
63
|
+
readonly mcpAuthProxyImageRef: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ReleaseServicesDeps {
|
|
67
|
+
readonly run?: GcloudRunner;
|
|
68
|
+
/** Writes a service spec for `gcloud run services replace`; returns its path and a cleanup. */
|
|
69
|
+
readonly writeSpecFile?: (json: string) => {
|
|
70
|
+
readonly path: string;
|
|
71
|
+
readonly cleanup: () => void;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const NOT_FOUND = /NOT_FOUND|not found|does not exist|could not be found/i;
|
|
76
|
+
|
|
77
|
+
/** Ensure everything {@link ReleaseServicesTarget} needs; outcomes in order, failures included. */
|
|
78
|
+
export async function ensureReleaseServices(
|
|
79
|
+
target: ReleaseServicesTarget,
|
|
80
|
+
deps: ReleaseServicesDeps = {},
|
|
81
|
+
): Promise<EnsureOutcome[]> {
|
|
82
|
+
const run = deps.run ?? defaultGcloud;
|
|
83
|
+
const outcomes: EnsureOutcome[] = [];
|
|
84
|
+
const account = await ensureMcpAuthProxyServiceAccount(target, { run });
|
|
85
|
+
outcomes.push(account);
|
|
86
|
+
if (account.status === "failed") return outcomes;
|
|
87
|
+
outcomes.push(...(await ensureMcpAuthProxySecretAccess(target, run)));
|
|
88
|
+
if (outcomes.some((o) => o.status === "failed")) return outcomes;
|
|
89
|
+
outcomes.push(
|
|
90
|
+
target.provider === "gcp-firebase"
|
|
91
|
+
? await ensureDataGatewayService(target, run, deps.writeSpecFile ?? writeTempSpec)
|
|
92
|
+
: await ensureMcpAuthProxyServiceGcp(target, run),
|
|
93
|
+
);
|
|
94
|
+
return outcomes;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `secretAccessor` for the proxy's account on each secret it reads
|
|
99
|
+
* (stack-contract.ts `serviceSecrets("mcp-auth-proxy")`) and nothing else.
|
|
100
|
+
* `add-iam-policy-binding` is idempotent; the stack invariants ensure the
|
|
101
|
+
* same bindings again later in the converge.
|
|
102
|
+
*/
|
|
103
|
+
async function ensureMcpAuthProxySecretAccess(
|
|
104
|
+
target: Pick<ReleaseServicesTarget, "projectId" | "env">,
|
|
105
|
+
run: GcloudRunner,
|
|
106
|
+
): Promise<EnsureOutcome[]> {
|
|
107
|
+
const member = `serviceAccount:${mcpAuthProxyServiceAccountEmail(target.projectId, target.env)}`;
|
|
108
|
+
const outcomes: EnsureOutcome[] = [];
|
|
109
|
+
for (const secret of serviceSecrets("mcp-auth-proxy")) {
|
|
110
|
+
const id = `secretAccessor → MCP-Auth-Proxy on ${secret}`;
|
|
111
|
+
const r = await run([
|
|
112
|
+
"secrets",
|
|
113
|
+
"add-iam-policy-binding",
|
|
114
|
+
gcpSecretId(target.env, secret),
|
|
115
|
+
`--project=${target.projectId}`,
|
|
116
|
+
`--member=${member}`,
|
|
117
|
+
"--role=roles/secretmanager.secretAccessor",
|
|
118
|
+
"--condition=None",
|
|
119
|
+
"--quiet",
|
|
120
|
+
]);
|
|
121
|
+
outcomes.push(
|
|
122
|
+
r.ok ? { id, status: "present" } : { id, status: "failed", error: r.stderr.trim() },
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return outcomes;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ===========================================================================
|
|
129
|
+
// gcp-firebase: the data gateway
|
|
130
|
+
// ===========================================================================
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The Knative service spec of `serviceJson` (`gcloud run services describe
|
|
134
|
+
* --format=json`) as a new service named `name`, for `gcloud run services
|
|
135
|
+
* replace`: the configuration only — image, env, secrets, volumes, VPC,
|
|
136
|
+
* scaling, account, ingress — without the source's identity (uid, revision
|
|
137
|
+
* names, status), its tool stamps or its traffic pins.
|
|
138
|
+
*
|
|
139
|
+
* @throws when the source is not a gateway image (a re-run must never copy
|
|
140
|
+
* the MCP-Auth-Proxy the old service became)
|
|
141
|
+
*/
|
|
142
|
+
export function cloneServiceSpec(serviceJson: string, name: string): string {
|
|
143
|
+
const svc = JSON.parse(serviceJson) as {
|
|
144
|
+
apiVersion?: string;
|
|
145
|
+
kind?: string;
|
|
146
|
+
metadata?: { labels?: Record<string, string>; annotations?: Record<string, string> };
|
|
147
|
+
spec?: {
|
|
148
|
+
template?: {
|
|
149
|
+
metadata?: { labels?: Record<string, string>; annotations?: Record<string, string> };
|
|
150
|
+
spec?: { containers?: { image?: string }[] } & Record<string, unknown>;
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
const image = svc.spec?.template?.spec?.containers?.[0]?.image ?? "";
|
|
155
|
+
if (!/\/gateway[@:]/.test(image)) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`the source service runs ${image || "no image"}, not a Caelo gateway image — the visitor gateway cannot be copied from it`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
const keepLabels = (labels: Record<string, string> | undefined) =>
|
|
161
|
+
Object.fromEntries(
|
|
162
|
+
Object.entries(labels ?? {}).filter(
|
|
163
|
+
([k]) =>
|
|
164
|
+
!k.startsWith("cloud.googleapis.com/") &&
|
|
165
|
+
!k.startsWith("run.googleapis.com/") &&
|
|
166
|
+
!k.startsWith("client.knative.dev/") &&
|
|
167
|
+
!k.startsWith("serving.knative.dev/") &&
|
|
168
|
+
!k.startsWith("goog-"),
|
|
169
|
+
),
|
|
170
|
+
);
|
|
171
|
+
// Service-level: only the settings; the rest are server stamps.
|
|
172
|
+
const KEEP_SERVICE_ANNOTATIONS = [
|
|
173
|
+
"run.googleapis.com/ingress",
|
|
174
|
+
"run.googleapis.com/launch-stage",
|
|
175
|
+
];
|
|
176
|
+
const annotations = Object.fromEntries(
|
|
177
|
+
Object.entries(svc.metadata?.annotations ?? {}).filter(([k]) =>
|
|
178
|
+
KEEP_SERVICE_ANNOTATIONS.includes(k),
|
|
179
|
+
),
|
|
180
|
+
);
|
|
181
|
+
const templateAnnotations = Object.fromEntries(
|
|
182
|
+
Object.entries(svc.spec?.template?.metadata?.annotations ?? {}).filter(
|
|
183
|
+
([k]) => k !== "run.googleapis.com/client-name" && k !== "run.googleapis.com/client-version",
|
|
184
|
+
),
|
|
185
|
+
);
|
|
186
|
+
return JSON.stringify({
|
|
187
|
+
apiVersion: svc.apiVersion ?? "serving.knative.dev/v1",
|
|
188
|
+
kind: svc.kind ?? "Service",
|
|
189
|
+
metadata: { name, labels: keepLabels(svc.metadata?.labels), annotations },
|
|
190
|
+
spec: {
|
|
191
|
+
template: {
|
|
192
|
+
metadata: {
|
|
193
|
+
labels: keepLabels(svc.spec?.template?.metadata?.labels),
|
|
194
|
+
annotations: templateAnnotations,
|
|
195
|
+
},
|
|
196
|
+
spec: svc.spec?.template?.spec ?? {},
|
|
197
|
+
},
|
|
198
|
+
traffic: [{ percent: 100, latestRevision: true }],
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function writeTempSpec(json: string): { path: string; cleanup: () => void } {
|
|
204
|
+
const dir = mkdtempSync(join(tmpdir(), "caelo-service-"));
|
|
205
|
+
const path = join(dir, "service.json");
|
|
206
|
+
writeFileSync(path, json, { mode: 0o600 });
|
|
207
|
+
return { path, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The gcp-firebase data gateway (`caelo-<env>-data-gateway`): present, or
|
|
212
|
+
* created as a copy of the `caelo-<env>-gateway*` service. Its public invoker
|
|
213
|
+
* and the admin's `run.viewer` (the Firebase rewrite) follow from the stack
|
|
214
|
+
* invariants later in the converge.
|
|
215
|
+
*/
|
|
216
|
+
async function ensureDataGatewayService(
|
|
217
|
+
target: ReleaseServicesTarget,
|
|
218
|
+
run: GcloudRunner,
|
|
219
|
+
writeSpecFile: NonNullable<ReleaseServicesDeps["writeSpecFile"]>,
|
|
220
|
+
): Promise<EnsureOutcome> {
|
|
221
|
+
const name = fixedCloudRunServiceName(target.provider, target.env, "gateway") as string;
|
|
222
|
+
const id = `Cloud Run service ${name} (the visitor gateway)`;
|
|
223
|
+
const where = [`--region=${target.region}`, `--project=${target.projectId}`];
|
|
224
|
+
const existing = await run([
|
|
225
|
+
"run",
|
|
226
|
+
"services",
|
|
227
|
+
"describe",
|
|
228
|
+
name,
|
|
229
|
+
...where,
|
|
230
|
+
"--format=value(metadata.name)",
|
|
231
|
+
]);
|
|
232
|
+
if (existing.ok) return { id, status: "present" };
|
|
233
|
+
if (!NOT_FOUND.test(existing.stderr)) {
|
|
234
|
+
return { id, status: "failed", error: existing.stderr.trim() };
|
|
235
|
+
}
|
|
236
|
+
// The source: the service that was the gateway and becomes the proxy.
|
|
237
|
+
const sourcePrefix = cloudRunServicePrefix(target.provider, target.env, "mcp-auth-proxy");
|
|
238
|
+
const source = await resolveGcpResourceName(
|
|
239
|
+
run,
|
|
240
|
+
"run-service",
|
|
241
|
+
sourcePrefix,
|
|
242
|
+
target.projectId,
|
|
243
|
+
target.region,
|
|
244
|
+
);
|
|
245
|
+
if (source.status !== "found") {
|
|
246
|
+
return {
|
|
247
|
+
id,
|
|
248
|
+
status: "failed",
|
|
249
|
+
error: lookupFailure(source, `${sourcePrefix}* Cloud Run service`, "operator"),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
const described = await run([
|
|
253
|
+
"run",
|
|
254
|
+
"services",
|
|
255
|
+
"describe",
|
|
256
|
+
source.name,
|
|
257
|
+
...where,
|
|
258
|
+
"--format=json",
|
|
259
|
+
]);
|
|
260
|
+
if (!described.ok) {
|
|
261
|
+
return { id, status: "failed", error: `describe ${source.name}: ${described.stderr.trim()}` };
|
|
262
|
+
}
|
|
263
|
+
let spec: string;
|
|
264
|
+
try {
|
|
265
|
+
spec = cloneServiceSpec(described.stdout, name);
|
|
266
|
+
} catch (e) {
|
|
267
|
+
return {
|
|
268
|
+
id,
|
|
269
|
+
status: "failed",
|
|
270
|
+
error: `${e instanceof Error ? e.message : String(e)}. Re-run the installer (it creates ${name} from the stack), then upgrade again.`,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const file = writeSpecFile(spec);
|
|
274
|
+
try {
|
|
275
|
+
const replaced = await run(["run", "services", "replace", file.path, ...where, "--quiet"]);
|
|
276
|
+
return replaced.ok
|
|
277
|
+
? { id, status: "applied", detail: `copied from ${source.name}` }
|
|
278
|
+
: { id, status: "failed", error: replaced.stderr.trim() };
|
|
279
|
+
} finally {
|
|
280
|
+
file.cleanup();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ===========================================================================
|
|
285
|
+
// gcp: the proxy's service
|
|
286
|
+
// ===========================================================================
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The full env the `gcp` proxy is deployed with: its contract plus the
|
|
290
|
+
* remote-MCP identity it can derive without a lookup (its public origin is
|
|
291
|
+
* the site's host, the admin's is `admin.<domain>`). The IAP audience is
|
|
292
|
+
* the admin's alone.
|
|
293
|
+
*/
|
|
294
|
+
function gcpProxyEnv(target: Pick<ReleaseServicesTarget, "env" | "domain">): CloudRunEnvVar[] {
|
|
295
|
+
const identity = remoteMcpIdentityEnv(
|
|
296
|
+
remoteMcpIdentity({
|
|
297
|
+
provider: "gcp",
|
|
298
|
+
domain: target.domain,
|
|
299
|
+
// Only the admin's audience uses these; the proxy's env does not.
|
|
300
|
+
projectNumber: "0",
|
|
301
|
+
adminBackendServiceId: "0",
|
|
302
|
+
}),
|
|
303
|
+
)["mcp-auth-proxy"];
|
|
304
|
+
return [...mcpAuthProxyEnvContract({ provider: "gcp", env: target.env }), ...identity];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The `gcloud run deploy` argv of the `gcp` proxy: load-balancer-only
|
|
309
|
+
* ingress (its two hops, stack-contract.ts MCP_AUTH_PROXY_TRUSTED_PROXY_HOPS),
|
|
310
|
+
* invokable without a Google identity (MCP clients have none), request-based
|
|
311
|
+
* CPU, no VPC, and its Google sign-in client file as a secret volume.
|
|
312
|
+
*/
|
|
313
|
+
export function gcpProxyDeployArgs(target: ReleaseServicesTarget): string[] {
|
|
314
|
+
const name = fixedCloudRunServiceName("gcp", target.env, "mcp-auth-proxy") as string;
|
|
315
|
+
const env = gcpProxyEnv(target);
|
|
316
|
+
const plain = env.flatMap((e) => ("value" in e ? [`${e.name}=${e.value}`] : []));
|
|
317
|
+
const secrets = [
|
|
318
|
+
...env.flatMap((e) =>
|
|
319
|
+
"valueSource" in e
|
|
320
|
+
? [`${e.name}=${e.valueSource.secretKeyRef.secret}:${e.valueSource.secretKeyRef.version}`]
|
|
321
|
+
: [],
|
|
322
|
+
),
|
|
323
|
+
`${secretFilePath(OPERATOR_OAUTH_CLIENT_FILE)}=${gcpSecretId(target.env, OPERATOR_OAUTH_CLIENT_FILE.secret)}:${OPERATOR_OAUTH_CLIENT_FILE.version}`,
|
|
324
|
+
];
|
|
325
|
+
return [
|
|
326
|
+
"run",
|
|
327
|
+
"deploy",
|
|
328
|
+
name,
|
|
329
|
+
`--image=${target.mcpAuthProxyImageRef}`,
|
|
330
|
+
`--region=${target.region}`,
|
|
331
|
+
`--project=${target.projectId}`,
|
|
332
|
+
`--service-account=${mcpAuthProxyServiceAccountEmail(target.projectId, target.env)}`,
|
|
333
|
+
"--ingress=internal-and-cloud-load-balancing",
|
|
334
|
+
"--allow-unauthenticated",
|
|
335
|
+
"--min-instances=0",
|
|
336
|
+
`--max-instances=${DEFAULT_MCP_AUTH_PROXY_MAX_INSTANCES}`,
|
|
337
|
+
"--cpu=1",
|
|
338
|
+
"--memory=512Mi",
|
|
339
|
+
"--cpu-throttling",
|
|
340
|
+
`--timeout=${MCP_AUTH_PROXY_TIMEOUT_SECONDS}`,
|
|
341
|
+
listFlag("--set-env-vars", plain),
|
|
342
|
+
listFlag("--set-secrets", secrets),
|
|
343
|
+
"--quiet",
|
|
344
|
+
];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The `gcp` proxy service: present, or deployed. Deployed once; the
|
|
349
|
+
* app-roll moves it onto each release's image after that.
|
|
350
|
+
*/
|
|
351
|
+
export async function ensureMcpAuthProxyServiceGcp(
|
|
352
|
+
target: ReleaseServicesTarget,
|
|
353
|
+
run: GcloudRunner,
|
|
354
|
+
): Promise<EnsureOutcome> {
|
|
355
|
+
const name = fixedCloudRunServiceName("gcp", target.env, "mcp-auth-proxy") as string;
|
|
356
|
+
const id = `Cloud Run service ${name} (the MCP-Auth-Proxy)`;
|
|
357
|
+
const existing = await run([
|
|
358
|
+
"run",
|
|
359
|
+
"services",
|
|
360
|
+
"describe",
|
|
361
|
+
name,
|
|
362
|
+
`--region=${target.region}`,
|
|
363
|
+
`--project=${target.projectId}`,
|
|
364
|
+
"--format=value(metadata.name)",
|
|
365
|
+
]);
|
|
366
|
+
if (existing.ok) return { id, status: "present" };
|
|
367
|
+
if (!NOT_FOUND.test(existing.stderr)) {
|
|
368
|
+
return { id, status: "failed", error: existing.stderr.trim() };
|
|
369
|
+
}
|
|
370
|
+
const deployed = await run(gcpProxyDeployArgs(target));
|
|
371
|
+
return deployed.ok
|
|
372
|
+
? { id, status: "applied" }
|
|
373
|
+
: { id, status: "failed", error: deployed.stderr.trim() };
|
|
374
|
+
}
|
|
@@ -48,7 +48,7 @@ describe.each(["gcp", "gcp-firebase"])("%s stack — MCP through IAP", (provider
|
|
|
48
48
|
env: "production",
|
|
49
49
|
domain: "d.com",
|
|
50
50
|
region: "r",
|
|
51
|
-
databaseUrls: databaseUrls("
|
|
51
|
+
databaseUrls: databaseUrls("db1.p-label.europe-west1.sql-psa.goog"),
|
|
52
52
|
});
|
|
53
53
|
expect(env).toContainEqual({
|
|
54
54
|
name: "CAELO_MCP_IAP_SERVICE_ACCOUNT",
|