@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
package/src/remote-mcp.ts CHANGED
@@ -3,13 +3,15 @@
3
3
  /**
4
4
  * #806 — the CLI's part of remote MCP on a Google IAP install (`gcp`,
5
5
  * `gcp-firebase`), run as the operator's (or the provisioner's) gcloud
6
- * identity by the wizard after `pulumi up` and by `cms-provision upgrade`
7
- * after the roll. Every step is idempotent.
6
+ * identity by the wizard around `pulumi up`, by `state migrate`'s preview
7
+ * and by `cms-provision upgrade` around the roll. Every step is idempotent.
8
8
  *
9
- * - {@link ensureRemoteMcpIdentityEnv}: the env vars that name generated
10
- * identities ({@link RemoteMcpIdentity} — why the stacks cannot declare
11
- * them is in stack-contract.ts), applied with `gcloud run services
12
- * update` only where they differ.
9
+ * - {@link resolveStackRemoteMcpIdentity}: the env vars that name
10
+ * generated identities ({@link RemoteMcpIdentity}), resolved from the
11
+ * live install for the stack config the installer writes before `up`
12
+ * (#1082: the stack is their one owner — stack-contract.ts says why
13
+ * they cannot be plain resource inputs), and
14
+ * {@link checkRemoteMcpIdentityEnv}, the read-only check after it.
13
15
  * - {@link ensureRemoteMcpEdge} (`gcp` only): the load balancer's part for
14
16
  * installs whose URL map / Cloud Armor policy predate it — the
15
17
  * `/.well-known/oauth-*` path rules (the stack ignores `pathRules` after
@@ -43,11 +45,13 @@ import type { HttpFetch } from "./runtime-secrets.js";
43
45
  import {
44
46
  type CloudRunSlug,
45
47
  type GcpProvider,
48
+ REMOTE_MCP_ENV,
46
49
  type RemoteMcpIdentity,
47
50
  remoteMcpIdentity,
48
51
  remoteMcpIdentityEnv,
49
52
  } from "./stack-contract.js";
50
53
  import { type EnvChange, liveContainerEnv, planEnvUpdate } from "./stack-converge.js";
54
+ import { lookupFailure, resolveGcpResourceName } from "./upgrade/gcp-lookup.js";
51
55
 
52
56
  /** The install remote MCP is set up on. */
53
57
  export interface RemoteMcpTarget {
@@ -226,76 +230,118 @@ async function resolveServiceIdentity(
226
230
  };
227
231
  }
228
232
 
233
+ /** `gcloud run services describe --format=json` of one service, or why it failed. */
234
+ async function describeService(
235
+ target: RemoteMcpTarget,
236
+ service: string,
237
+ run: GcloudRunner,
238
+ ): Promise<RemoteMcpResult<{ readonly json: string }>> {
239
+ const describe = await run([
240
+ "run",
241
+ "services",
242
+ "describe",
243
+ service,
244
+ `--region=${target.region}`,
245
+ `--project=${target.projectId}`,
246
+ "--format=json",
247
+ ]);
248
+ if (!describe.ok) return { ok: false, error: `describe ${service}: ${describe.stderr.trim()}` };
249
+ if (parseJson(describe.stdout) === null) {
250
+ return { ok: false, error: `describe ${service}: output is not JSON` };
251
+ }
252
+ return { ok: true, json: describe.stdout };
253
+ }
254
+
229
255
  /**
230
- * Bring both services' {@link RemoteMcpIdentity} env vars to `identity`
231
- * with one `gcloud run services update` per service that differs (a new
232
- * revision only then). Vars already right are left alone.
256
+ * #1082 — the deployed install remote MCP is resolved on: the Pulumi-suffixed
257
+ * admin and gateway Cloud Run services found by prefix. Read-only.
233
258
  */
