@caelo-cms/provisioning 0.11.9 → 0.11.10
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/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 +6 -1
- package/dist/adopt-existing.d.ts.map +1 -1
- package/dist/adopt-existing.js +86 -0
- package/dist/adopt-existing.js.map +1 -1
- package/dist/agent/command.d.ts +20 -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 +44 -2
- 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.js +12 -3
- package/dist/cli.js.map +1 -1
- package/dist/cloudsql-tls.d.ts +63 -0
- package/dist/cloudsql-tls.d.ts.map +1 -0
- package/dist/cloudsql-tls.js +289 -0
- package/dist/cloudsql-tls.js.map +1 -0
- package/dist/compose.d.ts +29 -6
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +95 -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-job-gcp.d.ts.map +1 -1
- package/dist/converge-job-gcp.js +2 -11
- package/dist/converge-job-gcp.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 +21 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +24 -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 +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -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 +24 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-state.d.ts +6 -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 +17 -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/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 +9 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +21 -10
- package/dist/runtime-secrets.js.map +1 -1
- 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 +34 -0
- package/dist/self-hosted-roles.d.ts.map +1 -1
- package/dist/self-hosted-roles.js +52 -0
- package/dist/self-hosted-roles.js.map +1 -1
- 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 +15 -6
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +72 -23
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +170 -25
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +314 -37
- 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/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.map +1 -1
- package/dist/upgrade/converge.js +99 -12
- 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 +13 -3
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +34 -2
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-gcp.d.ts +12 -0
- package/dist/upgrader-gcp.d.ts.map +1 -1
- package/dist/upgrader-gcp.js +49 -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/wizards/gcp-cost.d.ts.map +1 -1
- package/dist/wizards/gcp-cost.js +22 -7
- package/dist/wizards/gcp-cost.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +43 -17
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/admin-domain.test.ts +9 -8
- package/src/admin-domain.ts +13 -3
- package/src/admin-memory.test.ts +1 -0
- package/src/adopt-existing.test.ts +39 -0
- package/src/adopt-existing.ts +83 -1
- package/src/agent/command.test.ts +49 -1
- package/src/agent/command.ts +36 -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 +3 -0
- 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.ts +12 -1
- package/src/cloudsql-tls.test.ts +225 -0
- package/src/cloudsql-tls.ts +352 -0
- package/src/compose.ts +125 -24
- package/src/converge-checkpoint.ts +8 -1
- package/src/converge-job-gcp.ts +2 -11
- package/src/database-tls-builders.test.ts +55 -0
- package/src/deployed-release.test.ts +104 -40
- package/src/deployed-release.ts +30 -9
- package/src/gateway-env-isolation.test.ts +10 -5
- package/src/gcp-names.ts +31 -0
- package/src/gcp-url-map.test.ts +6 -8
- package/src/gcp-url-map.ts +6 -2
- package/src/index.test.ts +7 -1
- package/src/index.ts +1 -0
- package/src/infra-contract.test.ts +5 -3
- package/src/infra-contract.ts +24 -1
- package/src/install-state.ts +26 -4
- package/src/lifecycle.ts +20 -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/operator-access-stacks.test.ts +1 -1
- package/src/operator-access.test.ts +5 -4
- package/src/pool-budget-stacks.test.ts +1 -1
- 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 +20 -7
- package/src/runtime-secrets.ts +50 -9
- package/src/seal-key-rotation.test.ts +2 -2
- package/src/seal-key-rotation.ts +25 -25
- package/src/self-hosted-compose.test.ts +108 -5
- 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.test.ts +54 -0
- package/src/self-hosted-roles.ts +58 -0
- package/src/self-hosted-start.test.ts +34 -2
- package/src/self-hosted-start.ts +20 -2
- package/src/self-hosted-upgrade.test.ts +103 -14
- package/src/self-hosted-upgrade.ts +115 -29
- package/src/stack-contract-parity.test.ts +79 -12
- package/src/stack-contract.test.ts +169 -29
- package/src/stack-contract.ts +362 -42
- package/src/stack-converge-secrets.test.ts +26 -13
- package/src/stack-converge.test.ts +199 -26
- package/src/stack-converge.ts +92 -30
- package/src/state-store.test.ts +2 -1
- package/src/upgrade/app-roll.ts +97 -19
- package/src/upgrade/converge.ts +120 -11
- 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 +353 -44
- package/src/upgrade/upgrade.ts +55 -6
- package/src/upgrader-gcp.ts +65 -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/wizards/gcp-cost.test.ts +50 -0
- package/src/wizards/gcp-cost.ts +24 -7
- package/src/wizards/gcp.ts +55 -14
- package/stacks/aws/index.ts +32 -14
- package/stacks/azure/index.ts +57 -18
- package/stacks/gcp/index.ts +83 -23
- package/stacks/gcp-firebase/index.ts +162 -33
- package/stacks/self-hosted/README.md +2 -1
- package/stacks/self-hosted/index.ts +11 -1
package/src/infra-contract.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* whose app-roll references infrastructure an install does not have yet fails
|
|
23
23
|
* at roll time, after migrations ran.
|
|
24
24
|
*/
|
|
25
|
-
export const INFRA_CONTRACT_VERSION =
|
|
25
|
+
export const INFRA_CONTRACT_VERSION = 16;
|
|
26
26
|
|
|
27
27
|
// History (why each bump happened — an install below a number needs the
|
|
28
28
|
// converge that introduced it):
|
|
@@ -110,6 +110,27 @@ export const INFRA_CONTRACT_VERSION = 14;
|
|
|
110
110
|
// CAELO_CAPTURE_BUCKET / CAELO_MEDIA_BUCKET on the admin. The new admin
|
|
111
111
|
// runs no browser itself on GCP and the app-roll moves the job's image,
|
|
112
112
|
// so it must exist before an in-cloud upgrader rolls onto it.
|
|
113
|
+
// 15 — #1338: verify-full TLS to Cloud SQL. The instance moves to the
|
|
114
|
+
// shared server CA (a patch plus a server-certificate rotation) and
|
|
115
|
+
// refuses plaintext, a private Cloud DNS zone resolves its DNS name in
|
|
116
|
+
// the VPC, and the services dial that name (cloudsql-tls.ts). The new
|
|
117
|
+
// images refuse any database URL weaker than verify-full, and the
|
|
118
|
+
// in-cloud upgrader may neither patch the instance nor create a DNS
|
|
119
|
+
// zone, so it must not roll an install onto them first.
|
|
120
|
+
// 16 — #1328: the MCP-Auth-Proxy, remote MCP's broker as a service of its
|
|
121
|
+
// own (no database, its own image). Its service account with
|
|
122
|
+
// secretAccessor on exactly the Google sign-in client and the seal
|
|
123
|
+
// key, and the upgrader's actAs on it. On `gcp-firebase` the visitor
|
|
124
|
+
// gateway moves to a new Cloud Run service (`caelo-<env>-data-gateway`,
|
|
125
|
+
// public invoker, the admin's run.viewer for the Firebase rewrite) and
|
|
126
|
+
// the old `gateway` service, whose run.app URL every connector holds
|
|
127
|
+
// (E13 a), becomes the proxy: new image, new account, no VPC, no
|
|
128
|
+
// database env. On `gcp` the proxy is a new service behind its own
|
|
129
|
+
// serverless NEG, backend service and Cloud Armor policy (the MCP rule
|
|
130
|
+
// moves there) and the URL map sends the MCP paths to it. New
|
|
131
|
+
// services, accounts, IAM and load-balancer resources — none of which
|
|
132
|
+
// the in-cloud upgrader may create, and the app-roll rolls three
|
|
133
|
+
// services from now on.
|
|
113
134
|
|
|
114
135
|
/**
|
|
115
136
|
* What each contract version adds, in operator terms — the upgrader's
|
|
@@ -131,6 +152,8 @@ export const INFRA_CONTRACT_STEPS: Readonly<Record<number, string>> = {
|
|
|
131
152
|
12: "Google sign-in at project level: the operator-access job's permission to set the sign-in client in the project's IAP settings (#914)",
|
|
132
153
|
13: "the public site's security headers at the edge: nosniff on every file, uploaded SVGs no longer run as documents on the site's origin, and a one-time CDN purge (#891)",
|
|
133
154
|
14: "the capture job that takes server-side screenshots outside the admin, its service account, bucket and grants (#1217)",
|
|
155
|
+
15: "verified TLS to the database: Cloud SQL's shared server CA, encrypted connections only, and a private DNS name the services dial (#1338)",
|
|
156
|
+
16: "the MCP sign-in service as its own Cloud Run service with its own account (no database access), the visitor gateway as its own service on Firebase installs, and on Google Cloud load-balancer installs the sign-in service's backend and Cloud Armor rule (#1328)",
|
|
134
157
|
};
|
|
135
158
|
|
|
136
159
|
/**
|
package/src/install-state.ts
CHANGED
|
@@ -155,10 +155,25 @@ export function recordableStagingHost(host: string): string | null {
|
|
|
155
155
|
export interface ImageDigests {
|
|
156
156
|
readonly admin: string;
|
|
157
157
|
readonly gateway: string;
|
|
158
|
+
/**
|
|
159
|
+
* #1328 — the MCP-Auth-Proxy's. Absent on installs recorded before it
|
|
160
|
+
* existed: their first upgrade to a release with it records it, and a
|
|
161
|
+
* stack run without it deploys the release tag's proxy image.
|
|
162
|
+
*/
|
|
163
|
+
readonly "mcp-auth-proxy"?: string;
|
|
158
164
|
}
|
|
159
165
|
|
|
160
166
|
const DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
161
167
|
|
|
168
|
+
/** {@link ImageDigests} without the keys that are absent (a JSON-stable copy). */
|
|
169
|
+
function imageDigestsRecord(d: ImageDigests): ImageDigests {
|
|
170
|
+
return {
|
|
171
|
+
admin: d.admin,
|
|
172
|
+
gateway: d.gateway,
|
|
173
|
+
...(d["mcp-auth-proxy"] ? { "mcp-auth-proxy": d["mcp-auth-proxy"] } : {}),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
162
177
|
/**
|
|
163
178
|
* The image digests recorded for an install, or null when none are. Throws
|
|
164
179
|
* on a malformed record rather than deploying a guess.
|
|
@@ -166,7 +181,12 @@ const DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
|
166
181
|
export function recordedImageDigests(meta: InstallMetadata): ImageDigests | null {
|
|
167
182
|
const d = meta.imageDigests;
|
|
168
183
|
if (d === undefined) return null;
|
|
169
|
-
|
|
184
|
+
const proxy = d["mcp-auth-proxy"];
|
|
185
|
+
if (
|
|
186
|
+
!DIGEST.test(d.admin ?? "") ||
|
|
187
|
+
!DIGEST.test(d.gateway ?? "") ||
|
|
188
|
+
(proxy !== undefined && !DIGEST.test(proxy))
|
|
189
|
+
) {
|
|
170
190
|
throw new Error(
|
|
171
191
|
`install.json imageDigests is malformed (${JSON.stringify(d)}); fix or remove it, then re-run.`,
|
|
172
192
|
);
|
|
@@ -211,15 +231,17 @@ export function recordImageDigests(
|
|
|
211
231
|
// drop it — a stale one would be worse than none (the upgrader job
|
|
212
232
|
// refuses on none and judges downgrades by it).
|
|
213
233
|
const same =
|
|
214
|
-
meta.imageDigests?.admin === digests.admin &&
|
|
234
|
+
meta.imageDigests?.admin === digests.admin &&
|
|
235
|
+
meta.imageDigests?.gateway === digests.gateway &&
|
|
236
|
+
meta.imageDigests?.["mcp-auth-proxy"] === digests["mcp-auth-proxy"];
|
|
215
237
|
const { releaseVersion: previous, platformDigests: previousPlatform, ...rest } = meta;
|
|
216
238
|
const version = releaseVersion ?? (same ? previous : undefined);
|
|
217
239
|
// The platform forms belong to their indexes just as the version does.
|
|
218
240
|
const platform = platformDigests ?? (same ? previousPlatform : undefined);
|
|
219
241
|
writeMetadata(installId, {
|
|
220
242
|
...rest,
|
|
221
|
-
imageDigests:
|
|
222
|
-
...(platform ? { platformDigests:
|
|
243
|
+
imageDigests: imageDigestsRecord(digests),
|
|
244
|
+
...(platform ? { platformDigests: imageDigestsRecord(platform) } : {}),
|
|
223
245
|
...(version ? { releaseVersion: version } : {}),
|
|
224
246
|
});
|
|
225
247
|
}
|
package/src/lifecycle.ts
CHANGED
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
} from "./runtime-secrets.js";
|
|
61
61
|
import { finishSealKeyRotation, startSealKeyRotation } from "./seal-key-rotation.js";
|
|
62
62
|
import { selfCommand } from "./self-command.js";
|
|
63
|
+
import { CLOUD_RUN_SLUGS, type CloudRunSlug, cloudRunServicePrefix } from "./stack-contract.js";
|
|
63
64
|
import {
|
|
64
65
|
backendShellEnv,
|
|
65
66
|
localStateNotice,
|
|
@@ -720,6 +721,7 @@ async function gcpHealthProbe(
|
|
|
720
721
|
domain: install.domain,
|
|
721
722
|
adminServiceUrl: urls.admin as string,
|
|
722
723
|
gatewayServiceUrl: urls.gateway as string,
|
|
724
|
+
mcpAuthProxyServiceUrl: urls["mcp-auth-proxy"] as string,
|
|
723
725
|
},
|
|
724
726
|
{ run, http, sleep: realSleep, accessJustChanged: context.accessJustChanged },
|
|
725
727
|
),
|
|
@@ -880,23 +882,29 @@ export async function rotateSecretCommand(
|
|
|
880
882
|
}
|
|
881
883
|
|
|
882
884
|
if (name === SEAL_KEY_SECRET) {
|
|
883
|
-
|
|
885
|
+
// #1328 — the broker, and with it the seal key, lives in the MCP-Auth-Proxy.
|
|
886
|
+
const proxyPrefix = cloudRunServicePrefix(meta.provider, GCP_STACK_ENV, "mcp-auth-proxy");
|
|
887
|
+
const proxy = await resolveGcpResourceName(
|
|
884
888
|
gcloud,
|
|
885
889
|
"run-service",
|
|
886
|
-
|
|
890
|
+
proxyPrefix,
|
|
887
891
|
meta.projectId,
|
|
888
892
|
region,
|
|
889
893
|
);
|
|
890
|
-
if (
|
|
894
|
+
if (proxy.status !== "found") {
|
|
891
895
|
log.error(
|
|
892
896
|
red(
|
|
893
|
-
`${lookupFailure(
|
|
897
|
+
`${lookupFailure(proxy, `${proxyPrefix}* Cloud Run service`, "operator")} — nothing rotated`,
|
|
894
898
|
),
|
|
895
899
|
);
|
|
896
900
|
return;
|
|
897
901
|
}
|
|
898
|
-
const
|
|
899
|
-
|
|
902
|
+
const target = {
|
|
903
|
+
projectId: meta.projectId,
|
|
904
|
+
region,
|
|
905
|
+
env: GCP_STACK_ENV,
|
|
906
|
+
proxyService: proxy.name,
|
|
907
|
+
};
|
|
900
908
|
const report = opts.finish
|
|
901
909
|
? await finishSealKeyRotation(target, {}, { now: opts.now === true })
|
|
902
910
|
: await startSealKeyRotation(target);
|
|
@@ -910,19 +918,20 @@ export async function rotateSecretCommand(
|
|
|
910
918
|
|
|
911
919
|
const s = spinner();
|
|
912
920
|
s.start(`Rotating ${secret}...`);
|
|
913
|
-
const services: Partial<Record<
|
|
914
|
-
for (const slug of
|
|
921
|
+
const services: Partial<Record<CloudRunSlug, string>> = {};
|
|
922
|
+
for (const slug of CLOUD_RUN_SLUGS) {
|
|
923
|
+
const prefix = cloudRunServicePrefix(meta.provider, GCP_STACK_ENV, slug);
|
|
915
924
|
const service = await resolveGcpResourceName(
|
|
916
925
|
gcloud,
|
|
917
926
|
"run-service",
|
|
918
|
-
|
|
927
|
+
prefix,
|
|
919
928
|
meta.projectId,
|
|
920
929
|
region,
|
|
921
930
|
);
|
|
922
931
|
if (service.status !== "found") {
|
|
923
932
|
s.stop(
|
|
924
933
|
red(
|
|
925
|
-
`${lookupFailure(service,
|
|
934
|
+
`${lookupFailure(service, `${prefix}* Cloud Run service`, "operator")} — nothing rotated`,
|
|
926
935
|
),
|
|
927
936
|
);
|
|
928
937
|
return;
|
|
@@ -952,7 +961,7 @@ export async function rotateSecretCommand(
|
|
|
952
961
|
projectId: meta.projectId,
|
|
953
962
|
region,
|
|
954
963
|
env: GCP_STACK_ENV,
|
|
955
|
-
services: services as Record<
|
|
964
|
+
services: services as Record<CloudRunSlug, string>,
|
|
956
965
|
...(sqlInstance ? { sqlInstance } : {}),
|
|
957
966
|
},
|
|
958
967
|
secret,
|
|
@@ -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
|
+
});
|