@caelo-cms/provisioning 0.10.70 → 0.10.71

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.
Files changed (38) hide show
  1. package/dist/remote-mcp.d.ts +27 -8
  2. package/dist/remote-mcp.d.ts.map +1 -1
  3. package/dist/remote-mcp.js +88 -56
  4. package/dist/remote-mcp.js.map +1 -1
  5. package/dist/stack-contract.d.ts +53 -39
  6. package/dist/stack-contract.d.ts.map +1 -1
  7. package/dist/stack-contract.js +110 -0
  8. package/dist/stack-contract.js.map +1 -1
  9. package/dist/state-command.d.ts.map +1 -1
  10. package/dist/state-command.js +24 -0
  11. package/dist/state-command.js.map +1 -1
  12. package/dist/state-pulumi.d.ts +7 -0
  13. package/dist/state-pulumi.d.ts.map +1 -1
  14. package/dist/state-pulumi.js +11 -0
  15. package/dist/state-pulumi.js.map +1 -1
  16. package/dist/upgrade/converge.d.ts.map +1 -1
  17. package/dist/upgrade/converge.js +4 -2
  18. package/dist/upgrade/converge.js.map +1 -1
  19. package/dist/wizards/gcp-pulumi.d.ts +9 -1
  20. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  21. package/dist/wizards/gcp-pulumi.js +8 -1
  22. package/dist/wizards/gcp-pulumi.js.map +1 -1
  23. package/dist/wizards/gcp.d.ts.map +1 -1
  24. package/dist/wizards/gcp.js +86 -41
  25. package/dist/wizards/gcp.js.map +1 -1
  26. package/package.json +2 -2
  27. package/src/remote-mcp-stack-config.test.ts +195 -0
  28. package/src/remote-mcp.test.ts +99 -16
  29. package/src/remote-mcp.ts +104 -58
  30. package/src/stack-contract-parity.test.ts +28 -3
  31. package/src/stack-contract.ts +107 -6
  32. package/src/state-command.ts +32 -0
  33. package/src/state-pulumi.ts +21 -0
  34. package/src/upgrade/converge.ts +4 -2
  35. package/src/wizards/gcp-pulumi.ts +21 -1
  36. package/src/wizards/gcp.ts +115 -51
  37. package/stacks/gcp/index.ts +13 -2
  38. package/stacks/gcp-firebase/index.ts +20 -6
@@ -34,6 +34,7 @@
34
34
  * Pure module — the stacks import it from `dist/`.
35
35
  */
36
36
 
37
+ import { z } from "zod";
37
38
  import type { SupportedProvider } from "./adapter.js";