234
- export async function applyRemoteMcpIdentityEnv(
235
- target: RemoteMcpTarget,
236
- identity: RemoteMcpIdentity,
259
+ export async function findRemoteMcpTarget(
260
+ base: Omit<RemoteMcpTarget, "services">,
237
261
  deps: { run?: GcloudRunner } = {},
238
- ): Promise<RemoteMcpResult<{ readonly changes: Record<CloudRunSlug, EnvChange[]> }>> {
262
+ ): Promise<RemoteMcpResult<{ readonly target: RemoteMcpTarget }>> {
239
263
  const run = deps.run ?? defaultGcloud;
240
- const desired = remoteMcpIdentityEnv(identity);
241
- const changes: Record<CloudRunSlug, EnvChange[]> = { admin: [], gateway: [] };
264
+ const services: Partial<Record<CloudRunSlug, string>> = {};
242
265
  for (const slug of ["admin", "gateway"] as const) {
243
- const service = target.services[slug];
244
- const describe = await run([
245
- "run",
246
- "services",
247
- "describe",
248
- service,
249
- `--region=${target.region}`,
250
- `--project=${target.projectId}`,
251
- "--format=json",
252
- ]);
253
- if (!describe.ok) return { ok: false, error: `describe ${service}: ${describe.stderr.trim()}` };
254
- if (parseJson(describe.stdout) === null) {
255
- return { ok: false, error: `describe ${service}: output is not JSON` };
266
+ const prefix = `${gcpNamePrefix(base.env)}-${slug}`;
267
+ const lookup = await resolveGcpResourceName(
268
+ run,
269
+ "run-service",
270
+ prefix,
271
+ base.projectId,
272
+ base.region,
273
+ );
274
+ if (lookup.status !== "found") {
275
+ return {
276
+ ok: false,
277
+ error: lookupFailure(lookup, `${prefix}* Cloud Run service`, "operator"),
278
+ };
256
279
  }
257
- const plan = planEnvUpdate(liveContainerEnv(describe.stdout), desired[slug]);
258
- if (!plan.ok) return { ok: false, error: `${slug}: ${plan.error}` };
259
- if (plan.flags.length === 0) continue;
260
- const update = await run([
261
- "run",
262
- "services",
263
- "update",
264
- service,
265
- `--region=${target.region}`,
266
- `--project=${target.projectId}`,
267
- ...plan.flags,
268
- "--quiet",
269
- ]);
270
- if (!update.ok) return { ok: false, error: `update ${service}: ${update.stderr.trim()}` };
271
- changes[slug] = plan.changes;
280
+ services[slug] = lookup.name;
272
281
  }
273
- return { ok: true, changes };
282
+ return {
283
+ ok: true,
284
+ target: { ...base, services: services as Record<CloudRunSlug, string> },
285
+ };
274
286
  }
275
287
 
276
- /** {@link resolveRemoteMcpIdentity} then {@link applyRemoteMcpIdentityEnv}. */
277
- export async function ensureRemoteMcpIdentityEnv(
288
+ /**
289
+ * #1082 — the identity the installer writes into the stack config before
290
+ * `up` (stack-contract.ts REMOTE_MCP_IDENTITY_CONFIG), so the stack declares
291
+ * the vars the services carry and `up` keeps them.
292
+ *
293
+ * {@link resolveRemoteMcpIdentity}, except for the organisation domain when
294
+ * it cannot be read: then the admin's live `CAELO_GOOGLE_ORG_DOMAIN` is
295
+ * carried over (an earlier run as an account with the Organization Viewer
296
+ * role set it). The stack would otherwise remove it, where a gcloud roll
297
+ * leaves it as it is. `orgDomainError` still says why it was not read.
298
+ */
299
+ export async function resolveStackRemoteMcpIdentity(
278
300
  target: RemoteMcpTarget,
279
301
  deps: { run?: GcloudRunner } = {},
280
302
  ): Promise<
281
- RemoteMcpResult<{
282
- readonly identity: RemoteMcpIdentity;
283
- readonly changes: Record<CloudRunSlug, EnvChange[]>;
284
- readonly orgDomainError?: string;
285
- }>
303
+ RemoteMcpResult<{ readonly identity: RemoteMcpIdentity; readonly orgDomainError?: string }>
286
304
  > {
287
- const resolved = await resolveRemoteMcpIdentity(target, deps);
288
- if (!resolved.ok) return resolved;
289
- const applied = await applyRemoteMcpIdentityEnv(target, resolved.identity, deps);
290
- if (!applied.ok) return applied;
305
+ const run = deps.run ?? defaultGcloud;
306
+ const resolved = await resolveRemoteMcpIdentity(target, { run });
307
+ if (!resolved.ok || !resolved.orgDomainError) return resolved;
308
+ const admin = await describeService(target, target.services.admin, run);
309
+ if (!admin.ok) return admin;
310
+ const live = liveContainerEnv(admin.json).get(REMOTE_MCP_ENV.googleOrgDomain);
291
311
  return {
292
312
  ok: true,
293
- identity: resolved.identity,
294
- changes: applied.changes,
295
- ...(resolved.orgDomainError ? { orgDomainError: resolved.orgDomainError } : {}),
313
+ identity:
314
+ live?.kind === "value"
315
+ ? { ...resolved.identity, googleOrgDomain: live.value }
316
+ : resolved.identity,
317
+ orgDomainError: resolved.orgDomainError,
296
318
  };
297
319
  }
298
320
 
321
+ /**
322
+ * #1082 — what each service's identity env lacks against `identity`
323
+ * (read-only). The stack owns these vars now; this is the installer's check
324
+ * that the `up` it just ran put them in place. An empty list per service is
325
+ * the expected answer.
326
+ */
327
+ export async function checkRemoteMcpIdentityEnv(
328
+ target: RemoteMcpTarget,
329
+ identity: RemoteMcpIdentity,
330
+ deps: { run?: GcloudRunner } = {},
331
+ ): Promise<RemoteMcpResult<{ readonly drift: Record<CloudRunSlug, EnvChange[]> }>> {
332
+ const run = deps.run ?? defaultGcloud;
333
+ const desired = remoteMcpIdentityEnv(identity);
334
+ const drift: Record<CloudRunSlug, EnvChange[]> = { admin: [], gateway: [] };
335
+ for (const slug of ["admin", "gateway"] as const) {
336
+ const described = await describeService(target, target.services[slug], run);
337
+ if (!described.ok) return described;
338
+ const plan = planEnvUpdate(liveContainerEnv(described.json), desired[slug]);
339
+ if (!plan.ok) return { ok: false, error: `${slug}: ${plan.error}` };
340
+ drift[slug] = plan.changes;
341
+ }
342
+ return { ok: true, drift };
343
+ }
344
+
299
345
  // ===========================================================================
300
346
  // Load balancer (gcp)
301
347
  // ===========================================================================
@@ -15,6 +15,7 @@ import { describe, expect, it } from "bun:test";
15
15
  import { readFileSync } from "node:fs";
16
16
  import { resolve } from "node:path";
17
17
  import {
18
+ CLOUD_RUN_IGNORE_CHANGES,
18
19
  type GcpProvider,
19
20
  type IamInvariant,
20
21
  runtimeSecretBindings,
@@ -131,11 +132,35 @@ describe.each(["gcp", "gcp-firebase"] as const)("%s stack ↔ upgrade parity", (
131
132
  });
132
133
 
133
134
  it("builds the admin + gateway env from the shared contracts", () => {
134
- expect(src).toMatch(/serviceName: "admin",[\s\S]*?contractEnv: adminEnvContract\(/);
135
- expect(src).toMatch(/serviceName: "gateway",[\s\S]*?contractEnv: gatewayEnvContract\(/);
135
+ expect(src).toMatch(
136
+ /serviceName: "admin",[\s\S]*?contractEnv: \[\s*\.\.\.adminEnvContract\([\s\S]*?\.\.\.remoteMcpEnv\.admin,?\s*\]/,
137
+ );
138
+ expect(src).toMatch(
139
+ /serviceName: "gateway",[\s\S]*?contractEnv: \[\s*\.\.\.gatewayEnvContract\([\s\S]*?\.\.\.remoteMcpEnv\.gateway,?\s*\]/,
140
+ );
136
141
  expect(src).toContain("envs: [...args.contractEnv],");
137
142
  });
138
143
 
144
+ it("#1082 — declares the remote-MCP identity env from the config the CLI writes", () => {
145
+ // The stack is the vars' one owner: `pulumi up` keeps what the CLI used
146
+ // to add with gcloud after every `up` (and every `up` removed again).
147
+ expect(src).toContain(
148
+ "const remoteMcpEnv = remoteMcpIdentityStackEnv(cfg.get(REMOTE_MCP_IDENTITY_CONFIG));",
149
+ );
150
+ expect(src.match(/remoteMcpEnv\.(admin|gateway)/g)?.sort()).toEqual([
151
+ "remoteMcpEnv.admin",
152
+ "remoteMcpEnv.gateway",
153
+ ]);
154
+ });
155
+
156
+ it("#1082 — leaves client / clientVersion (who deployed last) to gcloud on both services", () => {
157
+ expect(src.match(/new gcp\.cloudrunv2\.Service\(/g)).toHaveLength(1);
158
+ expect(src).toMatch(
159
+ /new gcp\.cloudrunv2\.Service\([\s\S]*?\.\.\.opts,\s*\/\/[^\n]*\n\s*ignoreChanges: \[\.\.\.CLOUD_RUN_IGNORE_CHANGES\],/,
160
+ );
161
+ expect(CLOUD_RUN_IGNORE_CHANGES).toEqual(["client", "clientVersion"]);
162
+ });
163
+
139
164
  it("mounts the media bucket on the admin, and only there, from the contract upgrade applies", () => {
140
165
  // The stack's admin gets adminMediaVolume() — the same volume
141
166
  // stack-converge.ts planMediaVolume adds to installs that lack it.
@@ -166,7 +191,7 @@ describe.each(["gcp", "gcp-firebase"] as const)("%s stack ↔ upgrade parity", (
166
191
 
167
192
  it("#806 — gives the gateway the contract's request timeout upgrade applies", () => {
168
193
  expect(src).toMatch(
169
- /serviceName: "gateway",[\s\S]*?timeout: `\$\{GATEWAY_TIMEOUT_SECONDS\}s`,[\s\S]*?contractEnv: gatewayEnvContract\(/,
194
+ /serviceName: "gateway",[\s\S]*?timeout: `\$\{GATEWAY_TIMEOUT_SECONDS\}s`,[\s\S]*?contractEnv: \[\s*\.\.\.gatewayEnvContract\(/,
170
195
  );
171
196
  });
172
197
 
@@ -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
  *
@@ -14,7 +14,9 @@ import { homedir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import { confirm, isCancel, log, note } from "@clack/prompts";
16
16
  import { bold, red } from "kleur/colors";
17
- import { checkGcloudAuth } from "./gcloud.js";
17
+ import { adoptionCandidates, existingCandidates } from "./adopt-existing.js";
18
+ import { checkGcloudAuth, gcloud } from "./gcloud.js";
19
+ import { GCP_STACK_ENV } from "./gcp-names.js";
18
20
  import {
19
21
  adoptStateBackend,
20
22
  currentMetadata,
@@ -32,7 +34,13 @@ import { installIapAllowlist } from "./operator-access.js";
32
34
  import { ensureOpsBackend } from "./ops-backend.js";
33
35
  import { writePlanFile } from "./preview-plan.js";
34
36
  import { provisionerServiceAccountEmail } from "./provisioner-identity.js";
37
+ import {
38
+ findRemoteMcpTarget,
39
+ type RemoteMcpTarget,
40
+ resolveStackRemoteMcpIdentity,
41
+ } from "./remote-mcp.js";
35
42
  import { selfCommand } from "./self-command.js";
43
+ import type { RemoteMcpIdentity } from "./stack-contract.js";
36
44
  import { attachInstall } from "./state-attach.js";
37
45
  import {
38
46
  gcsBackendRecord,
@@ -191,6 +199,14 @@ export async function stateCommand(argv: readonly string[]): Promise<void> {
191
199
  iapAllowlist: installIapAllowlist(meta.ownerEmail),
192
200
  imageDigests,
193
201
  },
202
+ resolveRemoteMcpIdentity: () =>
203
+ resolveRemoteMcpIdentityForPreview({
204
+ provider,
205
+ projectId,
206
+ region,
207
+ env: GCP_STACK_ENV,
208
+ domain: meta.domain,
209
+ }),
194
210
  }),
195
211
  report: clackReporter(),
196
212
  resolve: (record) =>
@@ -260,6 +276,10 @@ export async function stateCommand(argv: readonly string[]): Promise<void> {
260
276
  return moved;
261
277
  },
262
278
  writePlan: (label, steps) => writePlanFile(root, label, steps),
279
+ existingInProject: async (steps) =>
280
+ (await existingCandidates(adoptionCandidates(steps, projectId), gcloud)).map(
281
+ (c) => `${c.label} ${c.display}`,
282
+ ),
263
283
  },
264
284
  );
265
285
  if (!result.ok) {
@@ -318,3 +338,20 @@ export function adcNote(run: SyncGcloud): string | null {
318
338
  if (r.ok) return null;
319
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";
320
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
+ }
@@ -154,6 +154,8 @@ function machine(initial: StateBackendRecord = { kind: "local" }) {
154
154
  acceptCost: true,
155
155
  bucketRecord: (initial.kind === "gcs" ? "gcs" : null) as "gcs" | "local" | null,
156
156
  planFiles: [] as { label: string; steps: number }[],
157
+ /** What existingInProject answers (#1079); an Error is thrown. */
158
+ existing: [] as readonly string[] | Error,
157
159
  };
158
160
  const meta = (): MigrateInstall => ({
159
161
  installId: "gcp-firebase-acme",
@@ -209,6 +211,10 @@ function machine(initial: StateBackendRecord = { kind: "local" }) {
209
211
  m.planFiles.push({ label, steps: steps.length });
210
212
  return `${ROOT}/plan-${label}.txt`;
211
213
  },
214
+ existingInProject: async () => {
215
+ if (m.existing instanceof Error) throw m.existing;
216
+ return m.existing;
217
+ },
212
218
  };
213
219
  p.stacks.set(localUrl, {
214
220
  outputs: {
@@ -380,6 +386,38 @@ describe("migrateState — failures leave the source authoritative", () => {
380
386
  expect(t.m.backups).toBe(0);
381
387
  });
382
388
 
389
+ it("the refusal names the planned creates the installer adopts (#1079)", async () => {
390
+ const t = machine();
391
+ const local = t.p.stacks.get(t.localUrl);
392
+ if (local) local.drift = 2;
393
+ t.m.existing = ["service account caelo-production-gateway-sa@acme.iam.gserviceaccount.com"];
394
+ const r = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
395
+ expect(r.ok).toBe(false);
396
+ if (r.ok) return;
397
+ expect(r.error).toContain(
398
+ "1 of the planned creates exist in the project already (service account caelo-production-gateway-sa@acme.iam.gserviceaccount.com): upgrade made them without Pulumi. The installer adopts them into the stack first",
399
+ );
400
+ expect(r.error.indexOf("adopts them")).toBeLessThan(r.error.indexOf("Run upgrade first"));
401
+ });
402
+
403
+ it("the refusal says nothing about adopting when nothing exists, and says so when it can't check", async () => {
404
+ const t = machine();
405
+ const local = t.p.stacks.get(t.localUrl);
406
+ if (local) local.drift = 1;
407
+ const none = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
408
+ expect(none.ok).toBe(false);
409
+ if (none.ok) return;
410
+ expect(none.error).not.toContain("adopt");
411
+ t.m.existing = new Error("gcloud: PERMISSION_DENIED");
412
+ const failed = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
413
+ expect(failed.ok).toBe(false);
414
+ if (failed.ok) return;
415
+ expect(failed.error).toContain(
416
+ "(Could not check which planned creates exist in the project already: gcloud: PERMISSION_DENIED. The installer checks again.)",
417
+ );
418
+ expect(failed.error).toContain("Run upgrade first");
419
+ });
420
+
383
421
  it("refuses a stack with pending operations", async () => {
384
422
  const t = machine();
385
423
  const local = t.p.stacks.get(t.localUrl);
@@ -117,6 +117,13 @@ export interface MigrateDeps {
117
117
  * writePlanFile); returns its path.
118
118
  */
119
119
  writePlan(label: string, steps: readonly PlannedStep[]): string;
120
+ /**
121
+ * #1079 — the planned creates whose resource exists in the project already
122
+ * (adopt-existing.ts: made by `upgrade` through gcloud), one
123
+ * `<kind> <name>` each. The installer adopts these into the stack before it
124
+ * applies anything. Throws when it can't tell.
125
+ */
126
+ existingInProject(steps: readonly PlannedStep[]): Promise<readonly string[]>;
120
127
  }
121
128
 
122
129
  export interface MigrateOptions {
@@ -167,6 +174,22 @@ function planReport(deps: MigrateDeps, label: string, steps: readonly PlannedSte
167
174
  return [...formatPlan(steps).map((l) => ` ${l}`), where].join("\n");
168
175
  }
169
176
 
177
+ /**
178
+ * The refusal's line about planned creates the installer adopts instead
179
+ * (#1079), or "" when there are none. A failed check is said, not fatal: the
180
+ * installer checks again before it applies anything.
181
+ */
182
+ async function adoptionNote(deps: MigrateDeps, steps: readonly PlannedStep[]): Promise<string> {
183
+ let existing: readonly string[];
184
+ try {
185
+ existing = await deps.existingInProject(steps);
186
+ } catch (e) {
187
+ return `(Could not check which planned creates exist in the project already: ${errorText(e)}. The installer checks again.)\n`;
188
+ }
189
+ if (existing.length === 0) return "";
190
+ return `${existing.length} of the planned creates exist in the project already (${existing.join(", ")}): upgrade made them without Pulumi. The installer adopts them into the stack first instead of creating them again, then shows what is left.\n`;
191
+ }
192
+
170
193
  /**
171
194
  * Run the migration. Never throws for an expected failure: the result says
172
195
  * what failed, and at every failure point the source backend is still the
@@ -264,7 +287,7 @@ export async function migrateState(
264
287
  pre.failed(`A preview shows ${sourcePlan.length} change(s)`);
265
288
  return {
266
289
  ok: false,
267
- error: `The stack is not converged (a preview shows ${sourcePlan.length} change(s)), so a copy can't be proven identical. Nothing was changed. The planned changes:\n${planReport(deps, "state-migrate", sourcePlan)}\nRun upgrade first, then re-run the installer (${selfCommand()}, pick this install): it shows this list again and asks before it replaces or deletes anything. Migrate once it reports no changes.`,
290
+ error: `The stack is not converged (a preview shows ${sourcePlan.length} change(s)), so a copy can't be proven identical. Nothing was changed. The planned changes:\n${planReport(deps, "state-migrate", sourcePlan)}\n${await adoptionNote(deps, sourcePlan)}Run upgrade first, then re-run the installer (${selfCommand()}, pick this install): it shows this list again and asks before it replaces or deletes anything. Migrate once it reports no changes.`,
268
291
  };
269
292
  }
270
293
  sourceDigest = await pulumi.outputsDigest(from);
@@ -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
  {