@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,460 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #806 — the CLI's part of remote MCP on a Google IAP install (`gcp`,
|
|
5
|
+
* `gcp-firebase`), run as the operator's (or the provisioner's) gcloud
|
|
6
|
+
* identity by the wizard after `pulumi up` and by `cms-provision upgrade`
|
|
7
|
+
* after the roll. Every step is idempotent.
|
|
8
|
+
*
|
|
9
|
+
* - {@link ensureRemoteMcpIdentityEnv}: the env vars that name generated
|
|
10
|
+
* identities ({@link RemoteMcpIdentity} — why the stacks cannot declare
|
|
11
|
+
* them is in stack-contract.ts), applied with `gcloud run services
|
|
12
|
+
* update` only where they differ.
|
|
13
|
+
* - {@link ensureRemoteMcpEdge} (`gcp` only): the load balancer's part for
|
|
14
|
+
* installs whose URL map / Cloud Armor policy predate it — the
|
|
15
|
+
* `/.well-known/oauth-*` path rules (the stack ignores `pathRules` after
|
|
16
|
+
* creation, gcp-url-map.ts) and the `/api/mcp/*` Cloud Armor rule
|
|
17
|
+
* (remote-mcp-edge.ts).
|
|
18
|
+
*
|
|
19
|
+
* The secrets, the IAM bindings, the gateway's secret volume and timeout
|
|
20
|
+
* come through the shared contract like everything else
|
|
21
|
+
* (runtime-secrets.ts, stack-contract.ts, upgrade/app-roll.ts).
|
|
22
|
+
*
|
|
23
|
+
* Nothing here enables remote MCP: that is the Owner's opt-in under
|
|
24
|
+
* Settings → MCP → Remote. Until then the gateway answers 503
|
|
25
|
+
* `remote_mcp_not_configured`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { GCP_PUBLIC_BASE_PATHS } from "@caelo-cms/shared";
|
|
29
|
+
import { gcloud as defaultGcloud } from "./gcloud.js";
|
|
30
|
+
import type { GcloudRunner } from "./gcloud-retry.js";
|
|
31
|
+
import { gcpNamePrefix } from "./gcp-names.js";
|
|
32
|
+
import {
|
|
33
|
+
isMcpArmorRule,
|
|
34
|
+
type LiveArmorRule,
|
|
35
|
+
MCP_ARMOR_RULE,
|
|
36
|
+
mcpArmorRuleFlags,
|
|
37
|
+
} from "./remote-mcp-edge.js";
|
|
38
|
+
import type { HttpFetch } from "./runtime-secrets.js";
|
|
39
|
+
import {
|
|
40
|
+
type CloudRunSlug,
|
|
41
|
+
type GcpProvider,
|
|
42
|
+
type RemoteMcpIdentity,
|
|
43
|
+
remoteMcpIdentity,
|
|
44
|
+
remoteMcpIdentityEnv,
|
|
45
|
+
} from "./stack-contract.js";
|
|
46
|
+
import { type EnvChange, liveContainerEnv, planEnvUpdate } from "./stack-converge.js";
|
|
47
|
+
|
|
48
|
+
/** The install remote MCP is set up on. */
|
|
49
|
+
export interface RemoteMcpTarget {
|
|
50
|
+
readonly provider: GcpProvider;
|
|
51
|
+
readonly projectId: string;
|
|
52
|
+
readonly region: string;
|
|
53
|
+
/** Pulumi stack name (`production`). */
|
|
54
|
+
readonly env: string;
|
|
55
|
+
readonly domain: string;
|
|
56
|
+
/** Deployed (Pulumi-suffixed) Cloud Run service names. */
|
|
57
|
+
readonly services: Readonly<Record<CloudRunSlug, string>>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type RemoteMcpResult<T> =
|
|
61
|
+
| ({ readonly ok: true } & T)
|
|
62
|
+
| { readonly ok: false; readonly error: string };
|
|
63
|
+
|
|
64
|
+
/** Parse gcloud JSON output; null (never a throw) when it is not JSON. */
|
|
65
|
+
function parseJson(stdout: string): unknown {
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(stdout);
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function lines(stdout: string): string[] {
|
|
74
|
+
return stdout
|
|
75
|
+
.split("\n")
|
|
76
|
+
.map((s) => s.trim())
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ===========================================================================
|
|
81
|
+
// Identity env
|
|
82
|
+
// ===========================================================================
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Read the live identities {@link remoteMcpIdentity} derives from: the
|
|
86
|
+
* project number, and the admin's backend service id (`gcp`) or both
|
|
87
|
+
* services' URLs (`gcp-firebase`). Fails naming what it could not read.
|
|
88
|
+
*/
|
|
89
|
+
export async function resolveRemoteMcpIdentity(
|
|
90
|
+
target: RemoteMcpTarget,
|
|
91
|
+
deps: { run?: GcloudRunner } = {},
|
|
92
|
+
): Promise<RemoteMcpResult<{ readonly identity: RemoteMcpIdentity }>> {
|
|
93
|
+
const run = deps.run ?? defaultGcloud;
|
|
94
|
+
const project = `--project=${target.projectId}`;
|
|
95
|
+
const num = await run([
|
|
96
|
+
"projects",
|
|
97
|
+
"describe",
|
|
98
|
+
target.projectId,
|
|
99
|
+
"--format=value(projectNumber)",
|
|
100
|
+
]);
|
|
101
|
+
const projectNumber = num.ok ? num.stdout.trim() : "";
|
|
102
|
+
if (!/^\d+$/.test(projectNumber)) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: `read the project number of ${target.projectId}: ${num.stderr.trim() || "empty"}`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (target.provider === "gcp") {
|
|
109
|
+
const prefix = `${gcpNamePrefix(target.env)}-admin-backend`;
|
|
110
|
+
const list = await run([
|
|
111
|
+
"compute",
|
|
112
|
+
"backend-services",
|
|
113
|
+
"list",
|
|
114
|
+
"--global",
|
|
115
|
+
project,
|
|
116
|
+
`--filter=name~^${prefix} AND iap.enabled=true`,
|
|
117
|
+
"--format=value(id)",
|
|
118
|
+
]);
|
|
119
|
+
if (!list.ok) return { ok: false, error: `list backend services: ${list.stderr.trim()}` };
|
|
120
|
+
const ids = lines(list.stdout);
|
|
121
|
+
if (ids.length !== 1 || !/^\d+$/.test(ids[0] ?? "")) {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
error: `expected exactly one IAP-enabled ${prefix}* backend service, found ${ids.length}`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
identity: remoteMcpIdentity({
|
|
130
|
+
provider: "gcp",
|
|
131
|
+
domain: target.domain,
|
|
132
|
+
projectNumber,
|
|
133
|
+
adminBackendServiceId: ids[0] as string,
|
|
134
|
+
}),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const url = async (service: string) => {
|
|
138
|
+
const r = await run([
|
|
139
|
+
"run",
|
|
140
|
+
"services",
|
|
141
|
+
"describe",
|
|
142
|
+
service,
|
|
143
|
+
`--region=${target.region}`,
|
|
144
|
+
project,
|
|
145
|
+
"--format=value(status.url)",
|
|
146
|
+
]);
|
|
147
|
+
const value = r.ok ? r.stdout.trim() : "";
|
|
148
|
+
return value.startsWith("https://") ? value : null;
|
|
149
|
+
};
|
|
150
|
+
const adminUrl = await url(target.services.admin);
|
|
151
|
+
const gatewayUrl = await url(target.services.gateway);
|
|
152
|
+
if (!adminUrl || !gatewayUrl) {
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
error: `read the Cloud Run URL of the ${adminUrl ? "gateway" : "admin"} service (${adminUrl ? target.services.gateway : target.services.admin})`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
ok: true,
|
|
160
|
+
identity: remoteMcpIdentity({
|
|
161
|
+
provider: "gcp-firebase",
|
|
162
|
+
projectNumber,
|
|
163
|
+
region: target.region,
|
|
164
|
+
adminServiceName: target.services.admin,
|
|
165
|
+
adminServiceUrl: adminUrl,
|
|
166
|
+
gatewayServiceUrl: gatewayUrl,
|
|
167
|
+
}),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Bring both services' {@link RemoteMcpIdentity} env vars to `identity`
|
|
173
|
+
* with one `gcloud run services update` per service that differs (a new
|
|
174
|
+
* revision only then). Vars already right are left alone.
|
|
175
|
+
*/
|
|
176
|
+
export async function applyRemoteMcpIdentityEnv(
|
|
177
|
+
target: RemoteMcpTarget,
|
|
178
|
+
identity: RemoteMcpIdentity,
|
|
179
|
+
deps: { run?: GcloudRunner } = {},
|
|
180
|
+
): Promise<RemoteMcpResult<{ readonly changes: Record<CloudRunSlug, EnvChange[]> }>> {
|
|
181
|
+
const run = deps.run ?? defaultGcloud;
|
|
182
|
+
const desired = remoteMcpIdentityEnv(identity);
|
|
183
|
+
const changes: Record<CloudRunSlug, EnvChange[]> = { admin: [], gateway: [] };
|
|
184
|
+
for (const slug of ["admin", "gateway"] as const) {
|
|
185
|
+
const service = target.services[slug];
|
|
186
|
+
const describe = await run([
|
|
187
|
+
"run",
|
|
188
|
+
"services",
|
|
189
|
+
"describe",
|
|
190
|
+
service,
|
|
191
|
+
`--region=${target.region}`,
|
|
192
|
+
`--project=${target.projectId}`,
|
|
193
|
+
"--format=json",
|
|
194
|
+
]);
|
|
195
|
+
if (!describe.ok) return { ok: false, error: `describe ${service}: ${describe.stderr.trim()}` };
|
|
196
|
+
if (parseJson(describe.stdout) === null) {
|
|
197
|
+
return { ok: false, error: `describe ${service}: output is not JSON` };
|
|
198
|
+
}
|
|
199
|
+
const plan = planEnvUpdate(liveContainerEnv(describe.stdout), desired[slug]);
|
|
200
|
+
if (!plan.ok) return { ok: false, error: `${slug}: ${plan.error}` };
|
|
201
|
+
if (plan.flags.length === 0) continue;
|
|
202
|
+
const update = await run([
|
|
203
|
+
"run",
|
|
204
|
+
"services",
|
|
205
|
+
"update",
|
|
206
|
+
service,
|
|
207
|
+
`--region=${target.region}`,
|
|
208
|
+
`--project=${target.projectId}`,
|
|
209
|
+
...plan.flags,
|
|
210
|
+
"--quiet",
|
|
211
|
+
]);
|
|
212
|
+
if (!update.ok) return { ok: false, error: `update ${service}: ${update.stderr.trim()}` };
|
|
213
|
+
changes[slug] = plan.changes;
|
|
214
|
+
}
|
|
215
|
+
return { ok: true, changes };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** {@link resolveRemoteMcpIdentity} then {@link applyRemoteMcpIdentityEnv}. */
|
|
219
|
+
export async function ensureRemoteMcpIdentityEnv(
|
|
220
|
+
target: RemoteMcpTarget,
|
|
221
|
+
deps: { run?: GcloudRunner } = {},
|
|
222
|
+
): Promise<
|
|
223
|
+
RemoteMcpResult<{
|
|
224
|
+
readonly identity: RemoteMcpIdentity;
|
|
225
|
+
readonly changes: Record<CloudRunSlug, EnvChange[]>;
|
|
226
|
+
}>
|
|
227
|
+
> {
|
|
228
|
+
const resolved = await resolveRemoteMcpIdentity(target, deps);
|
|
229
|
+
if (!resolved.ok) return resolved;
|
|
230
|
+
const applied = await applyRemoteMcpIdentityEnv(target, resolved.identity, deps);
|
|
231
|
+
if (!applied.ok) return applied;
|
|
232
|
+
return { ok: true, identity: resolved.identity, changes: applied.changes };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ===========================================================================
|
|
236
|
+
// Load balancer (gcp)
|
|
237
|
+
// ===========================================================================
|
|
238
|
+
|
|
239
|
+
/** A path rule / matcher / URL map as the Compute API returns them (the parts used here). */
|
|
240
|
+
interface UrlMapPathRule {
|
|
241
|
+
readonly paths: string[];
|
|
242
|
+
readonly service?: string;
|
|
243
|
+
readonly urlRedirect?: unknown;
|
|
244
|
+
}
|
|
245
|
+
interface UrlMapPathMatcher {
|
|
246
|
+
readonly name: string;
|
|
247
|
+
readonly pathRules?: UrlMapPathRule[];
|
|
248
|
+
}
|
|
249
|
+
export interface LiveUrlMap {
|
|
250
|
+
readonly name: string;
|
|
251
|
+
readonly hostRules?: { readonly hosts: string[]; readonly pathMatcher: string }[];
|
|
252
|
+
readonly pathMatchers?: UrlMapPathMatcher[];
|
|
253
|
+
readonly [field: string]: unknown;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export type UrlMapPathsPlan =
|
|
257
|
+
| { readonly ok: true; readonly map: LiveUrlMap; readonly added: string[] }
|
|
258
|
+
| { readonly ok: false; readonly error: string };
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The URL map with every {@link GCP_PUBLIC_BASE_PATHS} entry routed like
|
|
262
|
+
* `/api/*` (to the gateway). Only the rule that carries `/api/*` changes;
|
|
263
|
+
* redirect rules the admin wrote stay as they are. `added` is empty when the
|
|
264
|
+
* map is already complete.
|
|
265
|
+
*/
|
|
266
|
+
export function planUrlMapPaths(maps: readonly LiveUrlMap[], siteHost: string): UrlMapPathsPlan {
|
|
267
|
+
const routing = maps.flatMap((m) =>
|
|
268
|
+
(m.hostRules ?? [])
|
|
269
|
+
.filter((h) => h.hosts.includes(siteHost))
|
|
270
|
+
.map((h) => ({ map: m, pathMatcher: h.pathMatcher })),
|
|
271
|
+
);
|
|
272
|
+
if (routing.length !== 1) {
|
|
273
|
+
return {
|
|
274
|
+
ok: false,
|
|
275
|
+
error: `expected exactly one URL map routing ${siteHost}, found ${routing.length}`,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
const { map, pathMatcher } = routing[0] as (typeof routing)[number];
|
|
279
|
+
const matcher = (map.pathMatchers ?? []).find((m) => m.name === pathMatcher);
|
|
280
|
+
const gatewayRule = matcher?.pathRules?.find(
|
|
281
|
+
(r) => r.service !== undefined && r.urlRedirect === undefined && r.paths.includes("/api/*"),
|
|
282
|
+
);
|
|
283
|
+
if (!matcher || !gatewayRule) {
|
|
284
|
+
return {
|
|
285
|
+
ok: false,
|
|
286
|
+
error: `URL map ${map.name} has no /api/* rule to the gateway in path matcher ${pathMatcher}; re-run the installer to restore the load balancer`,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const added = GCP_PUBLIC_BASE_PATHS.filter((p) => !gatewayRule.paths.includes(p));
|
|
290
|
+
if (added.length === 0) return { ok: true, map, added };
|
|
291
|
+
const next: LiveUrlMap = {
|
|
292
|
+
...map,
|
|
293
|
+
pathMatchers: (map.pathMatchers ?? []).map((m) =>
|
|
294
|
+
m !== matcher
|
|
295
|
+
? m
|
|
296
|
+
: {
|
|
297
|
+
...m,
|
|
298
|
+
pathRules: (m.pathRules ?? []).map((r) =>
|
|
299
|
+
r === gatewayRule ? { ...r, paths: [...r.paths, ...added] } : r,
|
|
300
|
+
),
|
|
301
|
+
},
|
|
302
|
+
),
|
|
303
|
+
};
|
|
304
|
+
return { ok: true, map: next, added };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const COMPUTE_API = "https://compute.googleapis.com/compute/v1";
|
|
308
|
+
const OPERATION_POLLS = 60;
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Route the remote-MCP discovery paths to the gateway on an existing `gcp`
|
|
312
|
+
* install: read the URL map, add the missing paths to its `/api/*` rule and
|
|
313
|
+
* write it back through the Compute API (`urlMaps.update` carries the map's
|
|
314
|
+
* fingerprint, so a concurrent write — the admin publishing redirects —
|
|
315
|
+
* fails instead of being lost; a re-run then succeeds).
|
|
316
|
+
*/
|
|
317
|
+
export async function ensureUrlMapRemoteMcpPaths(
|
|
318
|
+
target: Pick<RemoteMcpTarget, "projectId" | "domain">,
|
|
319
|
+
deps: { run?: GcloudRunner; http?: HttpFetch; sleep?: (ms: number) => Promise<void> } = {},
|
|
320
|
+
): Promise<RemoteMcpResult<{ readonly added: string[] }>> {
|
|
321
|
+
const run = deps.run ?? defaultGcloud;
|
|
322
|
+
const http = deps.http ?? (fetch as unknown as HttpFetch);
|
|
323
|
+
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
324
|
+
const list = await run([
|
|
325
|
+
"compute",
|
|
326
|
+
"url-maps",
|
|
327
|
+
"list",
|
|
328
|
+
"--global",
|
|
329
|
+
`--project=${target.projectId}`,
|
|
330
|
+
"--format=json",
|
|
331
|
+
]);
|
|
332
|
+
if (!list.ok) return { ok: false, error: `list URL maps: ${list.stderr.trim()}` };
|
|
333
|
+
const maps = parseJson(list.stdout || "[]");
|
|
334
|
+
if (!Array.isArray(maps)) return { ok: false, error: "list URL maps: output is not a JSON list" };
|
|
335
|
+
const plan = planUrlMapPaths(maps as LiveUrlMap[], target.domain);
|
|
336
|
+
if (!plan.ok) return plan;
|
|
337
|
+
if (plan.added.length === 0) return { ok: true, added: [] };
|
|
338
|
+
const token = await run(["auth", "print-access-token"]);
|
|
339
|
+
const bearer = token.ok ? token.stdout.trim() : "";
|
|
340
|
+
if (!bearer)
|
|
341
|
+
return { ok: false, error: `gcloud auth print-access-token: ${token.stderr.trim()}` };
|
|
342
|
+
const headers = { Authorization: `Bearer ${bearer}`, "Content-Type": "application/json" };
|
|
343
|
+
const base = `${COMPUTE_API}/projects/${encodeURIComponent(target.projectId)}/global`;
|
|
344
|
+
const put = await http(`${base}/urlMaps/${encodeURIComponent(plan.map.name)}`, {
|
|
345
|
+
method: "PUT",
|
|
346
|
+
headers,
|
|
347
|
+
body: JSON.stringify(plan.map),
|
|
348
|
+
});
|
|
349
|
+
if (!put.ok) {
|
|
350
|
+
return {
|
|
351
|
+
ok: false,
|
|
352
|
+
error: `update URL map ${plan.map.name}: HTTP ${put.status} ${(await put.text()).slice(0, 500)}`,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
type Operation = { name?: string; status?: string; error?: { errors?: { message?: string }[] } };
|
|
356
|
+
let op = (parseJson(await put.text()) ?? {}) as Operation;
|
|
357
|
+
for (let i = 0; op.status !== "DONE" && i < OPERATION_POLLS; i++) {
|
|
358
|
+
await sleep(2_000);
|
|
359
|
+
const poll = await http(`${base}/operations/${encodeURIComponent(op.name ?? "")}`, {
|
|
360
|
+
method: "GET",
|
|
361
|
+
headers,
|
|
362
|
+
});
|
|
363
|
+
if (!poll.ok) return { ok: false, error: `poll ${op.name}: HTTP ${poll.status}` };
|
|
364
|
+
op = (parseJson(await poll.text()) ?? {}) as Operation;
|
|
365
|
+
}
|
|
366
|
+
if (op.status !== "DONE") return { ok: false, error: `URL-map update ${op.name} did not finish` };
|
|
367
|
+
const errors = op.error?.errors ?? [];
|
|
368
|
+
if (errors.length > 0) {
|
|
369
|
+
return {
|
|
370
|
+
ok: false,
|
|
371
|
+
error: `URL-map update failed: ${errors.map((e) => e.message).join("; ")}`,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
return { ok: true, added: plan.added };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Give the gateway's Cloud Armor policy the `/api/mcp/*` rule
|
|
379
|
+
* ({@link MCP_ARMOR_RULE}): create it, or bring a rule at its priority back
|
|
380
|
+
* to spec.
|
|
381
|
+
*/
|
|
382
|
+
export async function ensureMcpArmorRule(
|
|
383
|
+
target: Pick<RemoteMcpTarget, "projectId" | "env">,
|
|
384
|
+
deps: { run?: GcloudRunner } = {},
|
|
385
|
+
): Promise<
|
|
386
|
+
RemoteMcpResult<{ readonly policy: string; readonly action: "present" | "created" | "updated" }>
|
|
387
|
+
> {
|
|
388
|
+
const run = deps.run ?? defaultGcloud;
|
|
389
|
+
const project = `--project=${target.projectId}`;
|
|
390
|
+
const prefix = `${gcpNamePrefix(target.env)}-waf`;
|
|
391
|
+
const list = await run([
|
|
392
|
+
"compute",
|
|
393
|
+
"security-policies",
|
|
394
|
+
"list",
|
|
395
|
+
project,
|
|
396
|
+
`--filter=name~^${prefix}`,
|
|
397
|
+
"--format=value(name)",
|
|
398
|
+
]);
|
|
399
|
+
if (!list.ok) return { ok: false, error: `list security policies: ${list.stderr.trim()}` };
|
|
400
|
+
const names = lines(list.stdout);
|
|
401
|
+
if (names.length !== 1) {
|
|
402
|
+
return {
|
|
403
|
+
ok: false,
|
|
404
|
+
error: `expected exactly one ${prefix}* Cloud Armor policy, found ${names.length}`,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
const policy = names[0] as string;
|
|
408
|
+
const describe = await run([
|
|
409
|
+
"compute",
|
|
410
|
+
"security-policies",
|
|
411
|
+
"describe",
|
|
412
|
+
policy,
|
|
413
|
+
project,
|
|
414
|
+
"--format=json",
|
|
415
|
+
]);
|
|
416
|
+
if (!describe.ok) return { ok: false, error: `describe ${policy}: ${describe.stderr.trim()}` };
|
|
417
|
+
const described = parseJson(describe.stdout) as { rules?: LiveArmorRule[] } | null;
|
|
418
|
+
if (!described) return { ok: false, error: `describe ${policy}: output is not JSON` };
|
|
419
|
+
const rules = described.rules ?? [];
|
|
420
|
+
const existing = rules.find((r) => r.priority === MCP_ARMOR_RULE.priority);
|
|
421
|
+
if (existing && isMcpArmorRule(existing)) return { ok: true, policy, action: "present" };
|
|
422
|
+
const verb = existing ? "update" : "create";
|
|
423
|
+
const r = await run([
|
|
424
|
+
"compute",
|
|
425
|
+
"security-policies",
|
|
426
|
+
"rules",
|
|
427
|
+
verb,
|
|
428
|
+
String(MCP_ARMOR_RULE.priority),
|
|
429
|
+
`--security-policy=${policy}`,
|
|
430
|
+
project,
|
|
431
|
+
...mcpArmorRuleFlags(),
|
|
432
|
+
"--quiet",
|
|
433
|
+
]);
|
|
434
|
+
if (!r.ok)
|
|
435
|
+
return {
|
|
436
|
+
ok: false,
|
|
437
|
+
error: `${verb} rule ${MCP_ARMOR_RULE.priority} on ${policy}: ${r.stderr.trim()}`,
|
|
438
|
+
};
|
|
439
|
+
return { ok: true, policy, action: existing ? "updated" : "created" };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Both load-balancer parts on `gcp`; a no-op success on `gcp-firebase`. */
|
|
443
|
+
export async function ensureRemoteMcpEdge(
|
|
444
|
+
target: Pick<RemoteMcpTarget, "provider" | "projectId" | "env" | "domain">,
|
|
445
|
+
deps: { run?: GcloudRunner; http?: HttpFetch; sleep?: (ms: number) => Promise<void> } = {},
|
|
446
|
+
): Promise<RemoteMcpResult<{ readonly done: string[] }>> {
|
|
447
|
+
if (target.provider !== "gcp") return { ok: true, done: [] };
|
|
448
|
+
const done: string[] = [];
|
|
449
|
+
const armor = await ensureMcpArmorRule(target, deps);
|
|
450
|
+
if (!armor.ok) return { ok: false, error: `Cloud Armor: ${armor.error}` };
|
|
451
|
+
done.push(`Cloud Armor rule ${MCP_ARMOR_RULE.priority} on ${armor.policy}: ${armor.action}`);
|
|
452
|
+
const paths = await ensureUrlMapRemoteMcpPaths(target, deps);
|
|
453
|
+
if (!paths.ok) return { ok: false, error: `URL map: ${paths.error}` };
|
|
454
|
+
done.push(
|
|
455
|
+
paths.added.length > 0
|
|
456
|
+
? `URL map: routed ${paths.added.join(", ")} to the gateway`
|
|
457
|
+
: "URL map: discovery paths already routed to the gateway",
|
|
458
|
+
);
|
|
459
|
+
return { ok: true, done };
|
|
460
|
+
}
|
|
@@ -115,7 +115,7 @@ describe("ensureGeneratedSecrets", () => {
|
|
|
115
115
|
{ ...secretsInstall, replication: regional },
|
|
116
116
|
{ run },
|
|
117
117
|
);
|
|
118
|
-
expect(outcomes.map((o) => o.status)).toEqual(
|
|
118
|
+
expect(outcomes.map((o) => o.status)).toEqual(Array(6).fill("present"));
|
|
119
119
|
expect(lines().some((l) => l.includes(" create ") || l.includes(" add "))).toBe(false);
|
|
120
120
|
});
|
|
121
121
|
|
|
@@ -126,8 +126,11 @@ describe("ensureGeneratedSecrets", () => {
|
|
|
126
126
|
{ ...secretsInstall, replication: regional },
|
|
127
127
|
{ run, generate: () => `generated-value-${++n}` },
|
|
128
128
|
);
|
|
129
|
-
expect(outcomes.map((o) => o.status)).toEqual(
|
|
129
|
+
expect(outcomes.map((o) => o.status)).toEqual(Array(6).fill("applied"));
|
|
130
130
|
const creates = calls.filter((c) => c.line.startsWith("secrets create"));
|
|
131
|
+
// #806 — the seal key is 32 random bytes in base64 (not the injected hex generator).
|
|
132
|
+
const sealKey = creates.find((c) => c.line.includes("mcp-oauth-seal-key"))?.stdin ?? "";
|
|
133
|
+
expect(Buffer.from(sealKey, "base64")).toHaveLength(32);
|
|
131
134
|
expect(creates).toEqual([
|
|
132
135
|
{
|
|
133
136
|
line: "secrets create caelo-production-internal-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
@@ -147,6 +150,16 @@ describe("ensureGeneratedSecrets", () => {
|
|
|
147
150
|
line: "secrets create caelo-production-staging-gate-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
148
151
|
stdin: "generated-value-4",
|
|
149
152
|
},
|
|
153
|
+
// #806 — remote MCP's OAuth client starts as the "not configured"
|
|
154
|
+
// placeholder; the Owner writes the real one from Settings → MCP.
|
|
155
|
+
{
|
|
156
|
+
line: "secrets create caelo-production-mcp-oauth-client --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
157
|
+
stdin: "{}",
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
line: "secrets create caelo-production-mcp-oauth-seal-key --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
161
|
+
stdin: sealKey,
|
|
162
|
+
},
|
|
150
163
|
]);
|
|
151
164
|
for (const c of calls) expect(c.line).not.toContain("generated-value");
|
|
152
165
|
});
|
|
@@ -165,11 +178,12 @@ describe("ensureGeneratedSecrets", () => {
|
|
|
165
178
|
{ run, generate: () => "generated" },
|
|
166
179
|
);
|
|
167
180
|
const creates = calls.filter((c) => c.line.startsWith("secrets create"));
|
|
168
|
-
expect(creates.map((c) => c.stdin)).toEqual([
|
|
181
|
+
expect(creates.map((c) => c.stdin).slice(0, 5)).toEqual([
|
|
169
182
|
"operator-set",
|
|
170
183
|
"generated",
|
|
171
184
|
"generated",
|
|
172
185
|
"generated",
|
|
186
|
+
"{}",
|
|
173
187
|
]);
|
|
174
188
|
for (const c of calls) expect(c.line).not.toContain("operator-set");
|
|
175
189
|
});
|
|
@@ -181,12 +195,21 @@ describe("ensureGeneratedSecrets", () => {
|
|
|
181
195
|
"secrets versions list caelo-production-tool-approval-secret": [ok("v/1")],
|
|
182
196
|
"secrets versions list caelo-production-public-role-password": [ok("v/1")],
|
|
183
197
|
"secrets versions list caelo-production-staging-gate-secret": [ok("v/1")],
|
|
198
|
+
"secrets versions list caelo-production-mcp-oauth-client": [ok("v/1")],
|
|
199
|
+
"secrets versions list caelo-production-mcp-oauth-seal-key": [ok("v/1")],
|
|
184
200
|
});
|
|
185
201
|
const outcomes = await ensureGeneratedSecrets(
|
|
186
202
|
{ ...secretsInstall, replication: { kind: "automatic" } },
|
|
187
203
|
{ run, generate: () => "v" },
|
|
188
204
|
);
|
|
189
|
-
expect(outcomes.map((o) => o.status)).toEqual([
|
|
205
|
+
expect(outcomes.map((o) => o.status)).toEqual([
|
|
206
|
+
"applied",
|
|
207
|
+
"present",
|
|
208
|
+
"present",
|
|
209
|
+
"present",
|
|
210
|
+
"present",
|
|
211
|
+
"present",
|
|
212
|
+
]);
|
|
190
213
|
expect(calls.filter((c) => c.line.startsWith("secrets versions add"))).toEqual([
|
|
191
214
|
{
|
|
192
215
|
line: "secrets versions add caelo-production-internal-secret --project=acme --data-file=-",
|
|
@@ -220,7 +243,7 @@ describe("ensureGeneratedSecrets", () => {
|
|
|
220
243
|
{ ...secretsInstall, replication: { kind: "automatic" } },
|
|
221
244
|
{ run, generate: () => "v" },
|
|
222
245
|
);
|
|
223
|
-
expect(outcomes.map((o) => o.status)).toEqual(
|
|
246
|
+
expect(outcomes.map((o) => o.status)).toEqual(Array(6).fill("applied"));
|
|
224
247
|
expect(outcomes.every((o) => o.detail?.includes("gcp.resourceLocations"))).toBe(true);
|
|
225
248
|
const creates = lines().filter((l) => l.startsWith("secrets create"));
|
|
226
249
|
// One refused automatic attempt; then every secret — the refused one
|
|
@@ -232,6 +255,8 @@ describe("ensureGeneratedSecrets", () => {
|
|
|
232
255
|
"secrets create caelo-production-public-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
233
256
|
// #652 — the staging gate's key follows the same path.
|
|
234
257
|
"secrets create caelo-production-staging-gate-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
258
|
+
"secrets create caelo-production-mcp-oauth-client --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
259
|
+
"secrets create caelo-production-mcp-oauth-seal-key --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
235
260
|
]);
|
|
236
261
|
});
|
|
237
262
|
|
|
@@ -259,7 +284,7 @@ describe("ensureGeneratedSecrets", () => {
|
|
|
259
284
|
{ run, generate: () => "v" },
|
|
260
285
|
);
|
|
261
286
|
expect(outcomes.every((o) => o.status === "failed")).toBe(true);
|
|
262
|
-
expect(lines().filter((l) => l.startsWith("secrets create"))).toHaveLength(
|
|
287
|
+
expect(lines().filter((l) => l.startsWith("secrets create"))).toHaveLength(6);
|
|
263
288
|
});
|
|
264
289
|
});
|
|
265
290
|
|
package/src/runtime-secrets.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type CloudRunSlug,
|
|
30
30
|
DATABASE_ROLE_SECRET,
|
|
31
31
|
type DatabaseRole,
|
|
32
|
+
GENERATED_SECRET_FORMAT,
|
|
32
33
|
type RuntimeSecret,
|
|
33
34
|
SERVICE_SECRET_ENV,
|
|
34
35
|
serviceSecrets,
|
|
@@ -47,6 +48,26 @@ export function generateSecretValue(): string {
|
|
|
47
48
|
return randomBytes(32).toString("hex");
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The first value of a CLI-generated secret, in its format
|
|
53
|
+
* ({@link GENERATED_SECRET_FORMAT}). `generate` makes the hex ones (tests
|
|
54
|
+
* inject it); a placeholder is the fixed value its format names.
|
|
55
|
+
*/
|
|
56
|
+
export function initialSecretValue(
|
|
57
|
+
name: (typeof CLI_GENERATED_SECRETS)[number],
|
|
58
|
+
generate: () => string = generateSecretValue,
|
|
59
|
+
): string {
|
|
60
|
+
const format = GENERATED_SECRET_FORMAT[name];
|
|
61
|
+
switch (format.kind) {
|
|
62
|
+
case "hex32":
|
|
63
|
+
return generate();
|
|
64
|
+
case "base64-32":
|
|
65
|
+
return randomBytes(32).toString("base64");
|
|
66
|
+
case "placeholder":
|
|
67
|
+
return format.value;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
50
71
|
export interface EnsureOutcome {
|
|
51
72
|
readonly id: string;
|
|
52
73
|
readonly status: "present" | "applied" | "failed";
|
|
@@ -282,7 +303,7 @@ export async function ensureGeneratedSecrets(
|
|
|
282
303
|
continue;
|
|
283
304
|
}
|
|
284
305
|
if (!describe.ok) {
|
|
285
|
-
const value = install.seed?.[name] || generate
|
|
306
|
+
const value = install.seed?.[name] || initialSecretValue(name, generate);
|
|
286
307
|
const createWith = (replication: SecretReplication) =>
|
|
287
308
|
run(
|
|
288
309
|
[
|
|
@@ -341,7 +362,12 @@ export async function ensureGeneratedSecrets(
|
|
|
341
362
|
outcomes.push({ id, status: "present" });
|
|
342
363
|
continue;
|
|
343
364
|
}
|
|
344
|
-
const add = await addVersion(
|
|
365
|
+
const add = await addVersion(
|
|
366
|
+
run,
|
|
367
|
+
secretId,
|
|
368
|
+
project,
|
|
369
|
+
install.seed?.[name] || initialSecretValue(name, generate),
|
|
370
|
+
);
|
|
345
371
|
outcomes.push(add.ok ? { id, status: "applied" } : { id, status: "failed", error: add.error });
|
|
346
372
|
}
|
|
347
373
|
return outcomes;
|
|
@@ -375,6 +401,13 @@ export const ROTATABLE_SECRETS = [
|
|
|
375
401
|
] as const satisfies readonly RuntimeSecret[];
|
|
376
402
|
export type RotatableSecret = (typeof ROTATABLE_SECRETS)[number];
|
|
377
403
|
|
|
404
|
+
/**
|
|
405
|
+
* #806 — the remote-MCP seal key rotates in two steps with an overlap
|
|
406
|
+
* window (`rotate-secret mcp-oauth-seal-key`, then `--finish`), not through
|
|
407
|
+
* {@link rotateRuntimeSecret}: see seal-key-rotation.ts.
|
|
408
|
+
*/
|
|
409
|
+
export const SEAL_KEY_SECRET = "mcp-oauth-seal-key" satisfies RuntimeSecret;
|
|
410
|
+
|
|
378
411
|
/**
|
|
379
412
|
* Why `name` cannot be rotated, or `null` when it can.
|
|
380
413
|
*
|
|
@@ -384,10 +417,15 @@ export type RotatableSecret = (typeof ROTATABLE_SECRETS)[number];
|
|
|
384
417
|
*/
|
|
385
418
|
export function rotationRefusal(name: string): string | null {
|
|
386
419
|
if ((ROTATABLE_SECRETS as readonly string[]).includes(name)) return null;
|
|
420
|
+
// #806 — two-step rotation with an overlap window (seal-key-rotation.ts).
|
|
421
|
+
if (name === SEAL_KEY_SECRET) return null;
|
|
422
|
+
if (name === "mcp-oauth-client") {
|
|
423
|
+
return "mcp-oauth-client is the Google OAuth client of remote MCP; the Owner replaces it under Settings → MCP → Remote (create a new client secret in the Google console first).";
|
|
424
|
+
}
|
|
387
425
|
if (name === "secret-kek") {
|
|
388
426
|
return "secret-kek encrypts the AI provider keys and other at-rest secrets in cms_admin; a new KEK would make them unreadable. Rotating it needs a re-encryption step that does not exist yet.";
|
|
389
427
|
}
|
|
390
|
-
return `${name} is not a runtime secret of this install. Rotatable: ${ROTATABLE_SECRETS.join(", ")}.`;
|
|
428
|
+
return `${name} is not a runtime secret of this install. Rotatable: ${[...ROTATABLE_SECRETS, SEAL_KEY_SECRET].join(", ")}.`;
|
|
391
429
|
}
|
|
392
430
|
|
|
393
431
|
/** The deployed install `rotateRuntimeSecret` works on. */
|