38
39
  import {
39
40
  auditJobResource,
@@ -642,12 +643,21 @@ export function operatorOAuthClientSecretResource(projectId: string, env: string
642
643
  * its own name; the gateway's public URL is its own URL; the admin's URL is
643
644
  * a service created after the gateway), and on `gcp` the audience is the
644
645
  * numeric id of a backend service created after the admin. A resource
645
- * cannot take its own (or a later resource's) output as input. So the CLI
646
- * reads the live identities after deploy and applies these vars with gcloud
647
- * — the wizard after every `pulumi up`, `upgrade` after every roll
648
- * (remote-mcp.ts `ensureRemoteMcpIdentityEnv`). A roll that does not set
649
- * them leaves them as they are (planEnvUpdate never removes a var the
650
- * contract does not own).
646
+ * cannot take its own (or a later resource's) output as input.
647
+ *
648
+ * So the CLI reads the live identities and hands them to the stack as
649
+ * config ({@link REMOTE_MCP_IDENTITY_CONFIG}, the way the image digests are
650
+ * pinned), and the stacks declare the vars from it
651
+ * ({@link remoteMcpIdentityStackEnv}): the stack is their one owner, and a
652
+ * `pulumi up` keeps them (#1082 — before, the CLI set them with gcloud
653
+ * after every `up`, every `up` removed them again, and `state migrate`'s
654
+ * preview could never come back clean). The installer resolves them before
655
+ * every `up` of a deployed install, and on a fresh one after the first `up`
656
+ * followed by a second (wizards/gcp.ts); `state migrate`'s preview resolves
657
+ * them the same way (state-pulumi.ts). `upgrade`, which does not run
658
+ * Pulumi, applies the same derived values with its gcloud roll (like the
659
+ * rest of the env contract); a roll that does not set them leaves them as
660
+ * they are (planEnvUpdate never removes a var the contract does not own).
651
661
  *
652
662
  * #914 adds two the admin's Google sign-in needs: its own URL (the
653
663
  * self-check opens it signed out) and the project's Google organisation
@@ -752,6 +762,97 @@ export function remoteMcpIdentityEnv(
752
762
  };
753
763
  }
754
764
 
765
+ /**
766
+ * #1082 — stack config key (`caelo-<provider>:remoteMcpIdentity`) carrying
767
+ * the {@link RemoteMcpIdentity} as JSON. Written by the CLI before `up`,
768
+ * read by the stacks ({@link remoteMcpIdentityStackEnv}).
769
+ */
770
+ export const REMOTE_MCP_IDENTITY_CONFIG = "remoteMcpIdentity";
771
+
772
+ const remoteMcpIdentitySchema = z
773
+ .object({
774
+ adminUrl: z.string().regex(/^https:\/\/[^\s/]+$/),
775
+ mcpPublicUrl: z.string().regex(/^https:\/\/[^\s/]+$/),
776
+ iapAudience: z.string().regex(/^\/projects\/\d+\/[^\s]+$/),
777
+ googleOrgDomain: z
778
+ .string()
779
+ .regex(/^([a-z0-9.-]+\.[a-z]{2,}|none)$/)
780
+ .optional(),
781
+ })
782
+ .strict();
783
+
784
+ /** {@link RemoteMcpIdentity} as the value of {@link REMOTE_MCP_IDENTITY_CONFIG}. */
785
+ export function serializeRemoteMcpIdentity(id: RemoteMcpIdentity): string {
786
+ return JSON.stringify(remoteMcpIdentitySchema.parse(id));
787
+ }
788
+
789
+ /**
790
+ * Parse the {@link REMOTE_MCP_IDENTITY_CONFIG} value; null when unset (a
791
+ * fresh install before its first `up`).
792
+ *
793
+ * @throws Error naming the key when the value is not a valid identity — a
794
+ * hand-edited or corrupt config must stop the `up`, not silently drop
795
+ * the vars.
796
+ */
797
+ export function parseRemoteMcpIdentityConfig(raw: string | undefined): RemoteMcpIdentity | null {
798
+ if (raw === undefined || raw === "") return null;
799
+ let json: unknown;
800
+ try {
801
+ json = JSON.parse(raw);
802
+ } catch {
803
+ throw new Error(
804
+ `stack config ${REMOTE_MCP_IDENTITY_CONFIG} is not JSON; delete the key and re-run the installer, which writes it again from the live install`,
805
+ );
806
+ }
807
+ const parsed = remoteMcpIdentitySchema.safeParse(json);
808
+ if (!parsed.success) {
809
+ throw new Error(
810
+ `stack config ${REMOTE_MCP_IDENTITY_CONFIG} is not a remote-MCP identity (${parsed.error.issues.map((i) => i.path.join(".") || i.message).join(", ")}); delete the key and re-run the installer, which writes it again from the live install`,
811
+ );
812
+ }
813
+ return parsed.data;
814
+ }
815
+
816
+ /**
817
+ * The remote-MCP identity env each Cloud Run service declares in the stacks:
818
+ * {@link remoteMcpIdentityEnv} of the configured identity, nothing while the
819
+ * config is unset.
820
+ */
821
+ export function remoteMcpIdentityStackEnv(
822
+ raw: string | undefined,
823
+ ): Record<CloudRunSlug, CloudRunEnvVar[]> {
824
+ const id = parseRemoteMcpIdentityConfig(raw);
825
+ return id ? remoteMcpIdentityEnv(id) : { admin: [], gateway: [] };
826
+ }
827
+
828
+ /** Whether two identities would give the services the same env. */
829
+ export function sameRemoteMcpIdentity(
830
+ a: RemoteMcpIdentity | null,
831
+ b: RemoteMcpIdentity | null,
832
+ ): boolean {
833
+ if (a === null || b === null) return a === b;
834
+ return (
835
+ a.adminUrl === b.adminUrl &&
836
+ a.mcpPublicUrl === b.mcpPublicUrl &&
837
+ a.iapAudience === b.iapAudience &&
838
+ a.googleOrgDomain === b.googleOrgDomain
839
+ );
840
+ }
841
+
842
+ /**
843
+ * #1082 — Pulumi options both stacks give the Cloud Run v2 services.
844
+ * `client` / `clientVersion` only record which tool deployed the service
845
+ * last: `gcloud` sets them on every `upgrade` roll, and the provider
846
+ * (terraform-provider-google `google_cloud_run_v2_service`: `Optional`, not
847
+ * `Computed`) reads them back on refresh, so the stack, which leaves them
848
+ * unset, would plan an update on every service `upgrade` ever rolled. They
849
+ * affect nothing that runs. `ignoreChanges` is Pulumi's way to leave a
850
+ * property to another writer. (The env itself needs no such option: the
851
+ * provider holds `containers.env` as a set, so gcloud's reordering is no
852
+ * diff — only a missing or different var is.)
853
+ */
854
+ export const CLOUD_RUN_IGNORE_CHANGES: readonly string[] = ["client", "clientVersion"];
855
+
755
856
  /**
756
857
  * The env of the admin service.
757
858
  *
@@ -16,6 +16,7 @@ import { confirm, isCancel, log, note } from "@clack/prompts";
16
16
  import { bold, red } from "kleur/colors";
17
17
  import { adoptionCandidates, existingCandidates } from "./adopt-existing.js";
18
18
  import { checkGcloudAuth, gcloud } from "./gcloud.js";
19
+ import { GCP_STACK_ENV } from "./gcp-names.js";
19
20
  import {
20
21
  adoptStateBackend,
21
22
  currentMetadata,
@@ -33,7 +34,13 @@ import { installIapAllowlist } from "./operator-access.js";
33
34
  import { ensureOpsBackend } from "./ops-backend.js";
34
35
  import { writePlanFile } from "./preview-plan.js";
35
36
  import { provisionerServiceAccountEmail } from "./provisioner-identity.js";
37
+ import {
38
+ findRemoteMcpTarget,
39
+ type RemoteMcpTarget,
40
+ resolveStackRemoteMcpIdentity,
41
+ } from "./remote-mcp.js";
36
42
  import { selfCommand } from "./self-command.js";
43
+ import type { RemoteMcpIdentity } from "./stack-contract.js";
37
44
  import { attachInstall } from "./state-attach.js";
38
45
  import {
39
46
  gcsBackendRecord,
@@ -192,6 +199,14 @@ export async function stateCommand(argv: readonly string[]): Promise<void> {
192
199
  iapAllowlist: installIapAllowlist(meta.ownerEmail),
193
200
  imageDigests,
194
201
  },
202
+ resolveRemoteMcpIdentity: () =>
203
+ resolveRemoteMcpIdentityForPreview({
204
+ provider,
205
+ projectId,
206
+ region,
207
+ env: GCP_STACK_ENV,
208
+ domain: meta.domain,
209
+ }),
195
210
  }),
196
211
  report: clackReporter(),
197
212
  resolve: (record) =>
@@ -323,3 +338,20 @@ export function adcNote(run: SyncGcloud): string | null {
323
338
  if (r.ok) return null;
324
339
  return "No Application Default Credentials on this machine. upgrade, status and backup don't need them; re-running the installer or `state migrate` does: gcloud auth application-default login";
325
340
  }
341
+
342
+ /**
343
+ * #1082 — the remote-MCP identity a preview gives the stack, read from the
344
+ * live install exactly as the installer reads it before `up`
345
+ * (remote-mcp.ts resolveStackRemoteMcpIdentity). Unreadable: null, with a
346
+ * warning — the stack's recorded config then stands, and the preview shows
347
+ * any difference from what the services carry.
348
+ */
349
+ async function resolveRemoteMcpIdentityForPreview(
350
+ base: Omit<RemoteMcpTarget, "services">,
351
+ ): Promise<RemoteMcpIdentity | null> {
352
+ const found = await findRemoteMcpTarget(base);
353
+ const resolved = found.ok ? await resolveStackRemoteMcpIdentity(found.target) : found;
354
+ if (resolved.ok) return resolved.identity;
355
+ log.warn(`Could not read the remote-MCP URLs and IAP audience: ${resolved.error}`);
356
+ return null;
357
+ }
@@ -20,6 +20,11 @@ import * as automation from "@pulumi/pulumi/automation";
20
20
  import { redactCredentials } from "./checkpoint-secrets.js";
21
21
  import { GCP_STACK_ENV } from "./gcp-names.js";
22
22
  import { collectPreviewSteps } from "./preview-plan.js";
23
+ import {
24
+ REMOTE_MCP_IDENTITY_CONFIG,
25
+ type RemoteMcpIdentity,
26
+ serializeRemoteMcpIdentity,
27
+ } from "./stack-contract.js";
23
28
  import type { ResolvedBackend } from "./state-backend.js";
24
29
  import type { StatePulumiOps } from "./state-migrate.js";
25
30
  import {
@@ -68,6 +73,12 @@ export interface RealPulumiOpsInputs {
68
73
  readonly impersonateServiceAccount: string;
69
74
  /** The config a wizard run sets, so a preview shows real drift only. */
70
75
  readonly stackConfig: StackConfigInputs;
76
+ /**
77
+ * #1082 — the remote-MCP identity read from the live install, as the
78
+ * installer reads it before its `up` (null when it cannot be read: the
79
+ * stack's config then stays as it is). Asked once per preview.
80
+ */
81
+ readonly resolveRemoteMcpIdentity: () => Promise<RemoteMcpIdentity | null>;
71
82
  }
72
83
 
73
84
  /** {@link StatePulumiOps} backed by the `pulumi` CLI + Automation API. */
@@ -195,6 +206,16 @@ export function realStatePulumiOps(inputs: RealPulumiOpsInputs): StatePulumiOps
195
206
  []) as StateResource[];
196
207
  await applyStackConfig(stack, inputs.stackConfig, resources);
197
208
  }
