@caelo-cms/provisioning 0.10.69 → 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 (56) hide show
  1. package/dist/adopt-existing.d.ts +119 -0
  2. package/dist/adopt-existing.d.ts.map +1 -0
  3. package/dist/adopt-existing.js +118 -0
  4. package/dist/adopt-existing.js.map +1 -0
  5. package/dist/preview-plan.d.ts +7 -0
  6. package/dist/preview-plan.d.ts.map +1 -1
  7. package/dist/preview-plan.js +1 -0
  8. package/dist/preview-plan.js.map +1 -1
  9. package/dist/remote-mcp.d.ts +27 -8
  10. package/dist/remote-mcp.d.ts.map +1 -1
  11. package/dist/remote-mcp.js +88 -56
  12. package/dist/remote-mcp.js.map +1 -1
  13. package/dist/stack-contract.d.ts +53 -39
  14. package/dist/stack-contract.d.ts.map +1 -1
  15. package/dist/stack-contract.js +110 -0
  16. package/dist/stack-contract.js.map +1 -1
  17. package/dist/state-command.d.ts.map +1 -1
  18. package/dist/state-command.js +27 -1
  19. package/dist/state-command.js.map +1 -1
  20. package/dist/state-migrate.d.ts +7 -0
  21. package/dist/state-migrate.d.ts.map +1 -1
  22. package/dist/state-migrate.js +18 -1
  23. package/dist/state-migrate.js.map +1 -1
  24. package/dist/state-pulumi.d.ts +7 -0
  25. package/dist/state-pulumi.d.ts.map +1 -1
  26. package/dist/state-pulumi.js +11 -0
  27. package/dist/state-pulumi.js.map +1 -1
  28. package/dist/upgrade/converge.d.ts.map +1 -1
  29. package/dist/upgrade/converge.js +4 -2
  30. package/dist/upgrade/converge.js.map +1 -1
  31. package/dist/wizards/gcp-pulumi.d.ts +28 -5
  32. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  33. package/dist/wizards/gcp-pulumi.js +70 -8
  34. package/dist/wizards/gcp-pulumi.js.map +1 -1
  35. package/dist/wizards/gcp.d.ts.map +1 -1
  36. package/dist/wizards/gcp.js +100 -46
  37. package/dist/wizards/gcp.js.map +1 -1
  38. package/package.json +2 -2
  39. package/src/__tests__/fixtures/viu-one-plan-state-migrate.txt +98 -0
  40. package/src/adopt-existing.test.ts +397 -0
  41. package/src/adopt-existing.ts +228 -0
  42. package/src/preview-plan.ts +8 -0
  43. package/src/remote-mcp-stack-config.test.ts +195 -0
  44. package/src/remote-mcp.test.ts +99 -16
  45. package/src/remote-mcp.ts +104 -58
  46. package/src/stack-contract-parity.test.ts +28 -3
  47. package/src/stack-contract.ts +107 -6
  48. package/src/state-command.ts +38 -1
  49. package/src/state-migrate.test.ts +38 -0
  50. package/src/state-migrate.ts +24 -1
  51. package/src/state-pulumi.ts +21 -0
  52. package/src/upgrade/converge.ts +4 -2
  53. package/src/wizards/gcp-pulumi.ts +105 -9
  54. package/src/wizards/gcp.ts +126 -55
  55. package/stacks/gcp/index.ts +13 -2
  56. package/stacks/gcp-firebase/index.ts +20 -6
@@ -14,8 +14,14 @@
14
14
  import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
15
15
  import { join, relative, resolve as resolvePath } from "node:path";
16
16
  import * as pulumi from "@pulumi/pulumi/automation";
17
+ import {
18
+ type AdoptionCandidate,
19
+ AdoptionError,
20
+ adoptExistingResources,
21
+ } from "../adopt-existing.js";
17
22
  import { AUDIT_PARALLELISM_DEFAULT } from "../audit-job-setup.js";
18
23
  import { checkpointableOutputs } from "../checkpoint-secrets.js";
24
+ import { gcloud } from "../gcloud.js";
19
25
  import { GCP_STACK_ENV } from "../gcp-names.js";
20
26
  import { type ImageDigests, stackWorkDir } from "../install-state.js";
21
27
  import { collectPreviewSteps, type PlannedStep } from "../preview-plan.js";
@@ -24,7 +30,12 @@ import {
24
30
  withoutAmbientGoogleCredentials,
25
31
  } from "../provisioner-identity.js";
