@caelo-cms/provisioning 0.10.53 → 0.10.57
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/agent-db.js +272 -128
- package/dist/audit-job-setup.d.ts +69 -0
- package/dist/audit-job-setup.d.ts.map +1 -0
- package/dist/audit-job-setup.js +317 -0
- package/dist/audit-job-setup.js.map +1 -0
- package/dist/caddy.d.ts +9 -0
- package/dist/caddy.d.ts.map +1 -1
- package/dist/caddy.js +38 -0
- 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 +36 -9
- package/dist/cli.js.map +1 -1
- package/dist/compose.js +5 -0
- package/dist/compose.js.map +1 -1
- package/dist/converge-checkpoint.d.ts +66 -0
- package/dist/converge-checkpoint.d.ts.map +1 -0
- package/dist/converge-checkpoint.js +61 -0
- package/dist/converge-checkpoint.js.map +1 -0
- package/dist/converge-job-gcp.d.ts +46 -0
- package/dist/converge-job-gcp.d.ts.map +1 -0
- package/dist/converge-job-gcp.js +368 -0
- package/dist/converge-job-gcp.js.map +1 -0
- package/dist/converge-job-setup.d.ts +62 -0
- package/dist/converge-job-setup.d.ts.map +1 -0
- package/dist/converge-job-setup.js +228 -0
- package/dist/converge-job-setup.js.map +1 -0
- package/dist/converge-job.d.ts +141 -0
- package/dist/converge-job.d.ts.map +1 -0
- package/dist/converge-job.js +325 -0
- package/dist/converge-job.js.map +1 -0
- package/dist/gcp-names.d.ts +25 -1
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +36 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.d.ts.map +1 -1
- package/dist/infra-contract.js +28 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job.d.ts +11 -3
- package/dist/install-job.d.ts.map +1 -1
- package/dist/install-job.js +22 -12
- package/dist/install-job.js.map +1 -1
- package/dist/lifecycle.d.ts +13 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +115 -58
- package/dist/lifecycle.js.map +1 -1
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +13 -3
- package/dist/operator-access.js.map +1 -1
- package/dist/private-file.d.ts +26 -0
- package/dist/private-file.d.ts.map +1 -0
- package/dist/private-file.js +49 -0
- package/dist/private-file.js.map +1 -0
- package/dist/release-contract.d.ts +3 -2
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +8 -2
- package/dist/release-contract.js.map +1 -1
- package/dist/release-manifest.d.ts +2 -0
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js.map +1 -1
- package/dist/remote-mcp-edge.d.ts +114 -0
- package/dist/remote-mcp-edge.d.ts.map +1 -0
- package/dist/remote-mcp-edge.js +116 -0
- package/dist/remote-mcp-edge.js.map +1 -0
- package/dist/remote-mcp.d.ts +117 -0
- package/dist/remote-mcp.d.ts.map +1 -0
- package/dist/remote-mcp.js +379 -0
- package/dist/remote-mcp.js.map +1 -0
- package/dist/runtime-secrets.d.ts +12 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +32 -4
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/seal-key-rotation.d.ts +38 -0
- package/dist/seal-key-rotation.d.ts.map +1 -0
- package/dist/seal-key-rotation.js +206 -0
- package/dist/seal-key-rotation.js.map +1 -0
- package/dist/stack-contract.d.ts +175 -3
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +183 -3
- package/dist/stack-contract.js.map +1 -1
- package/dist/stack-converge.d.ts +38 -1
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +74 -4
- package/dist/stack-converge.js.map +1 -1
- package/dist/state-lock-lease.d.ts +142 -0
- package/dist/state-lock-lease.d.ts.map +1 -0
- package/dist/state-lock-lease.js +347 -0
- package/dist/state-lock-lease.js.map +1 -0
- package/dist/state-store.d.ts +66 -9
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js +249 -30
- package/dist/state-store.js.map +1 -1
- package/dist/tar-entries.d.ts.map +1 -1
- package/dist/tar-entries.js +84 -9
- package/dist/tar-entries.js.map +1 -1
- package/dist/upgrade/app-roll.d.ts +36 -7
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +88 -19
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/audit-job-roll.d.ts +38 -0
- package/dist/upgrade/audit-job-roll.d.ts.map +1 -0
- package/dist/upgrade/audit-job-roll.js +43 -0
- package/dist/upgrade/audit-job-roll.js.map +1 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +141 -8
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/gcp-lookup.d.ts +27 -10
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +46 -7
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/record-rolled.d.ts +40 -0
- package/dist/upgrade/record-rolled.d.ts.map +1 -0
- package/dist/upgrade/record-rolled.js +46 -0
- package/dist/upgrade/record-rolled.js.map +1 -0
- package/dist/upgrade/upgrade.d.ts +45 -1
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +60 -17
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-assess.d.ts +15 -1
- package/dist/upgrader-assess.d.ts.map +1 -1
- package/dist/upgrader-assess.js +36 -6
- package/dist/upgrader-assess.js.map +1 -1
- package/dist/upgrader-gcp.d.ts +15 -0
- package/dist/upgrader-gcp.d.ts.map +1 -1
- package/dist/upgrader-gcp.js +42 -7
- package/dist/upgrader-gcp.js.map +1 -1
- package/dist/upgrader-iam-preflight.d.ts +123 -0
- package/dist/upgrader-iam-preflight.d.ts.map +1 -0
- package/dist/upgrader-iam-preflight.js +414 -0
- package/dist/upgrader-iam-preflight.js.map +1 -0
- package/dist/upgrader-job.d.ts +33 -9
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.js +169 -27
- package/dist/upgrader-job.js.map +1 -1
- package/dist/upgrader-records.d.ts +58 -0
- package/dist/upgrader-records.d.ts.map +1 -0
- package/dist/upgrader-records.js +87 -0
- package/dist/upgrader-records.js.map +1 -0
- package/dist/upgrader-reporter.d.ts +3 -2
- package/dist/upgrader-reporter.d.ts.map +1 -1
- package/dist/upgrader-reporter.js +10 -2
- package/dist/upgrader-reporter.js.map +1 -1
- package/dist/upgrader-setup.d.ts +25 -3
- package/dist/upgrader-setup.d.ts.map +1 -1
- package/dist/upgrader-setup.js +132 -14
- package/dist/upgrader-setup.js.map +1 -1
- package/dist/wizards/gcp-cost.d.ts.map +1 -1
- package/dist/wizards/gcp-cost.js +14 -6
- package/dist/wizards/gcp-cost.js.map +1 -1
- package/dist/wizards/gcp-pulumi.d.ts +6 -0
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +7 -0
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +130 -4
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +4 -4
- package/src/agent/run.test.ts +15 -1
- package/src/audit-job-setup.test.ts +234 -0
- package/src/audit-job-setup.ts +360 -0
- package/src/caddy-redirects.docker.test.ts +19 -6
- package/src/caddy.test.ts +30 -0
- package/src/caddy.ts +43 -0
- package/src/cli-init-validation.test.ts +44 -0
- package/src/cli-routing.ts +1 -1
- package/src/cli.ts +40 -8
- package/src/compose.ts +5 -0
- package/src/converge-checkpoint.ts +75 -0
- package/src/converge-entrypoint.test.ts +213 -0
- package/src/converge-job-gcp.test.ts +222 -0
- package/src/converge-job-gcp.ts +443 -0
- package/src/converge-job-setup.test.ts +152 -0
- package/src/converge-job-setup.ts +268 -0
- package/src/converge-job.test.ts +486 -0
- package/src/converge-job.ts +542 -0
- package/src/gateway-env-isolation.test.ts +8 -1
- package/src/gcp-names.ts +43 -1
- package/src/gcp-url-map.test.ts +9 -1
- package/src/index.ts +8 -0
- package/src/infra-contract.test.ts +7 -4
- package/src/infra-contract.ts +28 -1
- package/src/install-job.test.ts +3 -1
- package/src/install-job.ts +31 -14
- package/src/lifecycle.ts +137 -71
- package/src/operator-access.test.ts +14 -0
- package/src/operator-access.ts +13 -3
- package/src/private-file.test.ts +80 -0
- package/src/private-file.ts +50 -0
- package/src/release-contract.ts +8 -2
- package/src/release-manifest.test.ts +42 -0
- package/src/release-manifest.ts +2 -0
- package/src/remote-mcp-edge.ts +137 -0
- package/src/remote-mcp.test.ts +517 -0
- package/src/remote-mcp.ts +460 -0
- package/src/runtime-secrets.test.ts +31 -6
- package/src/runtime-secrets.ts +41 -3
- package/src/seal-key-rotation.test.ts +131 -0
- package/src/seal-key-rotation.ts +258 -0
- package/src/stack-contract-parity.test.ts +20 -3
- package/src/stack-contract.test.ts +34 -2
- package/src/stack-contract.ts +305 -3
- package/src/stack-converge-secrets.test.ts +3 -0
- package/src/stack-converge.test.ts +21 -5
- package/src/stack-converge.ts +101 -7
- package/src/state-attach.test.ts +7 -1
- package/src/state-lock-lease.test.ts +302 -0
- package/src/state-lock-lease.ts +423 -0
- package/src/state-lock.test.ts +568 -0
- package/src/state-store.ts +347 -27
- package/src/tar-entries.test.ts +149 -7
- package/src/tar-entries.ts +90 -9
- package/src/upgrade/app-roll.ts +135 -21
- package/src/upgrade/audit-job-roll.ts +68 -0
- package/src/upgrade/converge.ts +170 -7
- package/src/upgrade/gcp-lookup.test.ts +80 -0
- package/src/upgrade/gcp-lookup.ts +65 -8
- package/src/upgrade/record-rolled.test.ts +105 -0
- package/src/upgrade/record-rolled.ts +91 -0
- package/src/upgrade/upgrade.test.ts +279 -5
- package/src/upgrade/upgrade.ts +120 -24
- package/src/upgrader-assess.test.ts +93 -3
- package/src/upgrader-assess.ts +58 -9
- package/src/upgrader-gcp.ts +47 -8
- package/src/upgrader-iam-preflight.test.ts +393 -0
- package/src/upgrader-iam-preflight.ts +510 -0
- package/src/upgrader-job.test.ts +587 -15
- package/src/upgrader-job.ts +237 -53
- package/src/upgrader-records.ts +151 -0
- package/src/upgrader-reporter.test.ts +6 -0
- package/src/upgrader-reporter.ts +7 -2
- package/src/upgrader-setup.test.ts +4 -3
- package/src/upgrader-setup.ts +137 -13
- package/src/wizards/gcp-cost.ts +15 -6
- package/src/wizards/gcp-pulumi.ts +7 -0
- package/src/wizards/gcp.ts +162 -4
- package/stacks/aws/index.ts +18 -0
- package/stacks/azure/index.ts +18 -0
- package/stacks/gcp/index.ts +59 -7
- package/stacks/gcp-firebase/index.ts +50 -6
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #806 — remote MCP's provisioning: the contract pieces (identity env,
|
|
5
|
+
* secrets, IAM), the gateway's secret file + timeout planning, and the CLI
|
|
6
|
+
* steps (identity resolution, LB path rules, Cloud Armor rule).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import { GCP_PUBLIC_BASE_PATHS } from "@caelo-cms/shared";
|
|
11
|
+
import type { GcloudResult } from "./gcloud.js";
|
|
12
|
+
import type { GcloudRunner } from "./gcloud-retry.js";
|
|
13
|
+
import {
|
|
14
|
+
applyRemoteMcpIdentityEnv,
|
|
15
|
+
ensureMcpArmorRule,
|
|
16
|
+
type LiveUrlMap,
|
|
17
|
+
planUrlMapPaths,
|
|
18
|
+
type RemoteMcpTarget,
|
|
19
|
+
resolveRemoteMcpIdentity,
|
|
20
|
+
} from "./remote-mcp.js";
|
|
21
|
+
import {
|
|
22
|
+
GENERAL_RATE_LIMIT_PRIORITY,
|
|
23
|
+
isMcpArmorRule,
|
|
24
|
+
MCP_ARMOR_PATH_PATTERN,
|
|
25
|
+
MCP_ARMOR_RULE,
|
|
26
|
+
mcpArmorRuleArgs,
|
|
27
|
+
mcpArmorRuleFlags,
|
|
28
|
+
OWASP_RULE_PRIORITIES,
|
|
29
|
+
} from "./remote-mcp-edge.js";
|
|
30
|
+
import { initialSecretValue } from "./runtime-secrets.js";
|
|
31
|
+
import {
|
|
32
|
+
CLI_GENERATED_SECRETS,
|
|
33
|
+
GATEWAY_TIMEOUT_SECONDS,
|
|
34
|
+
GATEWAY_TRUSTED_PROXY_HOPS,
|
|
35
|
+
GENERATED_SECRET_FORMAT,
|
|
36
|
+
MCP_OAUTH_CLIENT_FILE,
|
|
37
|
+
REMOTE_MCP_ENV,
|
|
38
|
+
remoteMcpIdentity,
|
|
39
|
+
remoteMcpIdentityEnv,
|
|
40
|
+
SERVICE_SECRET_FILES,
|
|
41
|
+
secretFileVolumesTemplate,
|
|
42
|
+
stackIamInvariants,
|
|
43
|
+
} from "./stack-contract.js";
|
|
44
|
+
import { planRequestTimeout, planSecretFiles } from "./stack-converge.js";
|
|
45
|
+
|
|
46
|
+
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "" }) as GcloudResult;
|
|
47
|
+
const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr }) as GcloudResult;
|
|
48
|
+
|
|
49
|
+
/** A gcloud fake answering by the longest matching command prefix. */
|
|
50
|
+
function fakeGcloud(answers: Record<string, GcloudResult>) {
|
|
51
|
+
const calls: string[] = [];
|
|
52
|
+
const run: GcloudRunner = async (args) => {
|
|
53
|
+
const line = args.join(" ");
|
|
54
|
+
calls.push(line);
|
|
55
|
+
const key = Object.keys(answers)
|
|
56
|
+
.filter((k) => line.startsWith(k))
|
|
57
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
58
|
+
return key ? (answers[key] as GcloudResult) : fail(`unexpected: ${line}`);
|
|
59
|
+
};
|
|
60
|
+
return { run, calls };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("remote-MCP identity (contract)", () => {
|
|
64
|
+
it("gcp: the site's own host, admin.<domain>, the LB backend's audience", () => {
|
|
65
|
+
expect(
|
|
66
|
+
remoteMcpIdentity({
|
|
67
|
+
provider: "gcp",
|
|
68
|
+
domain: "acme.com",
|
|
69
|
+
projectNumber: "42",
|
|
70
|
+
adminBackendServiceId: "123456789",
|
|
71
|
+
}),
|
|
72
|
+
).toEqual({
|
|
73
|
+
adminUrl: "https://admin.acme.com",
|
|
74
|
+
mcpPublicUrl: "https://acme.com",
|
|
75
|
+
iapAudience: "/projects/42/global/backendServices/123456789",
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("gcp-firebase: the gateway's and admin's Cloud Run URLs, the service's audience", () => {
|
|
80
|
+
expect(
|
|
81
|
+
remoteMcpIdentity({
|
|
82
|
+
provider: "gcp-firebase",
|
|
83
|
+
projectNumber: "42",
|
|
84
|
+
region: "europe-west1",
|
|
85
|
+
adminServiceName: "caelo-production-admin-aaa",
|
|
86
|
+
adminServiceUrl: "https://caelo-production-admin-aaa-xyz.a.run.app/",
|
|
87
|
+
gatewayServiceUrl: "https://caelo-production-gateway-bbb-xyz.a.run.app",
|
|
88
|
+
}),
|
|
89
|
+
).toEqual({
|
|
90
|
+
adminUrl: "https://caelo-production-admin-aaa-xyz.a.run.app",
|
|
91
|
+
mcpPublicUrl: "https://caelo-production-gateway-bbb-xyz.a.run.app",
|
|
92
|
+
iapAudience: "/projects/42/locations/europe-west1/services/caelo-production-admin-aaa",
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("puts the broker on the path its CAELO_TRUSTED_PROXY_HOPS describes (per-client rate limits)", () => {
|
|
97
|
+
// The broker's per-address buckets read the client from X-Forwarded-For
|
|
98
|
+
// with the deployment's hop count (api-gateway client-ip.ts). gcp: the
|
|
99
|
+
// broker answers on the site host, through the LB (client + LB = 2).
|
|
100
|
+
// gcp-firebase: on the gateway's own run.app URL, never through the
|
|
101
|
+
// Firebase Hosting rewrite, so Cloud Run's front end appends the real
|
|
102
|
+
// client (1). A broker URL on the Hosting path would pool every client
|
|
103
|
+
// behind one CDN edge address.
|
|
104
|
+
expect(GATEWAY_TRUSTED_PROXY_HOPS.gcp).toBe(2);
|
|
105
|
+
expect(
|
|
106
|
+
remoteMcpIdentity({
|
|
107
|
+
provider: "gcp",
|
|
108
|
+
domain: "acme.com",
|
|
109
|
+
projectNumber: "42",
|
|
110
|
+
adminBackendServiceId: "1",
|
|
111
|
+
}).mcpPublicUrl,
|
|
112
|
+
).toBe("https://acme.com");
|
|
113
|
+
expect(GATEWAY_TRUSTED_PROXY_HOPS["gcp-firebase"]).toBe(1);
|
|
114
|
+
const firebase = remoteMcpIdentity({
|
|
115
|
+
provider: "gcp-firebase",
|
|
116
|
+
projectNumber: "42",
|
|
117
|
+
region: "europe-west1",
|
|
118
|
+
adminServiceName: "a",
|
|
119
|
+
adminServiceUrl: "https://a-xyz.a.run.app",
|
|
120
|
+
gatewayServiceUrl: "https://g-xyz.a.run.app",
|
|
121
|
+
});
|
|
122
|
+
expect(new URL(firebase.mcpPublicUrl).hostname.endsWith(".run.app")).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("gives the admin the audience + public URL and the gateway the admin + public URL", () => {
|
|
126
|
+
const env = remoteMcpIdentityEnv({ adminUrl: "A", mcpPublicUrl: "P", iapAudience: "AUD" });
|
|
127
|
+
expect(env.admin).toEqual([
|
|
128
|
+
{ name: REMOTE_MCP_ENV.iapAudience, value: "AUD" },
|
|
129
|
+
{ name: REMOTE_MCP_ENV.mcpPublicUrl, value: "P" },
|
|
130
|
+
]);
|
|
131
|
+
expect(env.gateway).toEqual([
|
|
132
|
+
{ name: REMOTE_MCP_ENV.adminUrl, value: "A" },
|
|
133
|
+
{ name: REMOTE_MCP_ENV.mcpPublicUrl, value: "P" },
|
|
134
|
+
]);
|
|
135
|
+
expect(REMOTE_MCP_ENV).toEqual({
|
|
136
|
+
adminUrl: "CAELO_ADMIN_URL",
|
|
137
|
+
mcpPublicUrl: "CAELO_MCP_PUBLIC_URL",
|
|
138
|
+
iapAudience: "CAELO_IAP_AUDIENCE",
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("remote-MCP secrets + IAM (contract)", () => {
|
|
144
|
+
it("names a first-value format for every CLI-generated secret", () => {
|
|
145
|
+
expect(Object.keys(GENERATED_SECRET_FORMAT).sort()).toEqual([...CLI_GENERATED_SECRETS].sort());
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("starts the OAuth client as the {} placeholder and the seal key as 32 random bytes", () => {
|
|
149
|
+
expect(initialSecretValue("mcp-oauth-client")).toBe("{}");
|
|
150
|
+
const a = initialSecretValue("mcp-oauth-seal-key");
|
|
151
|
+
expect(Buffer.from(a, "base64")).toHaveLength(32);
|
|
152
|
+
expect(initialSecretValue("mcp-oauth-seal-key")).not.toBe(a);
|
|
153
|
+
expect(initialSecretValue("internal-secret", () => "hex")).toBe("hex");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("lets the admin add versions to the OAuth client secret only — never read, never another secret", () => {
|
|
157
|
+
for (const provider of ["gcp", "gcp-firebase"] as const) {
|
|
158
|
+
const adders = stackIamInvariants(provider).filter(
|
|
159
|
+
(i) => i.role === "roles/secretmanager.secretVersionAdder",
|
|
160
|
+
);
|
|
161
|
+
expect(adders).toEqual([
|
|
162
|
+
expect.objectContaining({
|
|
163
|
+
stackResource: "admin-mcp-oauth-client-version-adder",
|
|
164
|
+
member: "run-sa",
|
|
165
|
+
target: { kind: "secret", name: "mcp-oauth-client" },
|
|
166
|
+
onFailure: "warn",
|
|
167
|
+
}),
|
|
168
|
+
]);
|
|
169
|
+
const adminReads = stackIamInvariants(provider).filter(
|
|
170
|
+
(i) =>
|
|
171
|
+
i.member === "run-sa" &&
|
|
172
|
+
i.role === "roles/secretmanager.secretAccessor" &&
|
|
173
|
+
i.target.kind === "secret" &&
|
|
174
|
+
i.target.name.startsWith("mcp-oauth"),
|
|
175
|
+
);
|
|
176
|
+
expect(adminReads).toEqual([]);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("mounts the OAuth client on the gateway only, at latest, as the file its env names", () => {
|
|
181
|
+
expect(SERVICE_SECRET_FILES.admin).toEqual([]);
|
|
182
|
+
expect(SERVICE_SECRET_FILES.gateway).toEqual([MCP_OAUTH_CLIENT_FILE]);
|
|
183
|
+
expect(secretFileVolumesTemplate("gateway", "production")).toEqual({
|
|
184
|
+
volumes: [
|
|
185
|
+
{
|
|
186
|
+
name: "mcp-oauth-client",
|
|
187
|
+
secret: {
|
|
188
|
+
secret: "caelo-production-mcp-oauth-client",
|
|
189
|
+
items: [{ version: "latest", path: "client.json" }],
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
volumeMounts: [{ name: "mcp-oauth-client", mountPath: "/secrets/mcp-oauth-client" }],
|
|
194
|
+
});
|
|
195
|
+
expect(secretFileVolumesTemplate("admin", "production")).toEqual({
|
|
196
|
+
volumes: [],
|
|
197
|
+
volumeMounts: [],
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("planSecretFiles / planRequestTimeout (upgrade)", () => {
|
|
203
|
+
const empty = { executionEnvironment: null, volumes: [], mounts: [] };
|
|
204
|
+
|
|
205
|
+
it("mounts a missing secret file with the documented --update-secrets path form", () => {
|
|
206
|
+
expect(planSecretFiles(empty, [MCP_OAUTH_CLIENT_FILE], "production")).toEqual({
|
|
207
|
+
ok: true,
|
|
208
|
+
flags: [
|
|
209
|
+
"--update-secrets=/secrets/mcp-oauth-client/client.json=caelo-production-mcp-oauth-client:latest",
|
|
210
|
+
],
|
|
211
|
+
changes: [
|
|
212
|
+
"secret file /secrets/mcp-oauth-client/client.json: (none) → caelo-production-mcp-oauth-client:latest",
|
|
213
|
+
],
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("leaves the file the stack (or an earlier upgrade) mounted alone, whatever its volume is called", () => {
|
|
218
|
+
for (const name of ["mcp-oauth-client", "secret-1a2b3c"]) {
|
|
219
|
+
const live = {
|
|
220
|
+
executionEnvironment: null,
|
|
221
|
+
volumes: [
|
|
222
|
+
{
|
|
223
|
+
name,
|
|
224
|
+
secret: {
|
|
225
|
+
secretName: "projects/42/secrets/caelo-production-mcp-oauth-client",
|
|
226
|
+
items: [{ key: "latest", path: "client.json" }],
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
mounts: [{ name, mountPath: "/secrets/mcp-oauth-client" }],
|
|
231
|
+
};
|
|
232
|
+
expect(planSecretFiles(live, [MCP_OAUTH_CLIENT_FILE], "production")).toEqual({
|
|
233
|
+
ok: true,
|
|
234
|
+
flags: [],
|
|
235
|
+
changes: [],
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("refuses something else mounted where the client belongs (a pinned version included)", () => {
|
|
241
|
+
const live = {
|
|
242
|
+
executionEnvironment: null,
|
|
243
|
+
volumes: [
|
|
244
|
+
{
|
|
245
|
+
name: "v",
|
|
246
|
+
secret: {
|
|
247
|
+
secretName: "caelo-production-mcp-oauth-client",
|
|
248
|
+
items: [{ key: "3", path: "client.json" }],
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
mounts: [{ name: "v", mountPath: "/secrets/mcp-oauth-client" }],
|
|
253
|
+
};
|
|
254
|
+
const plan = planSecretFiles(live, [MCP_OAUTH_CLIENT_FILE], "production");
|
|
255
|
+
expect(plan.ok).toBe(false);
|
|
256
|
+
if (!plan.ok) expect(plan.error).toContain("--remove-volume-mount=/secrets/mcp-oauth-client");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("raises the gateway's request timeout to the contract's", () => {
|
|
260
|
+
expect(GATEWAY_TIMEOUT_SECONDS).toBe(3600);
|
|
261
|
+
expect(planRequestTimeout(null, 3600).ok && planRequestTimeout(null, 3600)).toEqual({
|
|
262
|
+
ok: true,
|
|
263
|
+
flags: ["--timeout=3600"],
|
|
264
|
+
changes: ["request timeout: (default 300)s → 3600s"],
|
|
265
|
+
});
|
|
266
|
+
expect(planRequestTimeout(60, 3600)).toMatchObject({ flags: ["--timeout=3600"] });
|
|
267
|
+
expect(planRequestTimeout(3600, 3600)).toEqual({ ok: true, flags: [], changes: [] });
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const firebase: RemoteMcpTarget = {
|
|
272
|
+
provider: "gcp-firebase",
|
|
273
|
+
projectId: "acme",
|
|
274
|
+
region: "europe-west1",
|
|
275
|
+
env: "production",
|
|
276
|
+
domain: "acme.com",
|
|
277
|
+
services: { admin: "caelo-production-admin-aaa", gateway: "caelo-production-gateway-bbb" },
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
describe("resolveRemoteMcpIdentity", () => {
|
|
281
|
+
it("gcp-firebase: reads the project number and both services' URLs", async () => {
|
|
282
|
+
const { run } = fakeGcloud({
|
|
283
|
+
"projects describe acme": ok("42\n"),
|
|
284
|
+
"run services describe caelo-production-admin-aaa": ok(
|
|
285
|
+
"https://caelo-production-admin-aaa-xyz.a.run.app\n",
|
|
286
|
+
),
|
|
287
|
+
"run services describe caelo-production-gateway-bbb": ok(
|
|
288
|
+
"https://caelo-production-gateway-bbb-xyz.a.run.app\n",
|
|
289
|
+
),
|
|
290
|
+
});
|
|
291
|
+
const r = await resolveRemoteMcpIdentity(firebase, { run });
|
|
292
|
+
expect(r).toEqual({
|
|
293
|
+
ok: true,
|
|
294
|
+
identity: {
|
|
295
|
+
adminUrl: "https://caelo-production-admin-aaa-xyz.a.run.app",
|
|
296
|
+
mcpPublicUrl: "https://caelo-production-gateway-bbb-xyz.a.run.app",
|
|
297
|
+
iapAudience: "/projects/42/locations/europe-west1/services/caelo-production-admin-aaa",
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("gcp: reads the IAP-enabled admin backend service's numeric id", async () => {
|
|
303
|
+
const { run, calls } = fakeGcloud({
|
|
304
|
+
"projects describe acme": ok("42"),
|
|
305
|
+
"compute backend-services list": ok("987654321\n"),
|
|
306
|
+
});
|
|
307
|
+
const r = await resolveRemoteMcpIdentity({ ...firebase, provider: "gcp" }, { run });
|
|
308
|
+
expect(r.ok && r.identity.iapAudience).toBe("/projects/42/global/backendServices/987654321");
|
|
309
|
+
expect(
|
|
310
|
+
calls.some((c) =>
|
|
311
|
+
c.includes("--filter=name~^caelo-production-admin-backend AND iap.enabled=true"),
|
|
312
|
+
),
|
|
313
|
+
).toBe(true);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("fails loudly instead of guessing when the backend is ambiguous", async () => {
|
|
317
|
+
const { run } = fakeGcloud({
|
|
318
|
+
"projects describe acme": ok("42"),
|
|
319
|
+
"compute backend-services list": ok("1\n2\n"),
|
|
320
|
+
});
|
|
321
|
+
const r = await resolveRemoteMcpIdentity({ ...firebase, provider: "gcp" }, { run });
|
|
322
|
+
expect(r.ok).toBe(false);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe("applyRemoteMcpIdentityEnv", () => {
|
|
327
|
+
const identity = {
|
|
328
|
+
adminUrl: "https://a.run.app",
|
|
329
|
+
mcpPublicUrl: "https://g.run.app",
|
|
330
|
+
iapAudience: "/projects/42/locations/europe-west1/services/caelo-production-admin-aaa",
|
|
331
|
+
};
|
|
332
|
+
const serviceJson = (env: { name: string; value: string }[]) =>
|
|
333
|
+
ok(JSON.stringify({ spec: { template: { spec: { containers: [{ env }] } } } }));
|
|
334
|
+
|
|
335
|
+
it("updates only a service whose vars differ", async () => {
|
|
336
|
+
const { run, calls } = fakeGcloud({
|
|
337
|
+
"run services describe caelo-production-admin-aaa": serviceJson([
|
|
338
|
+
{ name: "CAELO_IAP_AUDIENCE", value: identity.iapAudience },
|
|
339
|
+
{ name: "CAELO_MCP_PUBLIC_URL", value: identity.mcpPublicUrl },
|
|
340
|
+
]),
|
|
341
|
+
"run services describe caelo-production-gateway-bbb": serviceJson([]),
|
|
342
|
+
"run services update caelo-production-gateway-bbb": ok(),
|
|
343
|
+
});
|
|
344
|
+
const r = await applyRemoteMcpIdentityEnv(firebase, identity, { run });
|
|
345
|
+
expect(r.ok).toBe(true);
|
|
346
|
+
expect(calls.filter((c) => c.startsWith("run services update"))).toEqual([
|
|
347
|
+
"run services update caelo-production-gateway-bbb --region=europe-west1 --project=acme --update-env-vars=CAELO_ADMIN_URL=https://a.run.app,CAELO_MCP_PUBLIC_URL=https://g.run.app --quiet",
|
|
348
|
+
]);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe("gcp load balancer (remote-mcp-edge)", () => {
|
|
353
|
+
it("evaluates the /api/mcp/* rule before the general ban and the OWASP packs", () => {
|
|
354
|
+
expect(MCP_ARMOR_RULE.priority).toBeLessThan(GENERAL_RATE_LIMIT_PRIORITY);
|
|
355
|
+
for (const p of OWASP_RULE_PRIORITIES) expect(MCP_ARMOR_RULE.priority).toBeLessThan(p);
|
|
356
|
+
// A throttle (no ban) whose conforming action is `allow` — terminal.
|
|
357
|
+
expect(MCP_ARMOR_RULE.action).toBe("throttle");
|
|
358
|
+
expect(MCP_ARMOR_RULE.conformAction).toBe("allow");
|
|
359
|
+
expect(MCP_ARMOR_RULE.expression).toBe(`request.path.matches('${MCP_ARMOR_PATH_PATTERN}')`);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("covers exactly the broker's paths; a traversal never borrows the MCP rule", async () => {
|
|
363
|
+
// RE2 and JS agree on this pattern (anchors, alternation, classes only).
|
|
364
|
+
const re = new RegExp(MCP_ARMOR_PATH_PATTERN);
|
|
365
|
+
for (const p of [
|
|
366
|
+
"/api/mcp/chat",
|
|
367
|
+
"/api/mcp/admin",
|
|
368
|
+
"/api/mcp/oauth/authorize",
|
|
369
|
+
"/api/mcp/oauth/google-callback",
|
|
370
|
+
"/api/mcp/oauth/token",
|
|
371
|
+
"/api/mcp/oauth/register",
|
|
372
|
+
"/api/mcp/oauth/status",
|
|
373
|
+
"/api/mcp/oauth/protected-resource/chat",
|
|
374
|
+
]) {
|
|
375
|
+
expect(re.test(p)).toBe(true);
|
|
376
|
+
}
|
|
377
|
+
for (const p of [
|
|
378
|
+
"/api/mcp/../forms/submit",
|
|
379
|
+
"/api/mcp/%2e%2e/comments/post",
|
|
380
|
+
"/api/mcp/chat/../../forms/submit",
|
|
381
|
+
"/api/mcp/chat/x",
|
|
382
|
+
"/api/mcp/oauth/token/../../../forms",
|
|
383
|
+
"/api/mcp/",
|
|
384
|
+
"/api/forms/submit",
|
|
385
|
+
]) {
|
|
386
|
+
expect(re.test(p)).toBe(false);
|
|
387
|
+
}
|
|
388
|
+
// Every /api/mcp/ route the gateway documents — the consent page's
|
|
389
|
+
// POST /api/mcp/oauth/authorize included — is covered.
|
|
390
|
+
const routes = await Bun.file(
|
|
391
|
+
new URL("../../../apps/api-gateway/src/mcp/routes.ts", import.meta.url),
|
|
392
|
+
).text();
|
|
393
|
+
const documented = [...routes.matchAll(/^ \* {3}[A-Z|]+ +(\/api\/mcp\/[^\s]+)/gm)]
|
|
394
|
+
.map((m) => m[1] as string)
|
|
395
|
+
.flatMap((p) =>
|
|
396
|
+
p.includes("{") ? ["chat", "admin"].map((s) => p.replace(/\{(chat,admin|s)\}/, s)) : [p],
|
|
397
|
+
);
|
|
398
|
+
expect(documented).toContain("/api/mcp/oauth/authorize");
|
|
399
|
+
expect(documented.length).toBeGreaterThanOrEqual(8);
|
|
400
|
+
for (const p of documented) expect(re.test(p)).toBe(true);
|
|
401
|
+
// Why it matters: the gateway's URL parser resolves such a path to a public write.
|
|
402
|
+
expect(new URL("https://h/api/mcp/%2e%2e/comments/post").pathname).toBe("/api/comments/post");
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("throttles at the edge exactly where the gateway's own proxy bucket does (#899)", async () => {
|
|
406
|
+
// Both key on the client address (Cloud Armor: the LB's view; the
|
|
407
|
+
// gateway: CAELO_TRUSTED_PROXY_HOPS). Neither may be the tighter one by
|
|
408
|
+
// accident — a lower edge limit would 429 traffic the broker allows.
|
|
409
|
+
const src = await Bun.file(
|
|
410
|
+
new URL("../../../apps/api-gateway/src/mcp/rate-limit.ts", import.meta.url),
|
|
411
|
+
).text();
|
|
412
|
+
const proxy = /proxy: \{ capacity: (\d+), windowSeconds: (\d+) \}/.exec(src);
|
|
413
|
+
expect(proxy).not.toBeNull();
|
|
414
|
+
expect([Number(proxy?.[1]), Number(proxy?.[2])]).toEqual([
|
|
415
|
+
MCP_ARMOR_RULE.count,
|
|
416
|
+
MCP_ARMOR_RULE.intervalSec,
|
|
417
|
+
]);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("the stack shape and the gcloud flags describe the same rule", () => {
|
|
421
|
+
const args = mcpArmorRuleArgs();
|
|
422
|
+
expect(
|
|
423
|
+
isMcpArmorRule({ ...args, match: { expr: { expression: args.match.expr.expression } } }),
|
|
424
|
+
).toBe(true);
|
|
425
|
+
expect(mcpArmorRuleFlags()).toEqual([
|
|
426
|
+
`--description=${MCP_ARMOR_RULE.description}`,
|
|
427
|
+
`--expression=request.path.matches('${MCP_ARMOR_PATH_PATTERN}')`,
|
|
428
|
+
"--action=throttle",
|
|
429
|
+
"--rate-limit-threshold-count=600",
|
|
430
|
+
"--rate-limit-threshold-interval-sec=60",
|
|
431
|
+
"--conform-action=allow",
|
|
432
|
+
"--exceed-action=deny-429",
|
|
433
|
+
"--enforce-on-key=IP",
|
|
434
|
+
]);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("the gcp stack declares the rule from the shared module", async () => {
|
|
438
|
+
const src = await Bun.file(new URL("../stacks/gcp/index.ts", import.meta.url)).text();
|
|
439
|
+
expect(src).toContain("mcpArmorRuleArgs(),");
|
|
440
|
+
expect(src.indexOf("mcpArmorRuleArgs(),")).toBeLessThan(
|
|
441
|
+
src.indexOf("evaluatePreconfiguredWaf"),
|
|
442
|
+
);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const map = (paths: string[], extra: object[] = []): LiveUrlMap => ({
|
|
446
|
+
name: "caelo-production-url-map-abc",
|
|
447
|
+
fingerprint: "fp",
|
|
448
|
+
hostRules: [
|
|
449
|
+
{ hosts: ["acme.com"], pathMatcher: "public" },
|
|
450
|
+
{ hosts: ["admin.acme.com"], pathMatcher: "admin" },
|
|
451
|
+
],
|
|
452
|
+
pathMatchers: [
|
|
453
|
+
{
|
|
454
|
+
name: "public",
|
|
455
|
+
pathRules: [
|
|
456
|
+
{ paths, service: "projects/acme/global/backendServices/gw" },
|
|
457
|
+
...(extra as never[]),
|
|
458
|
+
],
|
|
459
|
+
},
|
|
460
|
+
{ name: "admin", pathRules: [] },
|
|
461
|
+
],
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("adds the discovery paths to the /api/* rule and keeps the admin's redirects", () => {
|
|
465
|
+
const redirect = { paths: ["/old"], urlRedirect: { pathRedirect: "/new" } };
|
|
466
|
+
const plan = planUrlMapPaths([map(["/api/*"], [redirect])], "acme.com");
|
|
467
|
+
if (!plan.ok) throw new Error(plan.error);
|
|
468
|
+
expect(plan.added).toEqual(GCP_PUBLIC_BASE_PATHS.filter((p) => p !== "/api/*"));
|
|
469
|
+
const rules = plan.map.pathMatchers?.[0]?.pathRules ?? [];
|
|
470
|
+
expect(rules[0]?.paths).toEqual([...GCP_PUBLIC_BASE_PATHS]);
|
|
471
|
+
expect(rules[1]).toEqual(redirect);
|
|
472
|
+
expect(plan.map.fingerprint).toBe("fp");
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("changes nothing on a map that already routes them", () => {
|
|
476
|
+
const plan = planUrlMapPaths([map([...GCP_PUBLIC_BASE_PATHS])], "acme.com");
|
|
477
|
+
expect(plan.ok && plan.added).toEqual([]);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("refuses a map without the gateway rule instead of inventing one", () => {
|
|
481
|
+
const plan = planUrlMapPaths([map(["/other/*"])], "acme.com");
|
|
482
|
+
expect(plan.ok).toBe(false);
|
|
483
|
+
expect(planUrlMapPaths([], "acme.com").ok).toBe(false);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("creates the Cloud Armor rule when missing, leaves it alone when in shape", async () => {
|
|
487
|
+
const policyJson = (rules: object[]) => ok(JSON.stringify({ rules }));
|
|
488
|
+
const missing = fakeGcloud({
|
|
489
|
+
"compute security-policies list": ok("caelo-production-waf-1a2b\n"),
|
|
490
|
+
"compute security-policies describe": policyJson([{ priority: 1000 }]),
|
|
491
|
+
"compute security-policies rules create 900": ok(),
|
|
492
|
+
});
|
|
493
|
+
expect(await ensureMcpArmorRule({ projectId: "acme", env: "production" }, missing)).toEqual({
|
|
494
|
+
ok: true,
|
|
495
|
+
policy: "caelo-production-waf-1a2b",
|
|
496
|
+
action: "created",
|
|
497
|
+
});
|
|
498
|
+
expect(missing.calls.at(-1)).toContain("--security-policy=caelo-production-waf-1a2b");
|
|
499
|
+
|
|
500
|
+
const args = mcpArmorRuleArgs();
|
|
501
|
+
const present = fakeGcloud({
|
|
502
|
+
"compute security-policies list": ok("caelo-production-waf-1a2b\n"),
|
|
503
|
+
"compute security-policies describe": policyJson([{ ...args }]),
|
|
504
|
+
});
|
|
505
|
+
const r = await ensureMcpArmorRule({ projectId: "acme", env: "production" }, present);
|
|
506
|
+
expect(r.ok && r.action).toBe("present");
|
|
507
|
+
expect(present.calls.some((c) => c.includes("rules "))).toBe(false);
|
|
508
|
+
|
|
509
|
+
const drifted = fakeGcloud({
|
|
510
|
+
"compute security-policies list": ok("caelo-production-waf-1a2b\n"),
|
|
511
|
+
"compute security-policies describe": policyJson([{ ...args, action: "allow" }]),
|
|
512
|
+
"compute security-policies rules update 900": ok(),
|
|
513
|
+
});
|
|
514
|
+
const d = await ensureMcpArmorRule({ projectId: "acme", env: "production" }, drifted);
|
|
515
|
+
expect(d.ok && d.action).toBe("updated");
|
|
516
|
+
});
|
|
517
|
+
});
|