209
+ // #1082 — the identity env the stack declares, set the way the
210
+ // installer sets it before every `up`: from the live install. A stack
211
+ // last applied before the stack owned it has no such config yet, and
212
+ // its preview would show the vars removed.
213
+ const identity = await inputs.resolveRemoteMcpIdentity();
214
+ if (identity) {
215
+ await stack.setConfig(`caelo-${inputs.provider}:${REMOTE_MCP_IDENTITY_CONFIG}`, {
216
+ value: serializeRemoteMcpIdentity(identity),
217
+ });
218
+ }
198
219
  await assertStackProject(stack, inputs.provider, inputs.stackConfig.projectId);
199
220
  // #737 — the steps themselves, not just the summary's count: a
200
221
  // refusal lists what would change, replaces and deletes first.
@@ -364,8 +364,10 @@ export function startConverge(
364
364
  // name identities the env contract cannot (stack-contract.ts
365
365
  // RemoteMcpIdentity). Resolved here, read-only, and set by the rolls
366
366
  // (AppRollHooks.afterMigrations `extraEnv`), so they ride the new
367
- // revisions. A failure leaves the vars as the services have them and
368
- // warns: only remote MCP depends on them.
367
+ // revisions. The stack declares the same derived values from the config
368
+ // the installer writes before `up` (#1082), so neither writer undoes the
369
+ // other. A failure leaves the vars as the services have them and warns:
370
+ // only remote MCP depends on them.
369
371
  const s = r.step("Resolving the remote-MCP URLs and IAP audience...");
370
372
  const resolved = await resolveRemoteMcpIdentity(
371
373
  {
@@ -30,7 +30,12 @@ import {
30
30
  withoutAmbientGoogleCredentials,
31
31
  } from "../provisioner-identity.js";
32
32
  import { ensureGeneratedSecrets, stackSecretReplication } from "../runtime-secrets.js";
33
- import { ADMIN_MEMORY_DEFAULT } from "../stack-contract.js";
33
+ import {
34
+ ADMIN_MEMORY_DEFAULT,
35
+ REMOTE_MCP_IDENTITY_CONFIG,
36
+ type RemoteMcpIdentity,
37
+ serializeRemoteMcpIdentity,
38
+ } from "../stack-contract.js";
34
39
  import type { ResolvedBackend } from "../state-backend.js";
35
40
 
36
41
  /**
@@ -96,6 +101,13 @@ export interface PulumiUpInputs {
96
101
  * Defaults to 'gcp' for backwards compatibility.
97
102
  */
98
103
  provider?: "gcp" | "gcp-firebase";
104
+ /**
105
+ * #1082 — the remote-MCP identity the stack declares as env
106
+ * (stack-contract.ts REMOTE_MCP_IDENTITY_CONFIG), resolved from the live
107
+ * install. Null when it could not be resolved (a fresh install before its
108
+ * first `up`): the config keeps what an earlier run wrote.
109
+ */
110
+ remoteMcpIdentity?: RemoteMcpIdentity | null;
99
111
  }
100
112
 
101
113
  export interface PulumiUpResult {
@@ -456,6 +468,7 @@ export type StackConfigInputs = Pick<
456
468
  | "wafAdaptiveProtection"
457
469
  | "iapAllowlist"
458
470
  | "imageDigests"
471
+ | "remoteMcpIdentity"
459
472
  > & { provider: "gcp" | "gcp-firebase" };
460
473
 
461
474
  /**
@@ -494,6 +507,13 @@ export async function applyStackConfig(
494
507
  // v0.3.2 — anthropicApiKey config dropped. Runtime path is
495
508
  // /settings/ai → ai_providers (KEK-encrypted).
496
509
  ...imageDigestConfig(provider, inputs.imageDigests),
510
+ ...(inputs.remoteMcpIdentity
511
+ ? {
512
+ [`${ns}:${REMOTE_MCP_IDENTITY_CONFIG}`]: {
513
+ value: serializeRemoteMcpIdentity(inputs.remoteMcpIdentity),
514
+ },
515
+ }
516
+ : {}),
497
517
  });
498
518
 
499
519
  // Read back only after the required keys are set: `pulumi config` refuses to
@@ -105,10 +105,20 @@ import {
105
105
  } from "../provisioner-identity.js";
106
106
  import { revokePublicRoleMemberships } from "../public-role-isolation.js";
107
107
  import { decideRegion, detectCliRegion, installableRegions, suggestRegion } from "../regions.js";
108
- import { ensureRemoteMcpEdge, ensureRemoteMcpIdentityEnv } from "../remote-mcp.js";
108
+ import {
109
+ checkRemoteMcpIdentityEnv,
110
+ ensureRemoteMcpEdge,
111
+ findRemoteMcpTarget,
112
+ type RemoteMcpTarget,
113
+ resolveStackRemoteMcpIdentity,
114
+ } from "../remote-mcp.js";
109
115
  import type { RunMode } from "../run-mode.js";
110
116
  import { selfCommand } from "../self-command.js";
111
- import { ABSENT_DEFAULT_FIREWALL_RULES } from "../stack-contract.js";
117
+ import {
118
+ ABSENT_DEFAULT_FIREWALL_RULES,
119
+ type RemoteMcpIdentity,
120
+ sameRemoteMcpIdentity,
121
+ } from "../stack-contract.js";
112
122
  import {
113
123
  chooseInstallBackend,
114
124
  describeBackend,
@@ -120,7 +130,6 @@ import {
120
130
  resolveBackend,
121
131
  } from "../state-backend.js";
122
132
  import { acquireStateLock, GcsStateStore } from "../state-store.js";
123
- import { lookupFailure, resolveGcpResourceName } from "../upgrade/gcp-lookup.js";
124
133
  import { createReleaseImageForms, platformDigestsOf } from "../upgrade/image-forms.js";
125
134
  import {
126
135
  releaseVersionForDigest,
@@ -379,12 +388,22 @@ export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
379
388
  // post-cleanup re-runs both pass through cleanly).
380
389
  await stepDrainLegacyCaeloAdminUser(projectId, region);
381
390
 
391
+ // === 9.8. The remote-MCP identity the stack declares (#1082) ===
392
+ // The stack owns the identity env vars (stack-contract.ts
393
+ // RemoteMcpIdentity) and declares them from config the CLI resolves from
394
+ // the live services, so this `up` keeps what they carry. A fresh install
395
+ // has no services yet: the identity is resolved after the first `up` and
396
+ // applied by a second one below.
397
+ const identityBefore = deployed
398
+ ? await stepResolveRemoteMcpIdentity(projectId, region, provider, domain, "before")
399
+ : null;
400
+
382
401
  // === 10. Pulumi up via Automation SDK ===
383
402
  // Pre-built signed images live at the Caelo-team public AR repo
384
403
  // (caelo-website/caelo-cms-images by default). Cloud Run reads them
385
404
  // directly with no operator-side IAM binding — anonymous public pull.
386
405
  phase("Provisioning the infrastructure (pulumi up, 8-15 min)");
387
- await stepPulumiUp(installId, {
406
+ const upOpts: PulumiUpOpts = {
388
407
  projectId,
389
408
  domain,
390
409
  // #650 — the wizard recorded it (or the install predates the setting).
@@ -406,9 +425,24 @@ export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
406
425
  // matching config namespace.
407
426
  provider,
408
427
  nonInteractive: opts.nonInteractive,
409
- });
428
+ };
429
+ await stepPulumiUp(installId, { ...upOpts, remoteMcpIdentity: identityBefore?.identity ?? null });
410
430
  recordImageDigests(installId, imageDigests);
411
431
 
432
+ // === 10.05. The identity once the services exist (#1082) ===
433
+ // Read again after every `up`: on a fresh install (or one whose identity
434
+ // could not be read before) this is the first time it can be, and an `up`
435
+ // that replaced a service changed it. A second `up` then applies it
436
+ // through the stack — never through gcloud, which the next `up` would
437
+ // undo.
438
+ const identity = await stepResolveRemoteMcpIdentity(projectId, region, provider, domain, "after");
439
+ if (identity && !sameRemoteMcpIdentity(identity.identity, identityBefore?.identity ?? null)) {
440
+ log.info(
441
+ "Applying the remote-MCP URLs and IAP audience through the stack (second pulumi up)...",
442
+ );
443
+ await stepPulumiUp(installId, { ...upOpts, remoteMcpIdentity: identity.identity });
444
+ }
445
+
412
446
  // === 10.1. Remove the default network's pre-populated firewall rules ===
413
447
  // GCP creates them when Compute Engine is enabled (step 5). Run after
414
448
  // Pulumi rather than right after enabling the API: the default network
@@ -489,11 +523,9 @@ export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
489
523
  keepSigner ? [`serviceAccount:${upgraderServiceAccountEmail(projectId, GCP_STACK_ENV)}`] : [],
490
524
  );
491
525
 
492
- // === 11.6. Remote MCP (#806): LB rules (gcp) + the identity env ===
493
- // After `pulumi up` on every pass: the identity env names services Pulumi
494
- // generated, so the stack cannot declare it, and an `up` that changed a
495
- // service's template dropped it (remote-mcp.ts).
496
- const remoteMcpReady = await stepRemoteMcp(projectId, region, provider, domain);
526
+ // === 11.6. Remote MCP (#806): LB rules (gcp) + a check of the identity env ===
527
+ // The env itself came with `pulumi up` (step 10.05, #1082).
528
+ const remoteMcpReady = await stepRemoteMcp(projectId, provider, domain, identity);
497
529
 
498
530
  // A fresh install ran every converge step above, so it is at this CLI's
499
531
  // infrastructure contract — recorded only when the upgrader and the
@@ -1166,74 +1198,104 @@ async function stepOperatorAccessSync(
1166
1198
  s.stop(green(`Operator-access sync job ready (${r.done.length} steps)`));
1167
1199
  }
1168
1200
 
1201
+ /** The install's identity for the stack, with the deployed services it was read from. */
1202
+ interface ResolvedRemoteMcpIdentity {
1203
+ readonly target: RemoteMcpTarget;
1204
+ readonly identity: RemoteMcpIdentity;
1205
+ }
1206
+
1169
1207
  /**
1170
- * #806 — the CLI's part of remote MCP (remote-mcp.ts): on `gcp` the load
1171
- * balancer's discovery routes and Cloud Armor rule, on both providers the
1172
- * env vars naming the admin URL, the broker's public URL and the IAP
1173
- * audience. Remote MCP stays off until the Owner sets it up under Settings →
1174
- * MCP, so a failure does not stop the install: it warns, and the next pass
1175
- * or `upgrade` retries. Idempotent.
1208
+ * #1082 — resolve the remote-MCP identity (remote-mcp.ts
1209
+ * resolveStackRemoteMcpIdentity) for the stack config: the deployed
1210
+ * services' names, then their URLs / the IAP backend and the organisation.
1211
+ * Read-only. Not fatal: remote MCP stays off until the Owner sets it up, so
1212
+ * a failure warns and the `up` keeps the identity the config last recorded.
1176
1213
  *
1177
- * @returns whether every part is in place
1214
+ * @param when `before` the `up` (the services exist), or `after` it
1215
+ * @returns the identity, or null when it could not be read
1178
1216
  */
1179
- async function stepRemoteMcp(
1217
+ async function stepResolveRemoteMcpIdentity(
1180
1218
  projectId: string,
1181
1219
  region: string,
1182
1220
  provider: "gcp" | "gcp-firebase",
1183
1221
  domain: string,
1184
- ): Promise<boolean> {
1222
+ when: "before" | "after",
1223
+ ): Promise<ResolvedRemoteMcpIdentity | null> {
1185
1224
  const s = spinner();
1186
1225
  s.start(
1187
- "Preparing remote MCP (ChatGPT / claude.ai connectors; off until the Owner enables it)...",
1226
+ "Reading the remote-MCP URLs and IAP audience (Google sign-in, ChatGPT / claude.ai connectors)...",
1188
1227
  );
1189
- const name = (slug: "admin" | "gateway") =>
1190
- resolveGcpResourceName(
1191
- gcloud,
1192
- "run-service",
1193
- `caelo-${GCP_STACK_ENV}-${slug}`,
1194
- projectId,
1195
- region,
1196
- );
1197
- const [adminLookup, gatewayLookup] = [await name("admin"), await name("gateway")];
1198
1228
  const failed = (error: string) => {
1199
- s.stop(yellow(`Remote MCP not prepared: ${error}`));
1229
+ s.stop(yellow(`Remote-MCP URLs not read: ${error}`));
1200
1230
  log.warn(
1201
- `The install works; Settings → Google sign-in and Settings → MCP report this install as not prepared until ${bold(selfCommand("upgrade"))} prepares it.`,
1231
+ when === "before"
1232
+ ? "pulumi up keeps the remote-MCP env the stack recorded last; the installer reads it again after the update."
1233
+ : "The install works; Settings → Google sign-in and Settings → MCP report this install as not prepared until a re-run of the installer reads them.",
1202
1234
  );
1203
- return false;
1235
+ return null;
1204
1236
  };
1205
- if (adminLookup.status !== "found") {
1206
- return failed(
1207
- lookupFailure(adminLookup, `caelo-${GCP_STACK_ENV}-admin* Cloud Run service`, "operator"),
1208
- );
1209
- }
1210
- if (gatewayLookup.status !== "found") {
1211
- return failed(
1212
- lookupFailure(gatewayLookup, `caelo-${GCP_STACK_ENV}-gateway* Cloud Run service`, "operator"),
1213
- );
1214
- }
1215
- const [admin, gateway] = [adminLookup.name, gatewayLookup.name];
1216
- const edge = await ensureRemoteMcpEdge({ provider, projectId, env: GCP_STACK_ENV, domain });
1217
- if (!edge.ok) return failed(edge.error);
1218
- const env = await ensureRemoteMcpIdentityEnv({
1237
+ const found = await findRemoteMcpTarget({
1219
1238
  provider,
1220
1239
  projectId,
1221
1240
  region,
1222
1241
  env: GCP_STACK_ENV,
1223
1242
  domain,
1224
- services: { admin, gateway },
1225
1243
  });
1226
- if (!env.ok) return failed(env.error);
1227
- s.stop(green(`Remote MCP and Google sign-in prepared (${env.identity.mcpPublicUrl})`));
1228
- if (env.orgDomainError) {
1244
+ if (!found.ok) return failed(found.error);
1245
+ const { target } = found;
1246
+ const resolved = await resolveStackRemoteMcpIdentity(target);
1247
+ if (!resolved.ok) return failed(resolved.error);
1248
+ s.stop(green(`Remote-MCP URLs read (${resolved.identity.mcpPublicUrl})`));
1249
+ if (resolved.orgDomainError && when === "after") {
1229
1250
  // #914 — without the organisation's domain the admin cannot tell who
1230
1251
  // IAP's built-in sign-in admits, so it refuses new users until the
1231
1252
  // Owner sets up Google sign-in. Say so now rather than at the first
1232
1253
  // invite.
1233
1254
  log.warn(
1234
- `Could not read this project's Google organisation (${env.orgDomainError}). Set up Google sign-in under Settings → Google sign-in before adding users, or re-run ${bold(selfCommand("upgrade"))} as an account with the Organization Viewer role.`,
1255
+ `Could not read this project's Google organisation (${resolved.orgDomainError}). Set up Google sign-in under Settings → Google sign-in before adding users, or re-run ${bold(selfCommand("upgrade"))} as an account with the Organization Viewer role.`,
1235
1256
  );
1236
1257
  }
1258
+ return { target, identity: resolved.identity };
1259
+ }
1260
+
1261
+ /**
1262
+ * #806 — the CLI's part of remote MCP (remote-mcp.ts): on `gcp` the load
1263
+ * balancer's discovery routes and Cloud Armor rule. The identity env vars
1264
+ * (admin URL, the broker's public URL, the IAP audience) come with `pulumi
1265
+ * up` from the stack config (#1082); this only checks that the services
1266
+ * carry them. Remote MCP stays off until the Owner sets it up under
1267
+ * Settings → MCP, so a failure does not stop the install: it warns, and the
1268
+ * next pass or `upgrade` retries. Idempotent.
1269
+ *
1270
+ * @returns whether every part is in place
1271
+ */
1272
+ async function stepRemoteMcp(
1273
+ projectId: string,
1274
+ provider: "gcp" | "gcp-firebase",
1275
+ domain: string,
1276
+ identity: ResolvedRemoteMcpIdentity | null,
1277
+ ): Promise<boolean> {
1278
+ const s = spinner();
1279
+ s.start(
1280
+ "Preparing remote MCP (ChatGPT / claude.ai connectors; off until the Owner enables it)...",
1281
+ );
1282
+ const failed = (error: string) => {
1283
+ s.stop(yellow(`Remote MCP not prepared: ${error}`));
1284
+ log.warn(
1285
+ `The install works; Settings → Google sign-in and Settings → MCP report this install as not prepared until ${bold(selfCommand("upgrade"))} prepares it.`,
1286
+ );
1287
+ return false;
1288
+ };
1289
+ if (!identity) return failed("its URLs and IAP audience could not be read (see above)");
1290
+ const edge = await ensureRemoteMcpEdge({ provider, projectId, env: GCP_STACK_ENV, domain });
1291
+ if (!edge.ok) return failed(edge.error);
1292
+ const check = await checkRemoteMcpIdentityEnv(identity.target, identity.identity);
1293
+ if (!check.ok) return failed(check.error);
1294
+ const missing = [...check.drift.admin, ...check.drift.gateway].map((c) => c.name);
1295
+ if (missing.length > 0) {
1296
+ return failed(`pulumi up left ${[...new Set(missing)].join(", ")} out of the services' env`);
1297
+ }
1298
+ s.stop(green(`Remote MCP and Google sign-in prepared (${identity.identity.mcpPublicUrl})`));
1237
1299
  return true;
1238
1300
  }
1239
1301
 
@@ -1481,6 +1543,8 @@ interface PulumiUpOpts {
1481
1543
  provider?: "gcp" | "gcp-firebase";
1482
1544
  /** No terminal to ask (the install job, `--non-interactive`): see planGate. */
1483
1545
  nonInteractive: boolean;
1546
+ /** #1082 — the remote-MCP identity the stack declares (gcp-pulumi.ts PulumiUpInputs). */
1547
+ remoteMcpIdentity?: RemoteMcpIdentity | null;
1484
1548
  }
1485
1549
 
1486
1550
  /**
@@ -37,13 +37,16 @@ import {
37
37
  adminEnvContract,
38
38
  adminMediaVolume,
39
39
  adminMediaVolumeTemplate,
40
+ CLOUD_RUN_IGNORE_CHANGES,
40
41
  type CloudRunEnvVar,
41
42
  type CloudRunSlug,
42
43
  databaseUrls,
43
44
  defaultStagingHost,
44
45
  GATEWAY_TIMEOUT_SECONDS,
45
46
  gatewayEnvContract,
47
+ REMOTE_MCP_IDENTITY_CONFIG,
46
48
  type RuntimeSecret,
49
+ remoteMcpIdentityStackEnv,
47
50
  runtimeSecretBindings,
48
51
  STATIC_CDN_POLICY,
49
52
  secretFileVolumesTemplate,
@@ -685,6 +688,12 @@ const envContractInputs = {
685
688
  },
686
689
  } as const;
687
690
 
691
+ // #1082 — the remote-MCP identity env (stack-contract.ts RemoteMcpIdentity):
692
+ // names generated identities no resource here can take as input, so the CLI
693
+ // resolves them from the live install and writes them as config before `up`.
694
+ // Declared here so the stack is their one owner and `up` keeps them.
695
+ const remoteMcpEnv = remoteMcpIdentityStackEnv(cfg.get(REMOTE_MCP_IDENTITY_CONFIG));
696
+
688
697
  function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
689
698
  return new gcp.cloudrunv2.Service(
690
699
  `${namePrefix}-${args.serviceName}`,
@@ -740,6 +749,8 @@ function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
740
749
  },
741
750
  {
742
751
  ...opts,
752
+ // #1082 — which tool deployed last; see CLOUD_RUN_IGNORE_CHANGES.
753
+ ignoreChanges: [...CLOUD_RUN_IGNORE_CHANGES],
743
754
  dependsOn: [
744
755
  pgAdminUser,
745
756
  cmsAdminDb,
@@ -771,7 +782,7 @@ const adminSvc = cloudRunService({
771
782
  // in Cloud Run logs. 3600s is the platform max; no cost penalty for
772
783
  // unused timeout (Cloud Run bills running time, not allocated time).
773
784
  timeout: "3600s",
774
- contractEnv: adminEnvContract(envContractInputs),
785
+ contractEnv: [...adminEnvContract(envContractInputs), ...remoteMcpEnv.admin],
775
786
  mediaVolume: adminMediaVolumeTemplate(adminMediaVolume(project, env)),
776
787
  });
777
788
  const gatewaySvc = cloudRunService({
@@ -786,7 +797,7 @@ const gatewaySvc = cloudRunService({
786
797
  timeout: `${GATEWAY_TIMEOUT_SECONDS}s`,
787
798
  // public_role on cms_public only: no cms_admin login of any role
788
799
  // (#613, #635, CLAUDE.md §2).
789
- contractEnv: gatewayEnvContract(envContractInputs),
800
+ contractEnv: [...gatewayEnvContract(envContractInputs), ...remoteMcpEnv.gateway],
790
801
  });
791
802
 
792
803
  // =========================================================================
@@ -42,13 +42,16 @@ import {
42
42
  adminEnvContract,
43
43
  adminMediaVolume,
44
44
  adminMediaVolumeTemplate,
45
+ CLOUD_RUN_IGNORE_CHANGES,
45
46
  type CloudRunEnvVar,
46
47
  type CloudRunSlug,
47
48
  databaseUrls,
48
49
  defaultStagingHost,
49
50
  GATEWAY_TIMEOUT_SECONDS,
50
51
  gatewayEnvContract,
52
+ REMOTE_MCP_IDENTITY_CONFIG,
51
53
  type RuntimeSecret,
54
+ remoteMcpIdentityStackEnv,
52
55
  runtimeSecretBindings,
53
56
  secretFileVolumesTemplate,
54
57
  } from "../../dist/stack-contract.js";
@@ -552,6 +555,12 @@ function imageRef(service: string): string {
552
555
  return digest ? `${base}@${digest}` : `${base}:${cfg.get("image-tag") ?? "latest"}`;
553
556
  }
554
557
 
558
+ // #1082 — the remote-MCP identity env (stack-contract.ts RemoteMcpIdentity):
559
+ // names generated identities no resource here can take as input, so the CLI
560
+ // resolves them from the live install and writes them as config before `up`.
561
+ // Declared here so the stack is their one owner and `up` keeps them.
562
+ const remoteMcpEnv = remoteMcpIdentityStackEnv(cfg.get(REMOTE_MCP_IDENTITY_CONFIG));
563
+
555
564
  function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
556
565
  return new gcp.cloudrunv2.Service(
557
566
  `${namePrefix}-${args.serviceName}`,
@@ -600,6 +609,8 @@ function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
600
609
  },
601
610
  {
602
611
  ...opts,
612
+ // #1082 — which tool deployed last; see CLOUD_RUN_IGNORE_CHANGES.
613
+ ignoreChanges: [...CLOUD_RUN_IGNORE_CHANGES],
603
614
  dependsOn: [
604
615
  pgAdminUser,
605
616
  cmsAdminDb,
@@ -654,7 +665,7 @@ const gatewaySvc = cloudRunService({
654
665
  // request passes its rate limits, captcha and honeypot; staging requests
655
666
  // pass the staging access gate.
656
667
  ingress: "INGRESS_TRAFFIC_ALL",
657
- contractEnv: gatewayEnvContract(envContractInputs),
668
+ contractEnv: [...gatewayEnvContract(envContractInputs), ...remoteMcpEnv.gateway],
658
669
  });
659
670
 
660
671
  const adminSvc = cloudRunService({
@@ -670,11 +681,14 @@ const adminSvc = cloudRunService({
670
681
  // Use the input string (known at config time) for the site — the
671
682
  // resource output is Output<string | undefined>, which Input<string>
672
683
  // rejects.
673
- contractEnv: adminEnvContract({
674
- ...envContractInputs,
675
- firebaseSiteId,
676
- gatewayService: gatewaySvc.name,
677
- }),
684
+ contractEnv: [
685
+ ...adminEnvContract({
686
+ ...envContractInputs,
687
+ firebaseSiteId,
688
+ gatewayService: gatewaySvc.name,
689
+ }),
690
+ ...remoteMcpEnv.admin,
691
+ ],
678
692
  mediaVolume: adminMediaVolumeTemplate(adminMediaVolume(project, env)),
679
693
  });
680
694