26
32
  import { ensureGeneratedSecrets, stackSecretReplication } from "../runtime-secrets.js";
27
- 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";
28
39
  import type { ResolvedBackend } from "../state-backend.js";
29
40
 
30
41
  /**
@@ -90,6 +101,13 @@ export interface PulumiUpInputs {
90
101
  * Defaults to 'gcp' for backwards compatibility.
91
102
  */
92
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;
93
111
  }
94
112
 
95
113
  export interface PulumiUpResult {
@@ -395,6 +413,7 @@ export function resolveSecretReplication(
395
413
 
396
414
  /** A resource as `pulumi stack export` lists it. */
397
415
  export interface StateResource {
416
+ urn?: string;
398
417
  type?: string;
399
418
  inputs?: Record<string, unknown>;
400
419
  outputs?: Record<string, unknown>;
@@ -449,6 +468,7 @@ export type StackConfigInputs = Pick<
449
468
  | "wafAdaptiveProtection"
450
469
  | "iapAllowlist"
451
470
  | "imageDigests"
471
+ | "remoteMcpIdentity"
452
472
  > & { provider: "gcp" | "gcp-firebase" };
453
473
 
454
474
  /**
@@ -487,6 +507,13 @@ export async function applyStackConfig(
487
507
  // v0.3.2 — anthropicApiKey config dropped. Runtime path is
488
508
  // /settings/ai → ai_providers (KEK-encrypted).
489
509
  ...imageDigestConfig(provider, inputs.imageDigests),
510
+ ...(inputs.remoteMcpIdentity
511
+ ? {
512
+ [`${ns}:${REMOTE_MCP_IDENTITY_CONFIG}`]: {
513
+ value: serializeRemoteMcpIdentity(inputs.remoteMcpIdentity),
514
+ },
515
+ }
516
+ : {}),
490
517
  });
491
518
 
492
519
  // Read back only after the required keys are set: `pulumi config` refuses to
@@ -534,6 +561,54 @@ export function pulumiGcpEnv(
534
561
  };
535
562
  }
536
563
 
564
+ /**
565
+ * The URN of the stack's explicit GCP provider (`new gcp.Provider` in both
566
+ * stacks), which every stack resource uses. An adopted resource is imported
567
+ * under it (#1079): under Pulumi's default provider instead, the next `up`
568
+ * would see a provider change on it.
569
+ *
570
+ * @throws AdoptionError unless the state holds exactly one
571
+ */
572
+ export function stackGcpProviderUrn(resources: ReadonlyArray<StateResource>): string {
573
+ const urns = resources
574
+ .filter((r) => r.type === "pulumi:providers:gcp" && typeof r.urn === "string")
575
+ .map((r) => r.urn as string);
576
+ if (urns.length !== 1) {
577
+ throw new AdoptionError(
578
+ `the stack's state holds ${urns.length} GCP providers, so it is unclear which one resources that already exist would be adopted under. Nothing was imported or applied. Report this; the stack needs a look before the installer can run.`,
579
+ );
580
+ }
581
+ return urns[0] as string;
582
+ }
583
+
584
+ /** The name {@link importIntoStack} gives the stack's provider in the import's name table. */
585
+ const IMPORT_PROVIDER_NAME = "gcpProvider";
586
+
587
+ /**
588
+ * `pulumi import` the candidates into the stack under its explicit provider.
589
+ * State only: nothing in the project changes. Not protected (the program
590
+ * does not protect them either) and no code generated.
591
+ */
592
+ async function importIntoStack(
593
+ stack: pulumi.Stack,
594
+ providerUrn: string,
595
+ candidates: readonly AdoptionCandidate[],
596
+ onOutput: (msg: string) => void,
597
+ ): Promise<void> {
598
+ await stack.import({
599
+ resources: candidates.map((c) => ({
600
+ type: c.step.type,
601
+ name: c.step.name,
602
+ id: c.importId,
603
+ provider: IMPORT_PROVIDER_NAME,
604
+ })),
605
+ nameTable: { [IMPORT_PROVIDER_NAME]: providerUrn },
606
+ protect: false,
607
+ generateCode: false,
608
+ onOutput,
609
+ });
610
+ }
611
+
537
612
  /** Thrown when the review (preview-plan.ts planGate) stops `pulumi up` after the preview. */
538
613
  export class PulumiUpDeclinedError extends Error {
539
614
  override readonly name = "PulumiUpDeclinedError";
@@ -544,16 +619,23 @@ export class PulumiUpDeclinedError extends Error {
544
619
  * Streams resource-create events to the supplied onEvent callback so
545
620
  * the wizard can render a live progress bar.
546
621
  *
547
- * On a stack that already holds resources, a preview runs first (#737) and
548
- * `review` sees its planned steps: it lists them and decides (planGate)
549
- * whether `up` may apply them. Nothing is applied when it says no.
622
+ * On a stack that already holds resources, a preview runs first (#737).
623
+ * Planned creates of resources that exist in the project already (made by
624
+ * `upgrade` through gcloud) are adopted into the stack and the stack is
625
+ * previewed again (adopt-existing.ts, #1079). `review` sees that plan and
626
+ * what was adopted: it lists them and decides (planGate) whether `up` may
627
+ * apply the plan. Nothing is applied when it says no.
550
628
  *
551
629
  * @throws PulumiUpDeclinedError when `review` declines the plan
630
+ * @throws AdoptionError when adopting fails; nothing was applied
552
631
  */
553
632
  export async function pulumiUpGcp(
554
633
  inputs: PulumiUpInputs,
555
634
  onEvent: (kind: "resource" | "error" | "log", message: string) => void,
556
- review: (steps: readonly PlannedStep[]) => Promise<boolean>,
635
+ review: (
636
+ steps: readonly PlannedStep[],
637
+ adopted: readonly AdoptionCandidate[],
638
+ ) => Promise<boolean>,
557
639
  ): Promise<PulumiUpResult> {
558
640
  const env = pulumiGcpEnv(process.env, inputs);
559
641
 
@@ -608,11 +690,25 @@ export async function pulumiUpGcp(
608
690
 
609
691
  // #737 — never `up` a deployed stack blind: list what it would change
610
692
  // (replaces and deletes first) and let the review stop it. A first run
611
- // on an empty stack only creates, so it skips the extra preview.
693
+ // on an empty stack only creates, so it skips the extra preview (and has
694
+ // no provider in its state to adopt anything under).
612
695
  if (stateResources.length > 0) {
613
- const collector = collectPreviewSteps();
614
- await stack.preview({ onEvent: collector.onEvent });
615
- if (!(await review(collector.steps()))) {
696
+ const preview = async () => {
697
+ const collector = collectPreviewSteps();
698
+ await stack.preview({ onEvent: collector.onEvent });
699
+ return collector.steps();
700
+ };
701
+ // #1079 — what `upgrade` created through gcloud joins the state first.
702
+ const { steps, adopted } = await adoptExistingResources(await preview(), {
703
+ projectId: inputs.projectId,
704
+ gcloud,
705
+ preview,
706
+ importResources: (candidates) =>
707
+ importIntoStack(stack, stackGcpProviderUrn(stateResources), candidates, (msg) =>
708
+ onEvent("log", msg),
709
+ ),
710
+ });
711
+ if (!(await review(steps, adopted))) {
616
712
  throw new PulumiUpDeclinedError("pulumi up was not run: the planned changes were declined.");
617
713
  }
618
714
  }
@@ -33,6 +33,7 @@ import { join } from "node:path";
33
33
  import { cancel, confirm, isCancel, log, note, select, spinner } from "@clack/prompts";
34
34
  import { bold, cyan, dim, green, red, yellow } from "kleur/colors";
35
35
  import { checkAdcImpersonation } from "../adc-impersonation.js";
36
+ import { type AdoptionCandidate, AdoptionError, formatAdopted } from "../adopt-existing.js";
36
37
  import { ensureAuditJob, resolveAdminImage } from "../audit-job-setup.js";
37
38
  import { CERT_WAIT_TIMEOUT_MS, lbCertProbe, waitForCert } from "../cert-wait.js";
38
39
  import { redactCredentials } from "../checkpoint-secrets.js";
@@ -104,10 +105,20 @@ import {
104
105
  } from "../provisioner-identity.js";
105
106
  import { revokePublicRoleMemberships } from "../public-role-isolation.js";
106
107
  import { decideRegion, detectCliRegion, installableRegions, suggestRegion } from "../regions.js";
107
- 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";
108
115
  import type { RunMode } from "../run-mode.js";
109
116
  import { selfCommand } from "../self-command.js";
110
- 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";
111
122
  import {
112
123
  chooseInstallBackend,
113
124
  describeBackend,
@@ -119,7 +130,6 @@ import {
119
130
  resolveBackend,
120
131
  } from "../state-backend.js";
121
132
  import { acquireStateLock, GcsStateStore } from "../state-store.js";
122
- import { lookupFailure, resolveGcpResourceName } from "../upgrade/gcp-lookup.js";
123
133
  import { createReleaseImageForms, platformDigestsOf } from "../upgrade/image-forms.js";
124
134
  import {
125
135
  releaseVersionForDigest,
@@ -378,12 +388,22 @@ export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
378
388
  // post-cleanup re-runs both pass through cleanly).
379
389
  await stepDrainLegacyCaeloAdminUser(projectId, region);
380
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
+
381
401
  // === 10. Pulumi up via Automation SDK ===
382
402
  // Pre-built signed images live at the Caelo-team public AR repo
383
403
  // (caelo-website/caelo-cms-images by default). Cloud Run reads them
384
404
  // directly with no operator-side IAM binding — anonymous public pull.
385
405
  phase("Provisioning the infrastructure (pulumi up, 8-15 min)");
386
- await stepPulumiUp(installId, {
406
+ const upOpts: PulumiUpOpts = {
387
407
  projectId,
388
408
  domain,
389
409
  // #650 — the wizard recorded it (or the install predates the setting).
@@ -405,9 +425,24 @@ export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
405
425
  // matching config namespace.
406
426
  provider,
407
427
  nonInteractive: opts.nonInteractive,
408
- });
428
+ };
429
+ await stepPulumiUp(installId, { ...upOpts, remoteMcpIdentity: identityBefore?.identity ?? null });
409
430
  recordImageDigests(installId, imageDigests);
410
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
+
411
446
  // === 10.1. Remove the default network's pre-populated firewall rules ===
412
447
  // GCP creates them when Compute Engine is enabled (step 5). Run after
413
448
  // Pulumi rather than right after enabling the API: the default network
@@ -488,11 +523,9 @@ export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
488
523
  keepSigner ? [`serviceAccount:${upgraderServiceAccountEmail(projectId, GCP_STACK_ENV)}`] : [],
489
524
  );
490
525
 
491
- // === 11.6. Remote MCP (#806): LB rules (gcp) + the identity env ===
492
- // After `pulumi up` on every pass: the identity env names services Pulumi
493
- // generated, so the stack cannot declare it, and an `up` that changed a
494
- // service's template dropped it (remote-mcp.ts).
495
- 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);
496
529
 
497
530
  // A fresh install ran every converge step above, so it is at this CLI's
498
531
  // infrastructure contract — recorded only when the upgrader and the
@@ -1165,74 +1198,104 @@ async function stepOperatorAccessSync(
1165
1198
  s.stop(green(`Operator-access sync job ready (${r.done.length} steps)`));
1166
1199
  }
1167
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
+
1168
1207
  /**
1169
- * #806 — the CLI's part of remote MCP (remote-mcp.ts): on `gcp` the load
1170
- * balancer's discovery routes and Cloud Armor rule, on both providers the
1171
- * env vars naming the admin URL, the broker's public URL and the IAP
1172
- * audience. Remote MCP stays off until the Owner sets it up under Settings →
1173
- * MCP, so a failure does not stop the install: it warns, and the next pass
1174
- * 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.
1175
1213
  *
1176
- * @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
1177
1216
  */
1178
- async function stepRemoteMcp(
1217
+ async function stepResolveRemoteMcpIdentity(
1179
1218
  projectId: string,
1180
1219
  region: string,
1181
1220
  provider: "gcp" | "gcp-firebase",
1182
1221
  domain: string,
1183
- ): Promise<boolean> {
1222
+ when: "before" | "after",
1223
+ ): Promise<ResolvedRemoteMcpIdentity | null> {
1184
1224
  const s = spinner();
1185
1225
  s.start(
1186
- "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)...",
1187
1227
  );
1188
- const name = (slug: "admin" | "gateway") =>
1189
- resolveGcpResourceName(
1190
- gcloud,
1191
- "run-service",
1192
- `caelo-${GCP_STACK_ENV}-${slug}`,
1193
- projectId,
1194
- region,
1195
- );
1196
- const [adminLookup, gatewayLookup] = [await name("admin"), await name("gateway")];
1197
1228
  const failed = (error: string) => {
1198
- s.stop(yellow(`Remote MCP not prepared: ${error}`));
1229
+ s.stop(yellow(`Remote-MCP URLs not read: ${error}`));
1199
1230
  log.warn(
1200
- `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.",
1201
1234
  );
1202
- return false;
1235
+ return null;
1203
1236
  };
1204
- if (adminLookup.status !== "found") {
1205
- return failed(
1206
- lookupFailure(adminLookup, `caelo-${GCP_STACK_ENV}-admin* Cloud Run service`, "operator"),
1207
- );
1208
- }
1209
- if (gatewayLookup.status !== "found") {
1210
- return failed(
1211
- lookupFailure(gatewayLookup, `caelo-${GCP_STACK_ENV}-gateway* Cloud Run service`, "operator"),
1212
- );
1213
- }
1214
- const [admin, gateway] = [adminLookup.name, gatewayLookup.name];
1215
- const edge = await ensureRemoteMcpEdge({ provider, projectId, env: GCP_STACK_ENV, domain });
1216
- if (!edge.ok) return failed(edge.error);
1217
- const env = await ensureRemoteMcpIdentityEnv({
1237
+ const found = await findRemoteMcpTarget({
1218
1238
  provider,
1219
1239
  projectId,
1220
1240
  region,
1221
1241
  env: GCP_STACK_ENV,
1222
1242
  domain,
1223
- services: { admin, gateway },
1224
1243
  });
1225
- if (!env.ok) return failed(env.error);
1226
- s.stop(green(`Remote MCP and Google sign-in prepared (${env.identity.mcpPublicUrl})`));
1227
- 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") {
1228
1250
  // #914 — without the organisation's domain the admin cannot tell who
1229
1251
  // IAP's built-in sign-in admits, so it refuses new users until the
1230
1252
  // Owner sets up Google sign-in. Say so now rather than at the first
1231
1253
  // invite.
1232
1254
  log.warn(
1233
- `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.`,
1234
1256
  );
1235
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})`));
1236
1299
  return true;
1237
1300
  }
1238
1301
 
@@ -1480,22 +1543,29 @@ interface PulumiUpOpts {
1480
1543
  provider?: "gcp" | "gcp-firebase";
1481
1544
  /** No terminal to ask (the install job, `--non-interactive`): see planGate. */
1482
1545
  nonInteractive: boolean;
1546
+ /** #1082 — the remote-MCP identity the stack declares (gcp-pulumi.ts PulumiUpInputs). */
1547
+ remoteMcpIdentity?: RemoteMcpIdentity | null;
1483
1548
  }
1484
1549
 
1485
1550
  /**
1486
1551
  * The installer's review of a deployed stack's plan before `pulumi up`
1487
1552
  * (#737): the grouped list, the full diff in the install dir, and a y/N
1488
1553
  * for any replace or delete. A data-bearing one is asked about by name and
1489
- * defaults to No; without a terminal it is refused (planGate).
1554
+ * defaults to No; without a terminal it is refused (planGate). Resources
1555
+ * adopted into the stack before this preview (adopt-existing.ts, #1079) are
1556
+ * listed first; they are no longer in the plan.
1490
1557
  *
1491
1558
  * @returns whether `pulumi up` may apply the plan
1492
1559
  */
1493
1560
  async function reviewPulumiPlan(
1494
1561
  installId: string,
1495
1562
  steps: readonly PlannedStep[],
1563
+ adopted: readonly AdoptionCandidate[],
1496
1564
  nonInteractive: boolean,
1497
1565
  ): Promise<boolean> {
1498
1566
  const gate = planGate(steps, { interactive: !nonInteractive && process.stdin.isTTY === true });
1567
+ const adoptedLines = formatAdopted(adopted);
1568
+ if (adoptedLines.length > 0) log.info(adoptedLines.join("\n"));
1499
1569
  if (steps.length === 0) {
1500
1570
  log.info(dim("Preview: no changes planned."));
1501
1571
  return true;
@@ -1601,11 +1671,12 @@ async function stepPulumiUp(installId: string, opts: PulumiUpOpts): Promise<void
1601
1671
  // logs: silent — too noisy for a live progress UI; pulumi
1602
1672
  // diagnostics will print on failure via the SDK's onOutput.
1603
1673
  },
1604
- (steps) => reviewPulumiPlan(installId, steps, opts.nonInteractive),
1674
+ (steps, adopted) => reviewPulumiPlan(installId, steps, adopted, opts.nonInteractive),
1605
1675
  );
1606
1676
  } catch (e) {
1607
- if (!(e instanceof PulumiUpDeclinedError)) throw e;
1608
- declined = e.message;
1677
+ if (e instanceof AdoptionError) declined = `pulumi up was not run: ${e.message}`;
1678
+ else if (e instanceof PulumiUpDeclinedError) declined = e.message;
1679
+ else throw e;
1609
1680
  } finally {
1610
1681
  releaseLock();
1611
1682
  }
@@ -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