@caelo-cms/provisioning 0.10.57 → 0.10.59
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 -5
- package/dist/compose.js +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.d.ts.map +1 -1
- package/dist/infra-contract.js +12 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/operator-access.d.ts +4 -0
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +37 -11
- package/dist/operator-access.js.map +1 -1
- package/dist/remote-mcp.d.ts +22 -0
- package/dist/remote-mcp.d.ts.map +1 -1
- package/dist/remote-mcp.js +59 -4
- package/dist/remote-mcp.js.map +1 -1
- package/dist/runtime-secrets.js +2 -2
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/stack-contract.d.ts +54 -15
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +80 -33
- package/dist/stack-contract.js.map +1 -1
- package/dist/stack-converge.d.ts +17 -2
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +52 -2
- package/dist/stack-converge.js.map +1 -1
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +3 -3
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/audit-job-roll.d.ts.map +1 -1
- package/dist/upgrade/audit-job-roll.js +3 -1
- package/dist/upgrade/audit-job-roll.js.map +1 -1
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +16 -5
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/wizards/gcp-cost.js +1 -1
- 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 +14 -2
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.js +9 -2
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/audit-job-setup.test.ts +7 -1
- package/src/compose.ts +1 -1
- package/src/gateway-env-isolation.test.ts +1 -1
- package/src/infra-contract.test.ts +4 -3
- package/src/infra-contract.ts +12 -1
- package/src/operator-access.test.ts +11 -2
- package/src/operator-access.ts +38 -10
- package/src/remote-mcp.test.ts +196 -23
- package/src/remote-mcp.ts +68 -4
- package/src/runtime-secrets.test.ts +3 -3
- package/src/runtime-secrets.ts +2 -2
- package/src/stack-contract.test.ts +10 -8
- package/src/stack-contract.ts +110 -39
- package/src/stack-converge-secrets.test.ts +3 -2
- package/src/stack-converge.test.ts +4 -4
- package/src/stack-converge.ts +61 -0
- package/src/upgrade/app-roll.ts +8 -2
- package/src/upgrade/audit-job-roll.ts +3 -1
- package/src/upgrade/converge.ts +20 -4
- package/src/upgrade/upgrade.test.ts +1 -1
- package/src/wizards/gcp-cost.ts +1 -1
- package/src/wizards/gcp-pulumi.test.ts +15 -0
- package/src/wizards/gcp-pulumi.ts +18 -2
- package/src/wizards/gcp.ts +11 -2
- package/stacks/gcp/index.ts +9 -8
- package/stacks/gcp-firebase/index.ts +9 -8
|
@@ -72,7 +72,8 @@ describe("adminEnvContract", () => {
|
|
|
72
72
|
MEDIA_ROOT_DIR: "/app/apps/admin/data/media",
|
|
73
73
|
CAELO_MCP_IAP_SERVICE_ACCOUNT: "caelo-mcp@acme.iam.gserviceaccount.com",
|
|
74
74
|
// #806 — where Settings → MCP → Remote writes the OAuth client.
|
|
75
|
-
|
|
75
|
+
CAELO_OPERATOR_OAUTH_CLIENT_STORE:
|
|
76
|
+
"projects/acme/secrets/caelo-production-operator-oauth-client",
|
|
76
77
|
CAELO_OPERATOR_ACCESS_JOB:
|
|
77
78
|
"projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync",
|
|
78
79
|
CAELO_UPGRADER_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-upgrader",
|
|
@@ -107,7 +108,8 @@ describe("adminEnvContract", () => {
|
|
|
107
108
|
MEDIA_ROOT_DIR: "/app/apps/admin/data/media",
|
|
108
109
|
CAELO_MCP_IAP_SERVICE_ACCOUNT: "caelo-mcp@acme.iam.gserviceaccount.com",
|
|
109
110
|
// #806 — where Settings → MCP → Remote writes the OAuth client.
|
|
110
|
-
|
|
111
|
+
CAELO_OPERATOR_OAUTH_CLIENT_STORE:
|
|
112
|
+
"projects/acme/secrets/caelo-production-operator-oauth-client",
|
|
111
113
|
CAELO_OPERATOR_ACCESS_JOB:
|
|
112
114
|
"projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync",
|
|
113
115
|
CAELO_UPGRADER_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-upgrader",
|
|
@@ -174,7 +176,7 @@ describe("gatewayEnvContract", () => {
|
|
|
174
176
|
PUBLIC_DATABASE_PASSWORD: "secret:caelo-production-public-role-password:latest",
|
|
175
177
|
CAELO_STAGING_GATE_SECRET: "secret:caelo-production-staging-gate-secret:latest",
|
|
176
178
|
// #806 — the remote-MCP broker: its client file and its seal key.
|
|
177
|
-
|
|
179
|
+
CAELO_OPERATOR_OAUTH_CLIENT_FILE: "/secrets/operator-oauth-client/client.json",
|
|
178
180
|
CAELO_MCP_OAUTH_SEAL_KEY: "secret:caelo-production-mcp-oauth-seal-key:latest",
|
|
179
181
|
});
|
|
180
182
|
});
|
|
@@ -212,7 +214,7 @@ describe("gatewayEnvContract", () => {
|
|
|
212
214
|
"staging-gate-secret",
|
|
213
215
|
// #806 — the broker's seal key (env) and OAuth client (file).
|
|
214
216
|
"mcp-oauth-seal-key",
|
|
215
|
-
"
|
|
217
|
+
"operator-oauth-client",
|
|
216
218
|
]);
|
|
217
219
|
for (const v of gateway) {
|
|
218
220
|
expect(v.name).not.toMatch(/KEK|INTERNAL|TOOL_APPROVAL|PUBLIC_ADMIN/);
|
|
@@ -318,8 +320,8 @@ describe("runtime secrets", () => {
|
|
|
318
320
|
// The secret file counts like an env secret: the SA reads it.
|
|
319
321
|
{
|
|
320
322
|
service: "gateway",
|
|
321
|
-
secret: "
|
|
322
|
-
stackResource: "gateway-
|
|
323
|
+
secret: "operator-oauth-client",
|
|
324
|
+
stackResource: "gateway-operator-oauth-client-binding",
|
|
323
325
|
},
|
|
324
326
|
]);
|
|
325
327
|
});
|
|
@@ -377,14 +379,14 @@ describe("stackIamInvariants", () => {
|
|
|
377
379
|
}
|
|
378
380
|
});
|
|
379
381
|
|
|
380
|
-
it("grants the gateway SA only its own role password, the staging gate key,
|
|
382
|
+
it("grants the gateway SA only its own role password, the staging gate key, the sign-in client, the seal key + telemetry roles", () => {
|
|
381
383
|
for (const provider of ["gcp", "gcp-firebase"] as const) {
|
|
382
384
|
const gateway = stackIamInvariants(provider).filter((i) => i.member === "gateway-sa");
|
|
383
385
|
expect(gateway.map((i) => `${i.role} ${JSON.stringify(i.target)}`).sort()).toEqual([
|
|
384
386
|
'roles/logging.logWriter {"kind":"project"}',
|
|
385
387
|
'roles/monitoring.metricWriter {"kind":"project"}',
|
|
386
|
-
'roles/secretmanager.secretAccessor {"kind":"secret","name":"mcp-oauth-client"}',
|
|
387
388
|
'roles/secretmanager.secretAccessor {"kind":"secret","name":"mcp-oauth-seal-key"}',
|
|
389
|
+
'roles/secretmanager.secretAccessor {"kind":"secret","name":"operator-oauth-client"}',
|
|
388
390
|
'roles/secretmanager.secretAccessor {"kind":"secret","name":"public-role-password"}',
|
|
389
391
|
'roles/secretmanager.secretAccessor {"kind":"secret","name":"staging-gate-secret"}',
|
|
390
392
|
]);
|
package/src/stack-contract.ts
CHANGED
|
@@ -261,12 +261,15 @@ export type RuntimeSecret =
|
|
|
261
261
|
| "tool-approval-secret"
|
|
262
262
|
| "staging-gate-secret"
|
|
263
263
|
/**
|
|
264
|
-
* #
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
264
|
+
* #914 — the install's Google sign-in client: JSON
|
|
265
|
+
* `{"client_id","client_secret"}`, `{}` while it is not set up. One client
|
|
266
|
+
* for IAP browser login, IAP programmatic access and the remote-MCP broker
|
|
267
|
+
* (#806 introduced it as `mcp-oauth-client`). The Owner writes it from
|
|
268
|
+
* Settings → Google sign-in (the admin may add versions, never read them);
|
|
269
|
+
* the gateway reads it from a file, the operator-access sync job reads it
|
|
270
|
+
* to hand it to IAP.
|
|
268
271
|
*/
|
|
269
|
-
| "
|
|
272
|
+
| "operator-oauth-client"
|
|
270
273
|
/**
|
|
271
274
|
* #806 — the broker's AES-256-GCM key (base64, 32 bytes) that seals its
|
|
272
275
|
* stateless OAuth state, codes and refresh tokens. Gateway only.
|
|
@@ -305,10 +308,11 @@ export const CLI_GENERATED_SECRETS = [
|
|
|
305
308
|
// session cookies, derives the crawler credentials. Read by the admin
|
|
306
309
|
// (issues) and the gateway (verifies).
|
|
307
310
|
"staging-gate-secret",
|
|
308
|
-
// #806 —
|
|
309
|
-
// configured; the Owner fills it under Settings →
|
|
310
|
-
// 32 random bytes in base64
|
|
311
|
-
|
|
311
|
+
// #914 / #806 — the Google sign-in client starts as the `{}` placeholder
|
|
312
|
+
// (not configured; the Owner fills it under Settings → Google sign-in),
|
|
313
|
+
// the broker's seal key as 32 random bytes in base64
|
|
314
|
+
// ({@link GENERATED_SECRET_FORMAT}).
|
|
315
|
+
"operator-oauth-client",
|
|
312
316
|
"mcp-oauth-seal-key",
|
|
313
317
|
] as const satisfies readonly RuntimeSecret[];
|
|
314
318
|
|
|
@@ -331,12 +335,28 @@ export const GENERATED_SECRET_FORMAT: Readonly<
|
|
|
331
335
|
"staging-gate-secret": { kind: "hex32" },
|
|
332
336
|
// The gateway's seal key contract (#806): base64 of exactly 32 bytes.
|
|
333
337
|
"mcp-oauth-seal-key": { kind: "base64-32" },
|
|
334
|
-
// "Not configured" until the Owner pastes a client (#
|
|
338
|
+
// "Not configured" until the Owner pastes a client (#914). A version must
|
|
335
339
|
// exist from the start: Cloud Run refuses a revision whose secret volume
|
|
336
340
|
// points at a secret without an enabled version.
|
|
337
|
-
"
|
|
341
|
+
"operator-oauth-client": { kind: "placeholder", value: "{}" },
|
|
338
342
|
};
|
|
339
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Secrets an older contract created that nothing reads any more. Upgrade
|
|
346
|
+
* deletes each once both services rolled onto revisions that no longer
|
|
347
|
+
* reference it (stack-converge.ts `deleteRetiredSecrets`); deleting a
|
|
348
|
+
* secret drops its IAM bindings with it. Pre-1.0 delete-don't-migrate: a
|
|
349
|
+
* renamed secret is created fresh, never copied.
|
|
350
|
+
*/
|
|
351
|
+
export const RETIRED_RUNTIME_SECRETS: readonly { readonly name: string; readonly why: string }[] = [
|
|
352
|
+
{
|
|
353
|
+
// #914 — renamed to `operator-oauth-client`; nobody had configured
|
|
354
|
+
// remote MCP when the rename shipped, so there is nothing to carry over.
|
|
355
|
+
name: "mcp-oauth-client",
|
|
356
|
+
why: "the Google sign-in client moved to operator-oauth-client (#914)",
|
|
357
|
+
},
|
|
358
|
+
];
|
|
359
|
+
|
|
340
360
|
/**
|
|
341
361
|
* Each service's Secret Manager-backed env vars and the secret each reads.
|
|
342
362
|
* The services' run SAs get `secretAccessor` on exactly these secrets
|
|
@@ -375,8 +395,8 @@ export const SERVICE_SECRET_ENV: Readonly<
|
|
|
375
395
|
// Secret files (#806)
|
|
376
396
|
// ===========================================================================
|
|
377
397
|
|
|
378
|
-
/** Env var naming the file that holds the
|
|
379
|
-
export const
|
|
398
|
+
/** Env var naming the file that holds the install's Google sign-in client (#914). */
|
|
399
|
+
export const OPERATOR_OAUTH_CLIENT_FILE_ENV = "CAELO_OPERATOR_OAUTH_CLIENT_FILE";
|
|
380
400
|
|
|
381
401
|
/**
|
|
382
402
|
* A Secret Manager secret mounted into a service as one file, at `latest`.
|
|
@@ -386,7 +406,7 @@ export const MCP_OAUTH_CLIENT_FILE_ENV = "CAELO_MCP_OAUTH_CLIENT_FILE";
|
|
|
386
406
|
* time the file is read ("When reading a volume, Cloud Run always fetches
|
|
387
407
|
* the secret value from the Secret Manager to use the value with the latest
|
|
388
408
|
* version" — cloud.google.com/run/docs/configuring/services/secrets). So the
|
|
389
|
-
* client the Owner saves under Settings →
|
|
409
|
+
* client the Owner saves under Settings → Google sign-in reaches the running gateway
|
|
390
410
|
* (which re-reads it within 60 s) without a new revision, and the admin
|
|
391
411
|
* needs no right to deploy the gateway.
|
|
392
412
|
*/
|
|
@@ -401,11 +421,11 @@ export interface SecretFileContract {
|
|
|
401
421
|
readonly version: "latest";
|
|
402
422
|
}
|
|
403
423
|
|
|
404
|
-
/** The gateway's
|
|
405
|
-
export const
|
|
406
|
-
secret: "
|
|
407
|
-
volumeName: "
|
|
408
|
-
mountDir: "/secrets/
|
|
424
|
+
/** The gateway's Google sign-in client file, read by the remote-MCP broker (#806, #914). */
|
|
425
|
+
export const OPERATOR_OAUTH_CLIENT_FILE: SecretFileContract = {
|
|
426
|
+
secret: "operator-oauth-client",
|
|
427
|
+
volumeName: "operator-oauth-client",
|
|
428
|
+
mountDir: "/secrets/operator-oauth-client",
|
|
409
429
|
fileName: "client.json",
|
|
410
430
|
version: "latest",
|
|
411
431
|
};
|
|
@@ -418,7 +438,30 @@ export function secretFilePath(file: SecretFileContract): string {
|
|
|
418
438
|
/** Each service's secret files. The admin has none. */
|
|
419
439
|
export const SERVICE_SECRET_FILES: Readonly<Record<CloudRunSlug, readonly SecretFileContract[]>> = {
|
|
420
440
|
admin: [],
|
|
421
|
-
gateway: [
|
|
441
|
+
gateway: [OPERATOR_OAUTH_CLIENT_FILE],
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
/** A secret file an older contract mounted; upgrade unmounts it in the roll. */
|
|
445
|
+
export interface RetiredSecretFile {
|
|
446
|
+
readonly mountDir: string;
|
|
447
|
+
readonly fileName: string;
|
|
448
|
+
readonly why: string;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Secret files older contracts mounted that nothing reads any more, per
|
|
453
|
+
* service. The roll removes each mount (`--remove-secrets=<path>`) in the
|
|
454
|
+
* same revision that mounts its replacement.
|
|
455
|
+
*/
|
|
456
|
+
export const RETIRED_SECRET_FILES: Readonly<Record<CloudRunSlug, readonly RetiredSecretFile[]>> = {
|
|
457
|
+
admin: [],
|
|
458
|
+
gateway: [
|
|
459
|
+
{
|
|
460
|
+
mountDir: "/secrets/mcp-oauth-client",
|
|
461
|
+
fileName: "client.json",
|
|
462
|
+
why: "the Google sign-in client is mounted from operator-oauth-client now (#914)",
|
|
463
|
+
},
|
|
464
|
+
],
|
|
422
465
|
};
|
|
423
466
|
|
|
424
467
|
/**
|
|
@@ -466,8 +509,11 @@ export function serviceSecrets(service: CloudRunSlug): RuntimeSecret[] {
|
|
|
466
509
|
* credential is in its env (apps/api-gateway `adminDatabaseAccessInEnv`).
|
|
467
510
|
*/
|
|
468
511
|
export const RETIRED_SERVICE_ENV: Readonly<Record<CloudRunSlug, readonly string[]>> = {
|
|
469
|
-
|
|
512
|
+
// #914 — the sign-in client's store was renamed with its secret.
|
|
513
|
+
admin: ["CAELO_MCP_OAUTH_CLIENT_STORE"],
|
|
470
514
|
gateway: [
|
|
515
|
+
// #914 — renamed to CAELO_OPERATOR_OAUTH_CLIENT_FILE with its secret.
|
|
516
|
+
"CAELO_MCP_OAUTH_CLIENT_FILE",
|
|
471
517
|
"CAELO_SECRET_KEK",
|
|
472
518
|
"ADMIN_DATABASE_URL",
|
|
473
519
|
"ADMIN_DATABASE_PASSWORD",
|
|
@@ -547,7 +593,7 @@ export function gatewayEnvContract<V>(inputs: RuntimeEnvInputs<V>): CloudRunEnvV
|
|
|
547
593
|
{ name: "PUBLIC_DATABASE_URL", value: inputs.databaseUrls.public },
|
|
548
594
|
// #806 — where the remote-MCP broker reads its Google OAuth client
|
|
549
595
|
// (the secret volume of SERVICE_SECRET_FILES).
|
|
550
|
-
{ name:
|
|
596
|
+
{ name: OPERATOR_OAUTH_CLIENT_FILE_ENV, value: secretFilePath(OPERATOR_OAUTH_CLIENT_FILE) },
|
|
551
597
|
...secretEnv("gateway", inputs.env),
|
|
552
598
|
];
|
|
553
599
|
}
|
|
@@ -565,12 +611,12 @@ export function gatewayEnvContract<V>(inputs: RuntimeEnvInputs<V>): CloudRunEnvV
|
|
|
565
611
|
*/
|
|
566
612
|
export const GATEWAY_TIMEOUT_SECONDS = 3600;
|
|
567
613
|
|
|
568
|
-
/** Admin env var naming the Secret Manager secret the Owner writes the
|
|
569
|
-
export const
|
|
614
|
+
/** Admin env var naming the Secret Manager secret the Owner writes the Google sign-in client to. */
|
|
615
|
+
export const OPERATOR_OAUTH_CLIENT_STORE_ENV = "CAELO_OPERATOR_OAUTH_CLIENT_STORE";
|
|
570
616
|
|
|
571
|
-
/** `projects/<project>/secrets/<id>` of the
|
|
572
|
-
export function
|
|
573
|
-
return `projects/${projectId}/secrets/${gcpSecretId(env, "
|
|
617
|
+
/** `projects/<project>/secrets/<id>` of the Google sign-in client secret (#914). */
|
|
618
|
+
export function operatorOAuthClientSecretResource(projectId: string, env: string): string {
|
|
619
|
+
return `projects/${projectId}/secrets/${gcpSecretId(env, "operator-oauth-client")}`;
|
|
574
620
|
}
|
|
575
621
|
|
|
576
622
|
// ===========================================================================
|
|
@@ -594,6 +640,12 @@ export function mcpOAuthClientSecretResource(projectId: string, env: string): st
|
|
|
594
640
|
* (remote-mcp.ts `ensureRemoteMcpIdentityEnv`). A roll that does not set
|
|
595
641
|
* them leaves them as they are (planEnvUpdate never removes a var the
|
|
596
642
|
* contract does not own).
|
|
643
|
+
*
|
|
644
|
+
* #914 adds two the admin's Google sign-in needs: its own URL (the
|
|
645
|
+
* self-check opens it signed out) and the project's Google organisation
|
|
646
|
+
* domain, which the admin's service account cannot read (organisation
|
|
647
|
+
* metadata needs a grant on the organisation) but the operator's identity
|
|
648
|
+
* usually can.
|
|
597
649
|
*/
|
|
598
650
|
export interface RemoteMcpIdentity {
|
|
599
651
|
/** Where the gateway proxies `/api/mcp/<surface>` to (IAP in front). */
|
|
@@ -602,6 +654,12 @@ export interface RemoteMcpIdentity {
|
|
|
602
654
|
readonly mcpPublicUrl: string;
|
|
603
655
|
/** The `aud` of the admin's IAP JWTs. */
|
|
604
656
|
readonly iapAudience: string;
|
|
657
|
+
/**
|
|
658
|
+
* #914 — the project's Google organisation domain, `none` when the project
|
|
659
|
+
* is in no organisation, undefined when it could not be read (the var is
|
|
660
|
+
* then left as it is and the admin reports the organisation as unknown).
|
|
661
|
+
*/
|
|
662
|
+
readonly googleOrgDomain?: string;
|
|
605
663
|
}
|
|
606
664
|
|
|
607
665
|
/** Env var names of {@link RemoteMcpIdentity}. */
|
|
@@ -609,6 +667,7 @@ export const REMOTE_MCP_ENV = {
|
|
|
609
667
|
adminUrl: "CAELO_ADMIN_URL",
|
|
610
668
|
mcpPublicUrl: "CAELO_MCP_PUBLIC_URL",
|
|
611
669
|
iapAudience: "CAELO_IAP_AUDIENCE",
|
|
670
|
+
googleOrgDomain: "CAELO_GOOGLE_ORG_DOMAIN",
|
|
612
671
|
} as const;
|
|
613
672
|
|
|
614
673
|
/** What {@link remoteMcpIdentity} derives the identity from — all read from the live install. */
|
|
@@ -668,8 +727,15 @@ export function remoteMcpIdentityEnv(
|
|
|
668
727
|
return {
|
|
669
728
|
admin: [
|
|
670
729
|
{ name: REMOTE_MCP_ENV.iapAudience, value: id.iapAudience },
|
|
671
|
-
// The setup
|
|
730
|
+
// The setup pages show the redirect URI and the MCP URLs from it.
|
|
672
731
|
{ name: REMOTE_MCP_ENV.mcpPublicUrl, value: id.mcpPublicUrl },
|
|
732
|
+
// #914 — Google sign-in's self-check opens the admin signed out.
|
|
733
|
+
{ name: REMOTE_MCP_ENV.adminUrl, value: id.adminUrl },
|
|
734
|
+
// #914 — who IAP admits without the Google sign-in client
|
|
735
|
+
// (admin-core security/identity-gate.ts).
|
|
736
|
+
...(id.googleOrgDomain
|
|
737
|
+
? [{ name: REMOTE_MCP_ENV.googleOrgDomain, value: id.googleOrgDomain }]
|
|
738
|
+
: []),
|
|
673
739
|
],
|
|
674
740
|
gateway: [
|
|
675
741
|
{ name: REMOTE_MCP_ENV.adminUrl, value: id.adminUrl },
|
|
@@ -722,9 +788,13 @@ export function adminEnvContract<V>(inputs: AdminEnvInputs<V>): CloudRunEnvVar<V
|
|
|
722
788
|
// where the tasks write their results (audit-job-setup.ts).
|
|
723
789
|
{ name: AUDIT_JOB_ENV_VAR, value: auditJobResource(projectId, inputs.region, env) },
|
|
724
790
|
{ name: AUDIT_RESULTS_BUCKET_ENV_VAR, value: gcpBucketName(projectId, env, "audit-results") },
|
|
725
|
-
// #
|
|
726
|
-
// (add-version only, see the run SA's secretVersionAdder
|
|
727
|
-
|
|
791
|
+
// #914 — where Settings → Google sign-in writes the install's Google
|
|
792
|
+
// sign-in client (add-version only, see the run SA's secretVersionAdder
|
|
793
|
+
// invariant).
|
|
794
|
+
{
|
|
795
|
+
name: OPERATOR_OAUTH_CLIENT_STORE_ENV,
|
|
796
|
+
value: operatorOAuthClientSecretResource(projectId, env),
|
|
797
|
+
},
|
|
728
798
|
];
|
|
729
799
|
if (inputs.provider === "gcp") {
|
|
730
800
|
// v0.2.78 — the GCS StaticPublisher: Stage uploads to staging,
|
|
@@ -850,17 +920,18 @@ function secretAccessors(): IamInvariant[] {
|
|
|
850
920
|
/** Bindings both GCP stacks declare beyond the secret accessors and telemetry. */
|
|
851
921
|
const GCP_COMMON: readonly IamInvariant[] = [
|
|
852
922
|
{
|
|
853
|
-
stackResource: "admin-
|
|
854
|
-
// Why the admin may write a secret at all: the Owner pastes the
|
|
855
|
-
//
|
|
856
|
-
// it where the gateway
|
|
857
|
-
//
|
|
858
|
-
//
|
|
923
|
+
stackResource: "admin-operator-oauth-client-version-adder",
|
|
924
|
+
// Why the admin may write a secret at all: the Owner pastes the
|
|
925
|
+
// install's Google sign-in client under Settings → Google sign-in, and
|
|
926
|
+
// the admin stores it where the gateway and the operator-access sync job
|
|
927
|
+
// read it. secretVersionAdder is add-version only (no access, no
|
|
928
|
+
// destroy, no IAM), bound on this one secret only — the admin can
|
|
929
|
+
// replace the client, never read it or any other secret.
|
|
859
930
|
role: SECRET_VERSION_ADDER,
|
|
860
931
|
member: "run-sa",
|
|
861
|
-
target: { kind: "secret", name: "
|
|
932
|
+
target: { kind: "secret", name: "operator-oauth-client" },
|
|
862
933
|
onFailure: "warn",
|
|
863
|
-
why: "the Owner's
|
|
934
|
+
why: "the Owner's Google sign-in setup (Settings → Google sign-in) stores the OAuth client; without it only that setup fails",
|
|
864
935
|
},
|
|
865
936
|
];
|
|
866
937
|
|
|
@@ -342,9 +342,10 @@ 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
|
|
346
|
-
|
|
345
|
+
// #806 — the remote-MCP broker's seal key and (#914) the Google
|
|
346
|
+
// sign-in client file path.
|
|
347
347
|
"CAELO_MCP_OAUTH_SEAL_KEY",
|
|
348
|
+
"CAELO_OPERATOR_OAUTH_CLIENT_FILE",
|
|
348
349
|
// #650 — the staging gate key arrives in the same roll.
|
|
349
350
|
"CAELO_STAGING_GATE_SECRET",
|
|
350
351
|
// #845 — and which forwarded address is the visitor.
|
|
@@ -312,13 +312,13 @@ 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_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,
|
|
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_OPERATOR_OAUTH_CLIENT_STORE=projects/acme/secrets/caelo-production-operator-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
320
|
// #806 — and where the remote-MCP broker's client file and seal key are.
|
|
321
|
-
"--update-env-vars=CAELO_TRUSTED_PROXY_HOPS=1,
|
|
321
|
+
"--update-env-vars=CAELO_TRUSTED_PROXY_HOPS=1,CAELO_OPERATOR_OAUTH_CLIENT_FILE=/secrets/operator-oauth-client/client.json",
|
|
322
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",
|
|
323
323
|
]);
|
|
324
324
|
});
|
|
@@ -789,12 +789,12 @@ describe("ensureStackInvariants", () => {
|
|
|
789
789
|
`secrets add-iam-policy-binding caelo-production-staging-gate-secret --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
|
|
790
790
|
// #806 — the remote-MCP broker's seal key and OAuth client file.
|
|
791
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-
|
|
792
|
+
`secrets add-iam-policy-binding caelo-production-operator-oauth-client --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
|
|
793
793
|
]);
|
|
794
794
|
// #806 — the admin may add versions to the OAuth client secret, and to no other.
|
|
795
795
|
const adders = calls.filter((c) => c.includes("roles/secretmanager.secretVersionAdder"));
|
|
796
796
|
expect(adders).toEqual([
|
|
797
|
-
`secrets add-iam-policy-binding caelo-production-
|
|
797
|
+
`secrets add-iam-policy-binding caelo-production-operator-oauth-client --project=acme --member=${RUN_SA} --role=roles/secretmanager.secretVersionAdder --quiet --format=none`,
|
|
798
798
|
]);
|
|
799
799
|
for (const secret of ["internal-secret", "tool-approval-secret", "secret-kek"]) {
|
|
800
800
|
expect(calls).toContain(
|
package/src/stack-converge.ts
CHANGED
|
@@ -43,8 +43,10 @@ import {
|
|
|
43
43
|
type MediaVolumeContract,
|
|
44
44
|
memoryQuantityMiB,
|
|
45
45
|
RETIRED_IAM_BINDINGS,
|
|
46
|
+
RETIRED_RUNTIME_SECRETS,
|
|
46
47
|
RETIRED_SERVICE_ENV,
|
|
47
48
|
type RetiredIamBinding,
|
|
49
|
+
type RetiredSecretFile,
|
|
48
50
|
type RuntimeEnvInputs,
|
|
49
51
|
type SecretFileContract,
|
|
50
52
|
type ServiceEnvInputs,
|
|
@@ -682,6 +684,53 @@ export async function removeRetiredIamBindings(
|
|
|
682
684
|
return outcomes;
|
|
683
685
|
}
|
|
684
686
|
|
|
687
|
+
/**
|
|
688
|
+
* Delete the secrets older contracts created and nothing reads any more
|
|
689
|
+
* ({@link RETIRED_RUNTIME_SECRETS}) — run by upgrade AFTER both services
|
|
690
|
+
* rolled onto revisions that no longer reference them (Cloud Run would
|
|
691
|
+
* refuse a revision whose secret is gone). Deleting a secret drops its IAM
|
|
692
|
+
* bindings with it. An absent secret is `absent`; never throws for a
|
|
693
|
+
* gcloud failure.
|
|
694
|
+
*/
|
|
695
|
+
export async function deleteRetiredSecrets(
|
|
696
|
+
install: Pick<InstallTarget, "projectId" | "env">,
|
|
697
|
+
deps: {
|
|
698
|
+
run?: GcloudRunner;
|
|
699
|
+
retired?: readonly { readonly name: string; readonly why: string }[];
|
|
700
|
+
} = {},
|
|
701
|
+
): Promise<RetiredBindingOutcome[]> {
|
|
702
|
+
const run = deps.run ?? defaultGcloud;
|
|
703
|
+
const outcomes: RetiredBindingOutcome[] = [];
|
|
704
|
+
for (const s of deps.retired ?? RETIRED_RUNTIME_SECRETS) {
|
|
705
|
+
const id = `secret ${gcpSecretId(install.env, s.name)}`;
|
|
706
|
+
const project = `--project=${install.projectId}`;
|
|
707
|
+
const describe = await run([
|
|
708
|
+
"secrets",
|
|
709
|
+
"describe",
|
|
710
|
+
gcpSecretId(install.env, s.name),
|
|
711
|
+
project,
|
|
712
|
+
"--format=value(name)",
|
|
713
|
+
]);
|
|
714
|
+
if (!describe.ok && RESOURCE_MISSING.test(describe.stderr)) {
|
|
715
|
+
outcomes.push({ id, status: "absent", why: s.why });
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
const del = await run([
|
|
719
|
+
"secrets",
|
|
720
|
+
"delete",
|
|
721
|
+
gcpSecretId(install.env, s.name),
|
|
722
|
+
project,
|
|
723
|
+
"--quiet",
|
|
724
|
+
]);
|
|
725
|
+
outcomes.push(
|
|
726
|
+
del.ok || RESOURCE_MISSING.test(del.stderr)
|
|
727
|
+
? { id, status: del.ok ? "removed" : "absent", why: s.why }
|
|
728
|
+
: { id, status: "failed", why: s.why, error: del.stderr.trim() },
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
return outcomes;
|
|
732
|
+
}
|
|
733
|
+
|
|
685
734
|
/** The memory limit of the service's container (`512Mi`, `2Gi`), or null
|
|
686
735
|
* when the service declares none (Cloud Run then runs it with 512Mi). */
|
|
687
736
|
export function liveContainerMemory(serviceJson: string): string | null {
|
|
@@ -895,9 +944,21 @@ export function planSecretFiles(
|
|
|
895
944
|
live: LiveVolumes,
|
|
896
945
|
files: readonly SecretFileContract[],
|
|
897
946
|
env: string,
|
|
947
|
+
retired: readonly RetiredSecretFile[] = [],
|
|
898
948
|
): RollAdditionsPlan {
|
|
899
949
|
const flags: string[] = [];
|
|
900
950
|
const changes: string[] = [];
|
|
951
|
+
// #914 — mounts an older contract made and nothing reads any more
|
|
952
|
+
// (RETIRED_SECRET_FILES) go in the same revision that mounts their
|
|
953
|
+
// replacement. `--remove-secrets=<dir>/<file>` drops the file, and the
|
|
954
|
+
// mount with it when it was the volume's only file (gcloud
|
|
955
|
+
// `_PruneManagedVolumeMapping`). Repeated `--remove-secrets` /
|
|
956
|
+
// `--update-secrets` flags merge (gcloud map flags use UpdateAction).
|
|
957
|
+
for (const old of retired) {
|
|
958
|
+
if (!live.mounts.some((m) => m.mountPath === old.mountDir)) continue;
|
|
959
|
+
flags.push(`--remove-secrets=${old.mountDir}/${old.fileName}`);
|
|
960
|
+
changes.push(`secret file ${old.mountDir}/${old.fileName}: removed (${old.why})`);
|
|
961
|
+
}
|
|
901
962
|
for (const file of files) {
|
|
902
963
|
const secretId = gcpSecretId(env, file.secret);
|
|
903
964
|
const mount = live.mounts.find((m) => m.mountPath === file.mountDir);
|
package/src/upgrade/app-roll.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
adminMediaVolume,
|
|
32
32
|
type CloudRunEnvVar,
|
|
33
33
|
GATEWAY_TIMEOUT_SECONDS,
|
|
34
|
+
RETIRED_SECRET_FILES,
|
|
34
35
|
SERVICE_SECRET_FILES,
|
|
35
36
|
} from "../stack-contract.js";
|
|
36
37
|
import {
|
|
@@ -197,12 +198,17 @@ export async function planAppRoll(
|
|
|
197
198
|
);
|
|
198
199
|
if (!media.ok)
|
|
199
200
|
return { ok: false, error: `the admin's media volume can't be set up: ${media.error}` };
|
|
200
|
-
// #806 — each service's secret files (the gateway's
|
|
201
|
+
// #806/#914 — each service's secret files (the gateway's Google sign-in
|
|
201
202
|
// client). A file the roll cannot mount fails the plan: the new gateway
|
|
202
203
|
// reads its path from the env contract.
|
|
203
204
|
const secretFiles = {} as Record<RollSlug, { flags: string[]; changes: string[] }>;
|
|
204
205
|
for (const s of services) {
|
|
205
|
-
const files = planSecretFiles(
|
|
206
|
+
const files = planSecretFiles(
|
|
207
|
+
s.liveVolumes,
|
|
208
|
+
SERVICE_SECRET_FILES[s.slug],
|
|
209
|
+
install.env,
|
|
210
|
+
RETIRED_SECRET_FILES[s.slug],
|
|
211
|
+
);
|
|
206
212
|
if (!files.ok) {
|
|
207
213
|
return { ok: false, error: `the ${s.slug}'s secret files can't be mounted: ${files.error}` };
|
|
208
214
|
}
|
|
@@ -37,7 +37,9 @@ export async function describeAuditJobImage(
|
|
|
37
37
|
"--format=value(spec.template.spec.template.spec.containers[0].image)",
|
|
38
38
|
]);
|
|
39
39
|
if (!r.ok) {
|
|
40
|
-
|
|
40
|
+
// gcloud's own wording for a missing job is "Cannot find job [<name>]."
|
|
41
|
+
// (no NOT_FOUND code), so match that too.
|
|
42
|
+
return /NOT_FOUND|not found|could not be found|cannot find/i.test(r.stderr)
|
|
41
43
|
? { ok: true, image: null }
|
|
42
44
|
: { ok: false, error: r.stderr.trim() };
|
|
43
45
|
}
|
package/src/upgrade/converge.ts
CHANGED
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
UPGRADER_JOB_ENV_VAR,
|
|
58
58
|
} from "../stack-contract.js";
|
|
59
59
|
import {
|
|
60
|
+
deleteRetiredSecrets,
|
|
60
61
|
ensureStackInvariants,
|
|
61
62
|
liveEnvHasInlinePassword,
|
|
62
63
|
removeRetiredIamBindings,
|
|
@@ -364,12 +365,22 @@ export function startConverge(
|
|
|
364
365
|
runDeps,
|
|
365
366
|
);
|
|
366
367
|
if (resolved.ok) {
|
|
367
|
-
s.done(
|
|
368
|
+
s.done(
|
|
369
|
+
`Google sign-in and remote MCP can be set up under Settings → Google sign-in (${resolved.identity.mcpPublicUrl})`,
|
|
370
|
+
);
|
|
371
|
+
if (resolved.orgDomainError) {
|
|
372
|
+
// #914 — the admin then refuses new users until Google sign-in is
|
|
373
|
+
// set up (it cannot tell who IAP's built-in sign-in admits). Not a
|
|
374
|
+
// converge failure: the Owner's sign-in setup resolves it.
|
|
375
|
+
r.warn(
|
|
376
|
+
`Could not read this project's Google organisation (${resolved.orgDomainError}). Until Google sign-in is set up under Settings → Google sign-in, the admin refuses to add users; or re-run upgrade as an account with the Organization Viewer role.`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
368
379
|
return remoteMcpIdentityEnv(resolved.identity);
|
|
369
380
|
}
|
|
370
381
|
s.warned(`Remote-MCP env not resolved: ${resolved.error}`);
|
|
371
382
|
r.warn(
|
|
372
|
-
"The upgrade continues; Settings →
|
|
383
|
+
"The upgrade continues; Settings → Google sign-in and Settings → MCP report this install as not prepared until this succeeds. Re-run upgrade once the cause is fixed.",
|
|
373
384
|
);
|
|
374
385
|
incomplete = true;
|
|
375
386
|
return null;
|
|
@@ -602,13 +613,18 @@ export function startConverge(
|
|
|
602
613
|
// #613 — with both services on their new revisions, drop the bindings
|
|
603
614
|
// nothing reads through any more (above all the gateway SA's access to
|
|
604
615
|
// admin_role's password). Loud on failure.
|
|
605
|
-
const retired =
|
|
616
|
+
const retired = [
|
|
617
|
+
...(await removeRetiredIamBindings(target, runDeps)),
|
|
618
|
+
// #914 — secrets nothing references any more (the renamed Google
|
|
619
|
+
// sign-in client); their IAM bindings go with them.
|
|
620
|
+
...(await deleteRetiredSecrets(target, runDeps)),
|
|
621
|
+
];
|
|
606
622
|
for (const o of retired) {
|
|
607
623
|
if (o.status === "removed") r.info(` removed: ${o.id} (${o.why})`);
|
|
608
624
|
if (o.status === "failed") {
|
|
609
625
|
incomplete = true;
|
|
610
626
|
r.error(
|
|
611
|
-
` NOT removed: ${o.id} — ${o.why}\n ${o.error ?? ""}\n Re-run upgrade with an account that may change IAM, or remove
|
|
627
|
+
` NOT removed: ${o.id} — ${o.why}\n ${o.error ?? ""}\n Re-run upgrade with an account that may change IAM and secrets, or remove it by hand.`,
|
|
612
628
|
);
|
|
613
629
|
}
|
|
614
630
|
}
|
|
@@ -684,7 +684,7 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
|
|
|
684
684
|
expect(gateway).toContain(`CAELO_ADMIN_URL=https://${ADMIN}-xyz.a.run.app`);
|
|
685
685
|
expect(gateway).toContain(`CAELO_MCP_PUBLIC_URL=https://${GATEWAY}-xyz.a.run.app`);
|
|
686
686
|
expect(gateway).toContain(
|
|
687
|
-
"--update-secrets=/secrets/
|
|
687
|
+
"--update-secrets=/secrets/operator-oauth-client/client.json=caelo-production-operator-oauth-client:latest",
|
|
688
688
|
);
|
|
689
689
|
expect(gateway).toContain("--timeout=3600");
|
|
690
690
|
expect(admin).not.toContain("--timeout");
|
package/src/wizards/gcp-cost.ts
CHANGED
|
@@ -166,7 +166,7 @@ export function estimateGcpCost(inputs: CostEstimateInputs): {
|
|
|
166
166
|
name: "Secret Manager (runtime secrets)",
|
|
167
167
|
monthlyUsd: 0.2,
|
|
168
168
|
notes:
|
|
169
|
-
"6 active versions free, then ~$0.06 each;
|
|
169
|
+
"6 active versions free, then ~$0.06 each; the Google sign-in client (#914) and the remote-MCP seal key (#806)",
|
|
170
170
|
},
|
|
171
171
|
{
|
|
172
172
|
name: "BigQuery edge log sink",
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
alignedStackSettings,
|
|
8
8
|
assertStackRegion,
|
|
9
9
|
deployedStackRegion,
|
|
10
|
+
presentImageOverrides,
|
|
10
11
|
pulumiGcpEnv,
|
|
11
12
|
resolveSecretReplication,
|
|
12
13
|
stateOnlyEnv,
|
|
@@ -244,3 +245,17 @@ describe("stateOnlyEnv (#696)", () => {
|
|
|
244
245
|
expect(env.PULUMI_BACKEND_URL).toBe("gs://acme-caelo-ops/pulumi");
|
|
245
246
|
});
|
|
246
247
|
});
|
|
248
|
+
|
|
249
|
+
describe("presentImageOverrides", () => {
|
|
250
|
+
it("reads the settings file as-is, so a stack missing its required keys still pins (regression: `pulumi config` refused to list it and every upgrade from a new CLI version failed)", () => {
|
|
251
|
+
const freshBunxSettings = { "caelo-gcp-firebase:image-digest-admin": "sha256:a" };
|
|
252
|
+
expect(presentImageOverrides("gcp-firebase", freshBunxSettings)).toEqual([]);
|
|
253
|
+
expect(presentImageOverrides("gcp", { "caelo-gcp:image-admin": "x:1" })).toEqual([
|
|
254
|
+
"caelo-gcp:image-admin",
|
|
255
|
+
]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("finds nothing in a settings file without config", () => {
|
|
259
|
+
expect(presentImageOverrides("gcp", undefined)).toEqual([]);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -150,12 +150,28 @@ export async function writeImageDigestsToStack(inputs: {
|
|
|
150
150
|
}): Promise<{ removedOverrides: string[] }> {
|
|
151
151
|
const stack = await selectInstallStack(inputs.backend, inputs.provider);
|
|
152
152
|
await stack.setAllConfig(imageDigestConfig(inputs.provider, inputs.digests));
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
// Read the settings file, not `getAllConfig()`: `pulumi config` refuses
|
|
154
|
+
// to list a stack whose required keys (domain, project, …) are unset, and
|
|
155
|
+
// the settings file in a freshly unpacked `bunx` package holds only what
|
|
156
|
+
// this call just wrote — so every upgrade from a new CLI version failed.
|
|
157
|
+
const settings = await stack.workspace.stackSettings(stack.name);
|
|
158
|
+
const removedOverrides = presentImageOverrides(inputs.provider, settings.config);
|
|
155
159
|
if (removedOverrides.length > 0) await stack.removeAllConfig(removedOverrides);
|
|
156
160
|
return { removedOverrides };
|
|
157
161
|
}
|
|
158
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The image-override keys set in a stack settings file's `config`.
|
|
165
|
+
*
|
|
166
|
+
* @param config the settings file's `config` map (absent when the file has none)
|
|
167
|
+
*/
|
|
168
|
+
export function presentImageOverrides(
|
|
169
|
+
provider: "gcp" | "gcp-firebase",
|
|
170
|
+
config: Record<string, unknown> | undefined,
|
|
171
|
+
): string[] {
|
|
172
|
+
return imageOverrideKeys(provider).filter((k) => config !== undefined && k in config);
|
|
173
|
+
}
|
|
174
|
+
|
|
159
175
|
/**
|
|
160
176
|
* One output of the install's stack, secrets included (the Automation API's
|
|
161
177
|
* `pulumi stack output --show-secrets`). Read at the moment of use, never
|