@caelo-cms/provisioning 0.10.55 → 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 +76 -19
- package/dist/cli-routing.js +1 -1
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +4 -1
- 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 +9 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +13 -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 +22 -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/release-contract.d.ts +3 -2
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +5 -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 +167 -3
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +171 -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/upgrade/app-roll.d.ts +36 -7
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +53 -19
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +111 -9
- 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 +123 -11
- package/dist/upgrader-setup.js.map +1 -1
- package/dist/wizards/gcp-cost.d.ts.map +1 -1
- package/dist/wizards/gcp-cost.js +11 -3
- package/dist/wizards/gcp-cost.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +99 -4
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/agent/run.test.ts +14 -1
- package/src/cli-routing.ts +1 -1
- package/src/cli.ts +4 -1
- 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 +15 -0
- 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 +22 -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/release-contract.ts +5 -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 +30 -2
- package/src/stack-contract.ts +291 -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/upgrade/app-roll.ts +96 -21
- package/src/upgrade/converge.ts +130 -8
- 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 +203 -3
- package/src/upgrade/upgrade.ts +120 -24
- package/src/upgrader-assess.test.ts +92 -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 +586 -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 +3 -3
- package/src/upgrader-setup.ts +128 -11
- package/src/wizards/gcp-cost.ts +12 -3
- package/src/wizards/gcp.ts +125 -4
- package/stacks/aws/index.ts +10 -0
- package/stacks/azure/index.ts +9 -0
- package/stacks/gcp/index.ts +46 -7
- package/stacks/gcp-firebase/index.ts +37 -6
package/src/stack-contract.ts
CHANGED
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
import type { SupportedProvider } from "./adapter.js";
|
|
38
38
|
import {
|
|
39
39
|
auditJobResource,
|
|
40
|
+
convergeJobResource,
|
|
40
41
|
gatewayServiceAccountEmail,
|
|
41
42
|
gcpBucketName,
|
|
42
43
|
gcpNamePrefix,
|
|
@@ -170,6 +171,13 @@ export const OPERATOR_ACCESS_JOB_ENV_VAR = "CAELO_OPERATOR_ACCESS_JOB";
|
|
|
170
171
|
*/
|
|
171
172
|
export const UPGRADER_JOB_ENV_VAR = "CAELO_UPGRADER_JOB";
|
|
172
173
|
|
|
174
|
+
/**
|
|
175
|
+
* The admin env var naming the converge job (#819) — what "Install update"
|
|
176
|
+
* starts for a release that changes the cloud setup. `upgrade` leaves it
|
|
177
|
+
* untouched when it could not set the job up.
|
|
178
|
+
*/
|
|
179
|
+
export const CONVERGE_JOB_ENV_VAR = "CAELO_CONVERGE_JOB";
|
|
180
|
+
|
|
173
181
|
/**
|
|
174
182
|
* #875 — the admin env vars naming the parallel quality-audit job and the
|
|
175
183
|
* bucket its tasks write results into (admin-core quality/
|
|
@@ -251,7 +259,19 @@ export type RuntimeSecret =
|
|
|
251
259
|
| "secret-kek"
|
|
252
260
|
| "internal-secret"
|
|
253
261
|
| "tool-approval-secret"
|
|
254
|
-
| "staging-gate-secret"
|
|
262
|
+
| "staging-gate-secret"
|
|
263
|
+
/**
|
|
264
|
+
* #806 — the Google OAuth client the remote-MCP broker signs users in
|
|
265
|
+
* with: JSON `{"client_id","client_secret"}`, `{}` while remote MCP is not
|
|
266
|
+
* set up. The Owner writes it from Settings → MCP → Remote (the admin may
|
|
267
|
+
* add versions, never read them); the gateway reads it from a file.
|
|
268
|
+
*/
|
|
269
|
+
| "mcp-oauth-client"
|
|
270
|
+
/**
|
|
271
|
+
* #806 — the broker's AES-256-GCM key (base64, 32 bytes) that seals its
|
|
272
|
+
* stateless OAuth state, codes and refresh tokens. Gateway only.
|
|
273
|
+
*/
|
|
274
|
+
| "mcp-oauth-seal-key";
|
|
255
275
|
|
|
256
276
|
/** The Postgres roles Caelo's services log in as, with their passwords. */
|
|
257
277
|
export type DatabaseRole = "admin_role" | "public_role";
|
|
@@ -285,8 +305,38 @@ export const CLI_GENERATED_SECRETS = [
|
|
|
285
305
|
// session cookies, derives the crawler credentials. Read by the admin
|
|
286
306
|
// (issues) and the gateway (verifies).
|
|
287
307
|
"staging-gate-secret",
|
|
308
|
+
// #806 — remote MCP. The client starts as the `{}` placeholder (not
|
|
309
|
+
// configured; the Owner fills it under Settings → MCP), the seal key as
|
|
310
|
+
// 32 random bytes in base64 ({@link GENERATED_SECRET_FORMAT}).
|
|
311
|
+
"mcp-oauth-client",
|
|
312
|
+
"mcp-oauth-seal-key",
|
|
288
313
|
] as const satisfies readonly RuntimeSecret[];
|
|
289
314
|
|
|
315
|
+
/** A CLI-generated secret's first value: random hex, random base64, or a fixed placeholder. */
|
|
316
|
+
export type GeneratedSecretFormat =
|
|
317
|
+
| { readonly kind: "hex32" }
|
|
318
|
+
| { readonly kind: "base64-32" }
|
|
319
|
+
| { readonly kind: "placeholder"; readonly value: string };
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* How each CLI-generated secret's first version is made. Every secret not
|
|
323
|
+
* listed is 32 random bytes as hex — the shape the stacks always generated.
|
|
324
|
+
*/
|
|
325
|
+
export const GENERATED_SECRET_FORMAT: Readonly<
|
|
326
|
+
Record<(typeof CLI_GENERATED_SECRETS)[number], GeneratedSecretFormat>
|
|
327
|
+
> = {
|
|
328
|
+
"internal-secret": { kind: "hex32" },
|
|
329
|
+
"tool-approval-secret": { kind: "hex32" },
|
|
330
|
+
"public-role-password": { kind: "hex32" },
|
|
331
|
+
"staging-gate-secret": { kind: "hex32" },
|
|
332
|
+
// The gateway's seal key contract (#806): base64 of exactly 32 bytes.
|
|
333
|
+
"mcp-oauth-seal-key": { kind: "base64-32" },
|
|
334
|
+
// "Not configured" until the Owner pastes a client (#806). A version must
|
|
335
|
+
// exist from the start: Cloud Run refuses a revision whose secret volume
|
|
336
|
+
// points at a secret without an enabled version.
|
|
337
|
+
"mcp-oauth-client": { kind: "placeholder", value: "{}" },
|
|
338
|
+
};
|
|
339
|
+
|
|
290
340
|
/**
|
|
291
341
|
* Each service's Secret Manager-backed env vars and the secret each reads.
|
|
292
342
|
* The services' run SAs get `secretAccessor` on exactly these secrets
|
|
@@ -316,12 +366,96 @@ export const SERVICE_SECRET_ENV: Readonly<
|
|
|
316
366
|
PUBLIC_DATABASE_PASSWORD: "public-role-password",
|
|
317
367
|
// #650 — verifies staging-host requests (api-gateway staging-proxy.ts).
|
|
318
368
|
CAELO_STAGING_GATE_SECRET: "staging-gate-secret",
|
|
369
|
+
// #806 — seals the remote-MCP broker's state, codes and refresh tokens.
|
|
370
|
+
CAELO_MCP_OAUTH_SEAL_KEY: "mcp-oauth-seal-key",
|
|
319
371
|
},
|
|
320
372
|
};
|
|
321
373
|
|
|
322
|
-
|
|
374
|
+
// ===========================================================================
|
|
375
|
+
// Secret files (#806)
|
|
376
|
+
// ===========================================================================
|
|
377
|
+
|
|
378
|
+
/** Env var naming the file that holds the remote-MCP Google OAuth client. */
|
|
379
|
+
export const MCP_OAUTH_CLIENT_FILE_ENV = "CAELO_MCP_OAUTH_CLIENT_FILE";
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* A Secret Manager secret mounted into a service as one file, at `latest`.
|
|
383
|
+
*
|
|
384
|
+
* Why a file and not an env var: Cloud Run resolves a secret env var once,
|
|
385
|
+
* at instance start, but reads a secret volume from Secret Manager each
|
|
386
|
+
* time the file is read ("When reading a volume, Cloud Run always fetches
|
|
387
|
+
* the secret value from the Secret Manager to use the value with the latest
|
|
388
|
+
* version" — cloud.google.com/run/docs/configuring/services/secrets). So the
|
|
389
|
+
* client the Owner saves under Settings → MCP reaches the running gateway
|
|
390
|
+
* (which re-reads it within 60 s) without a new revision, and the admin
|
|
391
|
+
* needs no right to deploy the gateway.
|
|
392
|
+
*/
|
|
393
|
+
export interface SecretFileContract {
|
|
394
|
+
readonly secret: RuntimeSecret;
|
|
395
|
+
/** Volume name the stacks give it (gcloud names the ones upgrade adds itself). */
|
|
396
|
+
readonly volumeName: string;
|
|
397
|
+
/** Directory the volume is mounted at; must not exist in the image. */
|
|
398
|
+
readonly mountDir: string;
|
|
399
|
+
readonly fileName: string;
|
|
400
|
+
/** Always `latest`: a pinned version would never see the Owner's new client. */
|
|
401
|
+
readonly version: "latest";
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** The gateway's remote-MCP OAuth client file (#806). */
|
|
405
|
+
export const MCP_OAUTH_CLIENT_FILE: SecretFileContract = {
|
|
406
|
+
secret: "mcp-oauth-client",
|
|
407
|
+
volumeName: "mcp-oauth-client",
|
|
408
|
+
mountDir: "/secrets/mcp-oauth-client",
|
|
409
|
+
fileName: "client.json",
|
|
410
|
+
version: "latest",
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
/** Full path of a secret file inside the container. */
|
|
414
|
+
export function secretFilePath(file: SecretFileContract): string {
|
|
415
|
+
return `${file.mountDir}/${file.fileName}`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Each service's secret files. The admin has none. */
|
|
419
|
+
export const SERVICE_SECRET_FILES: Readonly<Record<CloudRunSlug, readonly SecretFileContract[]>> = {
|
|
420
|
+
admin: [],
|
|
421
|
+
gateway: [MCP_OAUTH_CLIENT_FILE],
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The secret files of `service` in the shape `gcp.cloudrunv2.Service`
|
|
426
|
+
* takes: `volumes` on the template, `volumeMounts` on the container.
|
|
427
|
+
*/
|
|
428
|
+
export function secretFileVolumesTemplate(
|
|
429
|
+
service: CloudRunSlug,
|
|
430
|
+
env: string,
|
|
431
|
+
): {
|
|
432
|
+
readonly volumes: {
|
|
433
|
+
name: string;
|
|
434
|
+
secret: { secret: string; items: { version: string; path: string }[] };
|
|
435
|
+
}[];
|
|
436
|
+
readonly volumeMounts: { name: string; mountPath: string }[];
|
|
437
|
+
} {
|
|
438
|
+
const files = SERVICE_SECRET_FILES[service];
|
|
439
|
+
return {
|
|
440
|
+
volumes: files.map((f) => ({
|
|
441
|
+
name: f.volumeName,
|
|
442
|
+
secret: {
|
|
443
|
+
secret: gcpSecretId(env, f.secret),
|
|
444
|
+
items: [{ version: f.version, path: f.fileName }],
|
|
445
|
+
},
|
|
446
|
+
})),
|
|
447
|
+
volumeMounts: files.map((f) => ({ name: f.volumeName, mountPath: f.mountDir })),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** The secrets `service` reads, as env or as files (deduplicated, table order). */
|
|
323
452
|
export function serviceSecrets(service: CloudRunSlug): RuntimeSecret[] {
|
|
324
|
-
return [
|
|
453
|
+
return [
|
|
454
|
+
...new Set([
|
|
455
|
+
...Object.values(SERVICE_SECRET_ENV[service]),
|
|
456
|
+
...SERVICE_SECRET_FILES[service].map((f) => f.secret),
|
|
457
|
+
]),
|
|
458
|
+
];
|
|
325
459
|
}
|
|
326
460
|
|
|
327
461
|
/**
|
|
@@ -411,10 +545,139 @@ export function gatewayEnvContract<V>(inputs: RuntimeEnvInputs<V>): CloudRunEnvV
|
|
|
411
545
|
...commonEnv(inputs),
|
|
412
546
|
{ name: TRUSTED_PROXY_HOPS_ENV, value: String(GATEWAY_TRUSTED_PROXY_HOPS[inputs.provider]) },
|
|
413
547
|
{ name: "PUBLIC_DATABASE_URL", value: inputs.databaseUrls.public },
|
|
548
|
+
// #806 — where the remote-MCP broker reads its Google OAuth client
|
|
549
|
+
// (the secret volume of SERVICE_SECRET_FILES).
|
|
550
|
+
{ name: MCP_OAUTH_CLIENT_FILE_ENV, value: secretFilePath(MCP_OAUTH_CLIENT_FILE) },
|
|
414
551
|
...secretEnv("gateway", inputs.env),
|
|
415
552
|
];
|
|
416
553
|
}
|
|
417
554
|
|
|
555
|
+
/**
|
|
556
|
+
* #806 — the gateway's request timeout, in seconds: Cloud Run's maximum.
|
|
557
|
+
* The gateway proxies remote-MCP requests to the admin, and a `caelo_chat`
|
|
558
|
+
* turn streams for as long as the admin's own chat stream may (the admin
|
|
559
|
+
* runs at 3600 s for the same reason). Visitor endpoints are unaffected: a
|
|
560
|
+
* timeout is a ceiling, not a reservation, and Cloud Run bills request time,
|
|
561
|
+
* not the allowed maximum. On `gcp` the load balancer in front adds no
|
|
562
|
+
* shorter limit — the backend-service timeout does not apply to serverless
|
|
563
|
+
* NEGs, which get a fixed 60 minutes
|
|
564
|
+
* (cloud.google.com/load-balancing/docs/negs/serverless-neg-concepts).
|
|
565
|
+
*/
|
|
566
|
+
export const GATEWAY_TIMEOUT_SECONDS = 3600;
|
|
567
|
+
|
|
568
|
+
/** Admin env var naming the Secret Manager secret the Owner writes the OAuth client to. */
|
|
569
|
+
export const MCP_OAUTH_CLIENT_STORE_ENV = "CAELO_MCP_OAUTH_CLIENT_STORE";
|
|
570
|
+
|
|
571
|
+
/** `projects/<project>/secrets/<id>` of the remote-MCP OAuth client secret. */
|
|
572
|
+
export function mcpOAuthClientSecretResource(projectId: string, env: string): string {
|
|
573
|
+
return `projects/${projectId}/secrets/${gcpSecretId(env, "mcp-oauth-client")}`;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ===========================================================================
|
|
577
|
+
// Remote MCP identity env (#806)
|
|
578
|
+
// ===========================================================================
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* The remote-MCP env vars that name generated identities of the install:
|
|
582
|
+
* the admin's URL, the gateway's public origin, and the audience of the IAP
|
|
583
|
+
* JWTs the admin verifies.
|
|
584
|
+
*
|
|
585
|
+
* They are not part of {@link adminEnvContract} / {@link gatewayEnvContract}
|
|
586
|
+
* because the stacks cannot declare them: on `gcp-firebase` each service's
|
|
587
|
+
* value names a Cloud Run service Pulumi auto-names (the admin's audience is
|
|
588
|
+
* its own name; the gateway's public URL is its own URL; the admin's URL is
|
|
589
|
+
* a service created after the gateway), and on `gcp` the audience is the
|
|
590
|
+
* numeric id of a backend service created after the admin. A resource
|
|
591
|
+
* cannot take its own (or a later resource's) output as input. So the CLI
|
|
592
|
+
* reads the live identities after deploy and applies these vars with gcloud
|
|
593
|
+
* — the wizard after every `pulumi up`, `upgrade` after every roll
|
|
594
|
+
* (remote-mcp.ts `ensureRemoteMcpIdentityEnv`). A roll that does not set
|
|
595
|
+
* them leaves them as they are (planEnvUpdate never removes a var the
|
|
596
|
+
* contract does not own).
|
|
597
|
+
*/
|
|
598
|
+
export interface RemoteMcpIdentity {
|
|
599
|
+
/** Where the gateway proxies `/api/mcp/<surface>` to (IAP in front). */
|
|
600
|
+
readonly adminUrl: string;
|
|
601
|
+
/** The broker's public origin; no trailing slash. */
|
|
602
|
+
readonly mcpPublicUrl: string;
|
|
603
|
+
/** The `aud` of the admin's IAP JWTs. */
|
|
604
|
+
readonly iapAudience: string;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Env var names of {@link RemoteMcpIdentity}. */
|
|
608
|
+
export const REMOTE_MCP_ENV = {
|
|
609
|
+
adminUrl: "CAELO_ADMIN_URL",
|
|
610
|
+
mcpPublicUrl: "CAELO_MCP_PUBLIC_URL",
|
|
611
|
+
iapAudience: "CAELO_IAP_AUDIENCE",
|
|
612
|
+
} as const;
|
|
613
|
+
|
|
614
|
+
/** What {@link remoteMcpIdentity} derives the identity from — all read from the live install. */
|
|
615
|
+
export type RemoteMcpIdentityInputs =
|
|
616
|
+
| {
|
|
617
|
+
readonly provider: "gcp";
|
|
618
|
+
readonly domain: string;
|
|
619
|
+
readonly projectNumber: string;
|
|
620
|
+
/** Numeric `id` of the admin's IAP-enabled backend service. */
|
|
621
|
+
readonly adminBackendServiceId: string;
|
|
622
|
+
}
|
|
623
|
+
| {
|
|
624
|
+
readonly provider: "gcp-firebase";
|
|
625
|
+
readonly projectNumber: string;
|
|
626
|
+
readonly region: string;
|
|
627
|
+
readonly adminServiceName: string;
|
|
628
|
+
/** The services' `status.url` (`https://<name>-<hash>-<r>.a.run.app`). */
|
|
629
|
+
readonly adminServiceUrl: string;
|
|
630
|
+
readonly gatewayServiceUrl: string;
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* The install's remote-MCP identity.
|
|
635
|
+
*
|
|
636
|
+
* - `gcp`: the broker answers on the site's own host through the load
|
|
637
|
+
* balancer (`/api/*` and the `/.well-known/oauth-*` path rules), the admin
|
|
638
|
+
* on `admin.<domain>` behind the LB's IAP; the IAP audience is
|
|
639
|
+
* `/projects/<number>/global/backendServices/<id>`.
|
|
640
|
+
* - `gcp-firebase`: the broker answers on the gateway's own Cloud Run URL
|
|
641
|
+
* (Firebase Hosting rewrites cut requests at 60 s and buffer streams), the
|
|
642
|
+
* admin on its Cloud Run URL behind native IAP — whatever custom domain
|
|
643
|
+
* is mapped to it, IAP and its audience are the service's; the audience
|
|
644
|
+
* is `/projects/<number>/locations/<region>/services/<name>`.
|
|
645
|
+
*
|
|
646
|
+
* Audience formats: cloud.google.com/iap/docs/signed-headers-howto.
|
|
647
|
+
*/
|
|
648
|
+
export function remoteMcpIdentity(i: RemoteMcpIdentityInputs): RemoteMcpIdentity {
|
|
649
|
+
const origin = (url: string) => url.replace(/\/+$/, "");
|
|
650
|
+
if (i.provider === "gcp") {
|
|
651
|
+
return {
|
|
652
|
+
adminUrl: `https://admin.${i.domain}`,
|
|
653
|
+
mcpPublicUrl: publicSiteUrl(i.domain),
|
|
654
|
+
iapAudience: `/projects/${i.projectNumber}/global/backendServices/${i.adminBackendServiceId}`,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
return {
|
|
658
|
+
adminUrl: origin(i.adminServiceUrl),
|
|
659
|
+
mcpPublicUrl: origin(i.gatewayServiceUrl),
|
|
660
|
+
iapAudience: `/projects/${i.projectNumber}/locations/${i.region}/services/${i.adminServiceName}`,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** {@link RemoteMcpIdentity} as each service's env vars. */
|
|
665
|
+
export function remoteMcpIdentityEnv(
|
|
666
|
+
id: RemoteMcpIdentity,
|
|
667
|
+
): Record<CloudRunSlug, CloudRunEnvVar[]> {
|
|
668
|
+
return {
|
|
669
|
+
admin: [
|
|
670
|
+
{ name: REMOTE_MCP_ENV.iapAudience, value: id.iapAudience },
|
|
671
|
+
// The setup page shows the redirect URI and the MCP URLs from it.
|
|
672
|
+
{ name: REMOTE_MCP_ENV.mcpPublicUrl, value: id.mcpPublicUrl },
|
|
673
|
+
],
|
|
674
|
+
gateway: [
|
|
675
|
+
{ name: REMOTE_MCP_ENV.adminUrl, value: id.adminUrl },
|
|
676
|
+
{ name: REMOTE_MCP_ENV.mcpPublicUrl, value: id.mcpPublicUrl },
|
|
677
|
+
],
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
418
681
|
/**
|
|
419
682
|
* The env of the admin service.
|
|
420
683
|
*
|
|
@@ -452,10 +715,16 @@ export function adminEnvContract<V>(inputs: AdminEnvInputs<V>): CloudRunEnvVar<V
|
|
|
452
715
|
// #696 — the job "Install update" starts (upgrader-setup.ts); the admin
|
|
453
716
|
// may only run it, never override it.
|
|
454
717
|
{ name: UPGRADER_JOB_ENV_VAR, value: upgraderJobResource(projectId, inputs.region, env) },
|
|
718
|
+
// #819 — the job "Install update" starts for a release that changes the
|
|
719
|
+
// cloud setup (converge-job-setup.ts); start-only as well.
|
|
720
|
+
{ name: CONVERGE_JOB_ENV_VAR, value: convergeJobResource(projectId, inputs.region, env) },
|
|
455
721
|
// #875 — the job the quality audit fans its Lighthouse runs out to, and
|
|
456
722
|
// where the tasks write their results (audit-job-setup.ts).
|
|
457
723
|
{ name: AUDIT_JOB_ENV_VAR, value: auditJobResource(projectId, inputs.region, env) },
|
|
458
724
|
{ name: AUDIT_RESULTS_BUCKET_ENV_VAR, value: gcpBucketName(projectId, env, "audit-results") },
|
|
725
|
+
// #806 — where Settings → MCP → Remote writes the Google OAuth client
|
|
726
|
+
// (add-version only, see the run SA's secretVersionAdder invariant).
|
|
727
|
+
{ name: MCP_OAUTH_CLIENT_STORE_ENV, value: mcpOAuthClientSecretResource(projectId, env) },
|
|
459
728
|
];
|
|
460
729
|
if (inputs.provider === "gcp") {
|
|
461
730
|
// v0.2.78 — the GCS StaticPublisher: Stage uploads to staging,
|
|
@@ -542,6 +811,7 @@ export interface IamInvariant {
|
|
|
542
811
|
}
|
|
543
812
|
|
|
544
813
|
const SECRET_ACCESSOR = "roles/secretmanager.secretAccessor";
|
|
814
|
+
const SECRET_VERSION_ADDER = "roles/secretmanager.secretVersionAdder";
|
|
545
815
|
|
|
546
816
|
/** One `secretAccessor` binding a stack declares for a service's run SA. */
|
|
547
817
|
export interface RuntimeSecretBinding {
|
|
@@ -577,6 +847,23 @@ function secretAccessors(): IamInvariant[] {
|
|
|
577
847
|
}));
|
|
578
848
|
}
|
|
579
849
|
|
|
850
|
+
/** Bindings both GCP stacks declare beyond the secret accessors and telemetry. */
|
|
851
|
+
const GCP_COMMON: readonly IamInvariant[] = [
|
|
852
|
+
{
|
|
853
|
+
stackResource: "admin-mcp-oauth-client-version-adder",
|
|
854
|
+
// Why the admin may write a secret at all: the Owner pastes the remote-
|
|
855
|
+
// MCP OAuth client under Settings → MCP → Remote, and the admin stores
|
|
856
|
+
// it where the gateway reads it. secretVersionAdder is add-version only
|
|
857
|
+
// (no access, no destroy, no IAM), bound on this one secret only — the
|
|
858
|
+
// admin can replace the client, never read it or any other secret.
|
|
859
|
+
role: SECRET_VERSION_ADDER,
|
|
860
|
+
member: "run-sa",
|
|
861
|
+
target: { kind: "secret", name: "mcp-oauth-client" },
|
|
862
|
+
onFailure: "warn",
|
|
863
|
+
why: "the Owner's remote-MCP setup (Settings → MCP → Remote) stores the OAuth client; without it only that setup fails",
|
|
864
|
+
},
|
|
865
|
+
];
|
|
866
|
+
|
|
580
867
|
/** v0.6.6 — a custom runtime SA gets no telemetry roles implicitly. */
|
|
581
868
|
const TELEMETRY: readonly IamInvariant[] = [
|
|
582
869
|
{
|
|
@@ -743,6 +1030,7 @@ export const RETIRED_IAM_BINDINGS: readonly RetiredIamBinding[] = [
|
|
|
743
1030
|
export function stackIamInvariants(provider: GcpProvider): IamInvariant[] {
|
|
744
1031
|
return [
|
|
745
1032
|
...secretAccessors(),
|
|
1033
|
+
...GCP_COMMON,
|
|
746
1034
|
...(provider === "gcp" ? GCP_ONLY : GCP_FIREBASE_ONLY),
|
|
747
1035
|
...TELEMETRY,
|
|
748
1036
|
];
|
|
@@ -342,6 +342,9 @@ describe("upgrading a v0.10.35 install: the gateway leaves admin_role (#613)", (
|
|
|
342
342
|
expect(plan.ok && plan.services.gateway.changes.map((c) => c.name).sort()).toEqual([
|
|
343
343
|
"ADMIN_DATABASE_PASSWORD",
|
|
344
344
|
"ADMIN_DATABASE_URL",
|
|
345
|
+
// #806 — the remote-MCP broker's client file path and seal key.
|
|
346
|
+
"CAELO_MCP_OAUTH_CLIENT_FILE",
|
|
347
|
+
"CAELO_MCP_OAUTH_SEAL_KEY",
|
|
345
348
|
// #650 — the staging gate key arrives in the same roll.
|
|
346
349
|
"CAELO_STAGING_GATE_SECRET",
|
|
347
350
|
// #845 — and which forwarded address is the visitor.
|
|
@@ -312,13 +312,14 @@ describe("planContractEnv", () => {
|
|
|
312
312
|
// #650 — the same roll converges the protected staging domain's host
|
|
313
313
|
// and gate key onto an install that predates it.
|
|
314
314
|
expect(plan.services.admin.flags).toEqual([
|
|
315
|
-
"--update-env-vars=CAELO_SITE_URL=https://acme.com,MEDIA_ROOT_DIR=/app/apps/admin/data/media,CAELO_MCP_IAP_SERVICE_ACCOUNT=caelo-mcp@acme.iam.gserviceaccount.com,CAELO_OPERATOR_ACCESS_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync,CAELO_UPGRADER_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-upgrader,CAELO_AUDIT_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-audit,CAELO_AUDIT_RESULTS_BUCKET=acme-caelo-production-audit-results,CAELO_STAGING_HOST=staging.acme.com",
|
|
315
|
+
"--update-env-vars=CAELO_SITE_URL=https://acme.com,MEDIA_ROOT_DIR=/app/apps/admin/data/media,CAELO_MCP_IAP_SERVICE_ACCOUNT=caelo-mcp@acme.iam.gserviceaccount.com,CAELO_OPERATOR_ACCESS_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync,CAELO_UPGRADER_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-upgrader,CAELO_CONVERGE_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-converge,CAELO_AUDIT_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-audit,CAELO_AUDIT_RESULTS_BUCKET=acme-caelo-production-audit-results,CAELO_MCP_OAUTH_CLIENT_STORE=projects/acme/secrets/caelo-production-mcp-oauth-client,CAELO_STAGING_HOST=staging.acme.com",
|
|
316
316
|
"--update-secrets=CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest",
|
|
317
317
|
]);
|
|
318
318
|
expect(plan.services.gateway.flags).toEqual([
|
|
319
319
|
// The gateway learns which forwarded address is the visitor (#845).
|
|
320
|
-
|
|
321
|
-
"--update-
|
|
320
|
+
// #806 — and where the remote-MCP broker's client file and seal key are.
|
|
321
|
+
"--update-env-vars=CAELO_TRUSTED_PROXY_HOPS=1,CAELO_MCP_OAUTH_CLIENT_FILE=/secrets/mcp-oauth-client/client.json",
|
|
322
|
+
"--update-secrets=CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest,CAELO_MCP_OAUTH_SEAL_KEY=caelo-production-mcp-oauth-seal-key:latest",
|
|
322
323
|
]);
|
|
323
324
|
});
|
|
324
325
|
|
|
@@ -474,7 +475,11 @@ describe("ensureStackInvariants", () => {
|
|
|
474
475
|
]),
|
|
475
476
|
],
|
|
476
477
|
"secrets get-iam-policy": [
|
|
477
|
-
policy([
|
|
478
|
+
policy([
|
|
479
|
+
{ role: "roles/secretmanager.secretAccessor", members: [RUN_SA, GW_SA] },
|
|
480
|
+
// #806 — the admin adds remote-MCP OAuth client versions.
|
|
481
|
+
{ role: "roles/secretmanager.secretVersionAdder", members: [RUN_SA] },
|
|
482
|
+
]),
|
|
478
483
|
],
|
|
479
484
|
"storage buckets get-iam-policy": [
|
|
480
485
|
policy([{ role: "roles/storage.objectAdmin", members: [RUN_SA] }]),
|
|
@@ -673,7 +678,10 @@ describe("ensureStackInvariants", () => {
|
|
|
673
678
|
]),
|
|
674
679
|
],
|
|
675
680
|
"secrets get-iam-policy": [
|
|
676
|
-
policy([
|
|
681
|
+
policy([
|
|
682
|
+
{ role: "roles/secretmanager.secretAccessor", members: [RUN_SA, GW_SA] },
|
|
683
|
+
{ role: "roles/secretmanager.secretVersionAdder", members: [RUN_SA] },
|
|
684
|
+
]),
|
|
677
685
|
],
|
|
678
686
|
"storage buckets get-iam-policy": [
|
|
679
687
|
policy([
|
|
@@ -779,6 +787,14 @@ describe("ensureStackInvariants", () => {
|
|
|
779
787
|
expect(gatewayGrants).toEqual([
|
|
780
788
|
`secrets add-iam-policy-binding caelo-production-public-role-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
|
|
781
789
|
`secrets add-iam-policy-binding caelo-production-staging-gate-secret --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
|
|
790
|
+
// #806 — the remote-MCP broker's seal key and OAuth client file.
|
|
791
|
+
`secrets add-iam-policy-binding caelo-production-mcp-oauth-seal-key --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
|
|
792
|
+
`secrets add-iam-policy-binding caelo-production-mcp-oauth-client --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
|
|
793
|
+
]);
|
|
794
|
+
// #806 — the admin may add versions to the OAuth client secret, and to no other.
|
|
795
|
+
const adders = calls.filter((c) => c.includes("roles/secretmanager.secretVersionAdder"));
|
|
796
|
+
expect(adders).toEqual([
|
|
797
|
+
`secrets add-iam-policy-binding caelo-production-mcp-oauth-client --project=acme --member=${RUN_SA} --role=roles/secretmanager.secretVersionAdder --quiet --format=none`,
|
|
782
798
|
]);
|
|
783
799
|
for (const secret of ["internal-secret", "tool-approval-secret", "secret-kek"]) {
|
|
784
800
|
expect(calls).toContain(
|
package/src/stack-converge.ts
CHANGED
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
* - {@link planMediaVolume}: the admin's media bucket volume (gen2 + Cloud
|
|
19
19
|
* Storage volume + mount), added in the same `services update` when
|
|
20
20
|
* missing.
|
|
21
|
+
* - {@link planSecretFiles} / {@link planRequestTimeout} (#806): the
|
|
22
|
+
* gateway's secret file (the remote-MCP OAuth client) and its request
|
|
23
|
+
* timeout, in the same `services update`.
|
|
21
24
|
*/
|
|
22
25
|
|
|
23
26
|
import { removeDefaultFirewallRules } from "./default-firewall.js";
|
|
@@ -43,8 +46,10 @@ import {
|
|
|
43
46
|
RETIRED_SERVICE_ENV,
|
|
44
47
|
type RetiredIamBinding,
|
|
45
48
|
type RuntimeEnvInputs,
|
|
49
|
+
type SecretFileContract,
|
|
46
50
|
type ServiceEnvInputs,
|
|
47
51
|
STATIC_CDN_POLICY,
|
|
52
|
+
secretFilePath,
|
|
48
53
|
stackIamInvariants,
|
|
49
54
|
staticBackendBucketPrefix,
|
|
50
55
|
} from "./stack-contract.js";
|
|
@@ -285,6 +290,12 @@ export function planContractEnv(
|
|
|
285
290
|
opts: {
|
|
286
291
|
/** Contract vars to leave exactly as the services have them. */
|
|
287
292
|
readonly leaveUntouched?: readonly string[];
|
|
293
|
+
/**
|
|
294
|
+
* Vars beyond the contract to set as well (#806: the remote-MCP identity
|
|
295
|
+
* env, resolved by the converge). A name the contract also sets is
|
|
296
|
+
* refused rather than silently doubled.
|
|
297
|
+
*/
|
|
298
|
+
readonly extraEnv?: Readonly<Partial<Record<CloudRunSlug, readonly CloudRunEnvVar[]>>>;
|
|
288
299
|
} = {},
|
|
289
300
|
):
|
|
290
301
|
| {
|
|
@@ -320,16 +331,25 @@ export function planContractEnv(
|
|
|
320
331
|
inputs = { ...runtime, provider: "gcp" };
|
|
321
332
|
}
|
|
322
333
|
const skip = new Set(opts.leaveUntouched ?? []);
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
334
|
+
const desired = (slug: CloudRunSlug, contract: readonly CloudRunEnvVar[]) => {
|
|
335
|
+
const extra = opts.extraEnv?.[slug] ?? [];
|
|
336
|
+
const clash = extra.find((e) => contract.some((c) => c.name === e.name));
|
|
337
|
+
if (clash) throw new Error(`${slug}: ${clash.name} is both in the contract and extra env`);
|
|
338
|
+
return [...contract, ...extra].filter((e) => !skip.has(e.name));
|
|
339
|
+
};
|
|
340
|
+
let adminDesired: CloudRunEnvVar[];
|
|
341
|
+
let gatewayDesired: CloudRunEnvVar[];
|
|
342
|
+
try {
|
|
343
|
+
adminDesired = desired("admin", adminEnvContract(inputs));
|
|
344
|
+
gatewayDesired = desired("gateway", gatewayEnvContract(runtime));
|
|
345
|
+
} catch (e) {
|
|
346
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
347
|
+
}
|
|
348
|
+
const admin = planEnvUpdate(services.admin.liveEnv, adminDesired, RETIRED_SERVICE_ENV.admin);
|
|
329
349
|
if (!admin.ok) return { ok: false, error: `admin: ${admin.error}` };
|
|
330
350
|
const gateway = planEnvUpdate(
|
|
331
351
|
services.gateway.liveEnv,
|
|
332
|
-
|
|
352
|
+
gatewayDesired,
|
|
333
353
|
RETIRED_SERVICE_ENV.gateway,
|
|
334
354
|
);
|
|
335
355
|
if (!gateway.ok) return { ok: false, error: `gateway: ${gateway.error}` };
|
|
@@ -713,6 +733,11 @@ const EXECUTION_ENVIRONMENT_ANNOTATION = "run.googleapis.com/execution-environme
|
|
|
713
733
|
|
|
714
734
|
interface KnativeVolume {
|
|
715
735
|
readonly name: string;
|
|
736
|
+
/** A Secret Manager volume (#806 secret files). */
|
|
737
|
+
readonly secret?: {
|
|
738
|
+
readonly secretName?: string;
|
|
739
|
+
readonly items?: readonly { readonly key?: string; readonly path?: string }[];
|
|
740
|
+
};
|
|
716
741
|
readonly csi?: {
|
|
717
742
|
readonly driver?: string;
|
|
718
743
|
readonly readOnly?: boolean;
|
|
@@ -843,6 +868,75 @@ export function planMediaVolume(live: LiveVolumes, desired: MediaVolumeContract)
|
|
|
843
868
|
return { ok: true, flags, changes };
|
|
844
869
|
}
|
|
845
870
|
|
|
871
|
+
// ===========================================================================
|
|
872
|
+
// Secret files + request timeout (#806)
|
|
873
|
+
// ===========================================================================
|
|
874
|
+
|
|
875
|
+
export type RollAdditionsPlan =
|
|
876
|
+
| { readonly ok: true; readonly flags: string[]; readonly changes: string[] }
|
|
877
|
+
| { readonly ok: false; readonly error: string };
|
|
878
|
+
|
|
879
|
+
/** `projects/<p>/secrets/<id>` or `<id>` → `<id>`. */
|
|
880
|
+
function bareSecretId(name: string | undefined): string {
|
|
881
|
+
return (name ?? "").slice((name ?? "").lastIndexOf("/") + 1);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* The flags that mount each of `files` (stack-contract.ts
|
|
886
|
+
* SERVICE_SECRET_FILES) where the stacks mount it, adding only what is
|
|
887
|
+
* missing — `--update-secrets=<dir>/<file>=<secret>:latest`, the form
|
|
888
|
+
* cloud.google.com/run/docs/configuring/services/secrets documents (gcloud
|
|
889
|
+
* names the volume itself). A file already mounted from the same secret at
|
|
890
|
+
* `latest` is converged whatever its volume is called. Anything else at that
|
|
891
|
+
* directory is refused with what was found: replacing it could feed the
|
|
892
|
+
* gateway a different credential.
|
|
893
|
+
*/
|
|
894
|
+
export function planSecretFiles(
|
|
895
|
+
live: LiveVolumes,
|
|
896
|
+
files: readonly SecretFileContract[],
|
|
897
|
+
env: string,
|
|
898
|
+
): RollAdditionsPlan {
|
|
899
|
+
const flags: string[] = [];
|
|
900
|
+
const changes: string[] = [];
|
|
901
|
+
for (const file of files) {
|
|
902
|
+
const secretId = gcpSecretId(env, file.secret);
|
|
903
|
+
const mount = live.mounts.find((m) => m.mountPath === file.mountDir);
|
|
904
|
+
if (mount) {
|
|
905
|
+
const volume = live.volumes.find((v) => v.name === mount.name);
|
|
906
|
+
const item = volume?.secret?.items?.find((i) => i.path === file.fileName);
|
|
907
|
+
const inShape =
|
|
908
|
+
bareSecretId(volume?.secret?.secretName) === secretId && item?.key === file.version;
|
|
909
|
+
if (inShape) continue;
|
|
910
|
+
return {
|
|
911
|
+
ok: false,
|
|
912
|
+
error: `the service mounts volume "${mount.name}" at ${file.mountDir}, where ${secretId}:${file.version} belongs as ${file.fileName}. Remove it (gcloud run services update <service> --remove-volume-mount=${file.mountDir}) only if nothing else needs it, then re-run upgrade.`,
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
flags.push(`--update-secrets=${secretFilePath(file)}=${secretId}:${file.version}`);
|
|
916
|
+
changes.push(`secret file ${secretFilePath(file)}: (none) → ${secretId}:${file.version}`);
|
|
917
|
+
}
|
|
918
|
+
return { ok: true, flags, changes };
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/** The request timeout of a described service, in seconds (null: Cloud Run's default, 300). */
|
|
922
|
+
export function liveTimeoutSeconds(serviceJson: string): number | null {
|
|
923
|
+
const svc = JSON.parse(serviceJson) as {
|
|
924
|
+
spec?: { template?: { spec?: { timeoutSeconds?: number | string } } };
|
|
925
|
+
};
|
|
926
|
+
const t = svc.spec?.template?.spec?.timeoutSeconds;
|
|
927
|
+
return t === undefined ? null : Number(t);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** `--timeout=<desired>` when the live timeout differs; nothing when it matches. */
|
|
931
|
+
export function planRequestTimeout(live: number | null, desired: number): RollAdditionsPlan {
|
|
932
|
+
if (live === desired) return { ok: true, flags: [], changes: [] };
|
|
933
|
+
return {
|
|
934
|
+
ok: true,
|
|
935
|
+
flags: [`--timeout=${desired}`],
|
|
936
|
+
changes: [`request timeout: ${live === null ? "(default 300)" : live}s → ${desired}s`],
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
846
940
|
/**
|
|
847
941
|
* The single `gcloud run services update` that rolls a service to a new
|
|
848
942
|
* image, its run SA and its env changes, so all land in one new revision.
|
package/src/state-attach.test.ts
CHANGED
|
@@ -186,12 +186,18 @@ describe("state locks", () => {
|
|
|
186
186
|
}
|
|
187
187
|
if (args[1] === "cat")
|
|
188
188
|
return objects.has(url) ? ok(objects.get(url)) : fail("matched no objects");
|
|
189
|
+
if (args[1] === "objects") {
|
|
190
|
+
const target = args[3] ?? "";
|
|
191
|
+
return objects.has(target)
|
|
192
|
+
? ok(JSON.stringify({ generation: "1" }))
|
|
193
|
+
: fail("matched no objects");
|
|
194
|
+
}
|
|
189
195
|
if (args[1] === "rm") return objects.delete(url) ? ok() : fail("matched no objects");
|
|
190
196
|
return fail("unexpected");
|
|
191
197
|
};
|
|
192
198
|
const release = acquireBucketStateLock("acme-site-caelo-ops", "state migrate", run);
|
|
193
199
|
expect(() => acquireBucketStateLock("acme-site-caelo-ops", "installer pulumi up", run)).toThrow(
|
|
194
|
-
/
|
|
200
|
+
/held by state migrate .*lease runs until/,
|
|
195
201
|
);
|
|
196
202
|
release();
|
|
197
203
|
acquireBucketStateLock("acme-site-caelo-ops", "installer pulumi up", run)();
|