@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
@@ -0,0 +1,195 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #1082 — the remote-MCP identity env has one owner: the stack, which
5
+ * declares it from the config the CLI writes before `up`. These tests pin
6
+ * the config round trip and what a refreshed `pulumi up` / `state migrate`
7
+ * preview would plan for the services' env against an install whose live
8
+ * services carry the vars (the viu.one shape: contract env plus the four /
9
+ * two identity vars gcloud added, in gcloud's order).
10
+ */
11
+
12
+ import { describe, expect, it } from "bun:test";
13
+ import type * as automation from "@pulumi/pulumi/automation";
14
+ import {
15
+ adminEnvContract,
16
+ type CloudRunEnvVar,
17
+ databaseUrls,
18
+ gatewayEnvContract,
19
+ parseRemoteMcpIdentityConfig,
20
+ REMOTE_MCP_ENV,
21
+ REMOTE_MCP_IDENTITY_CONFIG,
22
+ type RemoteMcpIdentity,
23
+ remoteMcpIdentityEnv,
24
+ remoteMcpIdentityStackEnv,
25
+ sameRemoteMcpIdentity,
26
+ serializeRemoteMcpIdentity,
27
+ } from "./stack-contract.js";
28
+ import { applyStackConfig, type StackConfigInputs } from "./wizards/gcp-pulumi.js";
29
+
30
+ const identity: RemoteMcpIdentity = {
31
+ adminUrl: "https://caelo-production-admin-f89c711-7whkh344ca-ew.a.run.app",
32
+ mcpPublicUrl: "https://caelo-production-gateway-6da3bb0-7whkh344ca-ew.a.run.app",
33
+ iapAudience: "/projects/42/locations/europe-west1/services/caelo-production-admin-f89c711",
34
+ googleOrgDomain: "acme.com",
35
+ };
36
+
37
+ const contractInputs = {
38
+ provider: "gcp-firebase",
39
+ projectId: "viu-one-web",
40
+ env: "production",
41
+ domain: "viu.one",
42
+ region: "europe-west1",
43
+ stagingHost: "staging.viu.one",
44
+ databaseUrls: databaseUrls("10.153.0.3"),
45
+ } as const;
46
+ const contract = {
47
+ admin: adminEnvContract({
48
+ ...contractInputs,
49
+ firebaseSiteId: "caelo-production-site-eb4697",
50
+ gatewayService: "caelo-production-gateway-6da3bb0",
51
+ }),
52
+ gateway: gatewayEnvContract(contractInputs),
53
+ };
54
+
55
+ /** The env a stack declares for one service, given the config value. */
56
+ function declared(slug: "admin" | "gateway", config: string | undefined): CloudRunEnvVar[] {
57
+ return [...contract[slug], ...remoteMcpIdentityStackEnv(config)[slug]];
58
+ }
59
+
60
+ /** The live services after an installer run plus its old gcloud post-step: gcloud reorders. */
61
+ function live(slug: "admin" | "gateway"): CloudRunEnvVar[] {
62
+ return [...contract[slug], ...remoteMcpIdentityEnv(identity)[slug]].reverse();
63
+ }
64
+
65
+ /**
66
+ * What a refreshed preview plans for `containers.env`: the provider holds it
67
+ * as a set (terraform-provider-google `google_cloud_run_v2_service`), so
68
+ * order is no diff — a var only the live service has is removed, one only
69
+ * the program has is added, a different value is changed.
70
+ */
71
+ function envPlan(desired: readonly CloudRunEnvVar[], actual: readonly CloudRunEnvVar[]) {
72
+ const key = (v: CloudRunEnvVar) => JSON.stringify(v);
73
+ const want = new Map(desired.map((v) => [v.name, key(v)]));
74
+ const have = new Map(actual.map((v) => [v.name, key(v)]));
75
+ return {
76
+ removed: [...have.keys()].filter((n) => !want.has(n)).sort(),
77
+ added: [...want.keys()].filter((n) => !have.has(n)).sort(),
78
+ changed: [...want.keys()].filter((n) => have.has(n) && have.get(n) !== want.get(n)).sort(),
79
+ };
80
+ }
81
+
82
+ describe("remote-MCP identity as stack config (#1082)", () => {
83
+ it("round-trips through the config value", () => {
84
+ const raw = serializeRemoteMcpIdentity(identity);
85
+ expect(parseRemoteMcpIdentityConfig(raw)).toEqual(identity);
86
+ const { googleOrgDomain: _d, ...noOrg } = identity;
87
+ expect(parseRemoteMcpIdentityConfig(serializeRemoteMcpIdentity(noOrg))).toEqual(noOrg);
88
+ });
89
+
90
+ it("declares nothing while the config is unset (a fresh install's first up)", () => {
91
+ expect(parseRemoteMcpIdentityConfig(undefined)).toBeNull();
92
+ expect(remoteMcpIdentityStackEnv(undefined)).toEqual({ admin: [], gateway: [] });
93
+ });
94
+
95
+ it("refuses a corrupt or foreign value loudly instead of dropping the vars", () => {
96
+ expect(() => parseRemoteMcpIdentityConfig("{")).toThrow(REMOTE_MCP_IDENTITY_CONFIG);
97
+ expect(() =>
98
+ parseRemoteMcpIdentityConfig(JSON.stringify({ ...identity, adminUrl: "http://x" })),
99
+ ).toThrow("adminUrl");
100
+ expect(() => parseRemoteMcpIdentityConfig(JSON.stringify({ ...identity, extra: "x" }))).toThrow(
101
+ REMOTE_MCP_IDENTITY_CONFIG,
102
+ );
103
+ });
104
+
105
+ it("declares exactly the vars and values upgrade's gcloud roll sets", () => {
106
+ expect(remoteMcpIdentityStackEnv(serializeRemoteMcpIdentity(identity))).toEqual(
107
+ remoteMcpIdentityEnv(identity),
108
+ );
109
+ expect(
110
+ remoteMcpIdentityStackEnv(serializeRemoteMcpIdentity(identity))
111
+ .admin.map((v) => v.name)
112
+ .sort(),
113
+ ).toEqual(Object.values(REMOTE_MCP_ENV).sort());
114
+ });
115
+
116
+ it.each(["admin", "gateway"] as const)(
117
+ "%s: with the config the CLI writes, a refreshed plan removes and changes nothing",
118
+ (slug) => {
119
+ const plan = envPlan(declared(slug, serializeRemoteMcpIdentity(identity)), live(slug));
120
+ expect(plan).toEqual({ removed: [], added: [], changed: [] });
121
+ },
122
+ );
123
+
124
+ it("without it (the bug), every up strips the vars gcloud set", () => {
125
+ expect(envPlan(declared("admin", undefined), live("admin")).removed).toEqual([
126
+ "CAELO_ADMIN_URL",
127
+ "CAELO_GOOGLE_ORG_DOMAIN",
128
+ "CAELO_IAP_AUDIENCE",
129
+ "CAELO_MCP_PUBLIC_URL",
130
+ ]);
131
+ expect(envPlan(declared("gateway", undefined), live("gateway")).removed).toEqual([
132
+ "CAELO_ADMIN_URL",
133
+ "CAELO_MCP_PUBLIC_URL",
134
+ ]);
135
+ });
136
+
137
+ it("compares identities by every var they set", () => {
138
+ expect(sameRemoteMcpIdentity(identity, { ...identity })).toBe(true);
139
+ expect(sameRemoteMcpIdentity(identity, { ...identity, googleOrgDomain: undefined })).toBe(
140
+ false,
141
+ );
142
+ expect(sameRemoteMcpIdentity(identity, null)).toBe(false);
143
+ expect(sameRemoteMcpIdentity(null, null)).toBe(true);
144
+ });
145
+ });
146
+
147
+ describe("applyStackConfig writes the identity (#1082)", () => {
148
+ /** A Stack fake recording the config the CLI sets. */
149
+ function fakeStack() {
150
+ const config: Record<string, string> = {};
151
+ const stack = {
152
+ setAllConfig: async (c: automation.ConfigMap) => {
153
+ for (const [k, v] of Object.entries(c)) config[k] = v.value;
154
+ },
155
+ setConfig: async (k: string, v: automation.ConfigValue) => {
156
+ config[k] = v.value;
157
+ },
158
+ getConfig: async (k: string) => {
159
+ if (!(k in config)) throw new Error(`configuration key '${k}' not found`);
160
+ return { value: config[k] as string };
161
+ },
162
+ } as unknown as automation.Stack;
163
+ return { stack, config };
164
+ }
165
+ const inputs: StackConfigInputs = {
166
+ provider: "gcp-firebase",
167
+ projectId: "viu-one-web",
168
+ domain: "viu.one",
169
+ stagingDomain: "staging.viu.one",
170
+ ownerEmail: "owner@viu.one",
171
+ region: "europe-west1",
172
+ cloudSqlTier: "db-f1-micro",
173
+ cloudSqlHa: false,
174
+ adminMinInstances: 0,
175
+ adminMemory: "2Gi",
176
+ gatewayMinInstances: 0,
177
+ wafAdaptiveProtection: false,
178
+ iapAllowlist: ["owner@viu.one"],
179
+ imageDigests: { admin: "sha256:a", gateway: "sha256:g" },
180
+ };
181
+ const key = `caelo-gcp-firebase:${REMOTE_MCP_IDENTITY_CONFIG}`;
182
+
183
+ it("sets the key the stack reads, from the resolved identity", async () => {
184
+ const { stack, config } = fakeStack();
185
+ await applyStackConfig(stack, { ...inputs, remoteMcpIdentity: identity }, []);
186
+ expect(parseRemoteMcpIdentityConfig(config[key])).toEqual(identity);
187
+ });
188
+
189
+ it("keeps what an earlier run recorded when the identity could not be read", async () => {
190
+ const { stack, config } = fakeStack();
191
+ config[key] = serializeRemoteMcpIdentity(identity);
192
+ await applyStackConfig(stack, { ...inputs, remoteMcpIdentity: null }, []);
193
+ expect(parseRemoteMcpIdentityConfig(config[key])).toEqual(identity);
194
+ });
195
+ });
@@ -11,13 +11,15 @@ import { GCP_PUBLIC_BASE_PATHS } from "@caelo-cms/shared";
11
11
  import type { GcloudResult } from "./gcloud.js";
12
12
  import type { GcloudRunner } from "./gcloud-retry.js";
13
13
  import {
14
- applyRemoteMcpIdentityEnv,
14
+ checkRemoteMcpIdentityEnv,
15
15
  ensureMcpArmorRule,
16
+ findRemoteMcpTarget,
16
17
  type LiveUrlMap,
17
18
  planUrlMapPaths,
18
19
  type RemoteMcpTarget,
19
20
  resolveGoogleOrgDomain,
20
21
  resolveRemoteMcpIdentity,
22
+ resolveStackRemoteMcpIdentity,
21
23
  } from "./remote-mcp.js";
22
24
  import {
23
25
  GENERAL_RATE_LIMIT_PRIORITY,
@@ -497,30 +499,111 @@ describe("resolveGoogleOrgDomain (#914)", () => {
497
499
  });
498
500
  });
499
501
 
500
- describe("applyRemoteMcpIdentityEnv", () => {
502
+ const serviceJson = (env: { name: string; value: string }[]) =>
503
+ ok(JSON.stringify({ spec: { template: { spec: { containers: [{ env }] } } } }));
504
+ const DESCRIBE_JSON = "--region=europe-west1 --project=acme --format=json";
505
+
506
+ describe("findRemoteMcpTarget (#1082)", () => {
507
+ it("finds the Pulumi-suffixed services by prefix", async () => {
508
+ const { run } = fakeGcloud({
509
+ "run services list --region europe-west1 --project acme --filter metadata.name~^caelo-production-admin":
510
+ ok("caelo-production-admin-aaa\n"),
511
+ "run services list --region europe-west1 --project acme --filter metadata.name~^caelo-production-gateway":
512
+ ok("caelo-production-gateway-bbb\n"),
513
+ });
514
+ const { services: _s, ...base } = firebase;
515
+ expect(await findRemoteMcpTarget(base, { run })).toEqual({ ok: true, target: firebase });
516
+ });
517
+
518
+ it("fails naming the service it cannot find (a fresh install before its first up)", async () => {
519
+ const { run } = fakeGcloud({ "run services list": ok("") });
520
+ const { services: _s, ...base } = firebase;
521
+ const r = await findRemoteMcpTarget(base, { run });
522
+ expect(r.ok).toBe(false);
523
+ if (!r.ok) expect(r.error).toContain("caelo-production-admin* Cloud Run service");
524
+ });
525
+ });
526
+
527
+ describe("resolveStackRemoteMcpIdentity (#1082)", () => {
528
+ const base = {
529
+ "projects describe acme": ok("42\n"),
530
+ "projects get-ancestors acme": ANCESTORS_IN_ORG,
531
+ "run services describe caelo-production-admin-aaa": ok("https://a-xyz.a.run.app\n"),
532
+ "run services describe caelo-production-gateway-bbb": ok("https://g-xyz.a.run.app\n"),
533
+ };
534
+
535
+ it("is the live identity when the organisation is readable", async () => {
536
+ const { run } = fakeGcloud({ ...base, "organizations describe 123456": ok("acme.com\n") });
537
+ const r = await resolveStackRemoteMcpIdentity(firebase, { run });
538
+ expect(r.ok && r.identity.googleOrgDomain).toBe("acme.com");
539
+ expect(r.ok && r.orgDomainError).toBeUndefined();
540
+ });
541
+
542
+ it("carries the admin's live organisation domain over when it cannot be read now", async () => {
543
+ // The stack owns the var: leaving it out of the config would remove it
544
+ // on the next `up`, where a gcloud roll leaves it as it is.
545
+ const { run } = fakeGcloud({
546
+ ...base,
547
+ "organizations describe 123456": fail("PERMISSION_DENIED"),
548
+ [`run services describe caelo-production-admin-aaa ${DESCRIBE_JSON}`]: serviceJson([
549
+ { name: "CAELO_GOOGLE_ORG_DOMAIN", value: "acme.com" },
550
+ ]),
551
+ });
552
+ const r = await resolveStackRemoteMcpIdentity(firebase, { run });
553
+ expect(r.ok).toBe(true);
554
+ if (!r.ok) return;
555
+ expect(r.identity.googleOrgDomain).toBe("acme.com");
556
+ expect(r.orgDomainError).toContain("Organization Viewer");
557
+ });
558
+
559
+ it("sets no domain when neither the organisation nor the admin has one", async () => {
560
+ const { run } = fakeGcloud({
561
+ ...base,
562
+ "organizations describe 123456": fail("PERMISSION_DENIED"),
563
+ [`run services describe caelo-production-admin-aaa ${DESCRIBE_JSON}`]: serviceJson([]),
564
+ });
565
+ const r = await resolveStackRemoteMcpIdentity(firebase, { run });
566
+ expect(r.ok && r.identity.googleOrgDomain).toBeUndefined();
567
+ });
568
+ });
569
+
570
+ describe("checkRemoteMcpIdentityEnv (#1082)", () => {
501
571
  const identity = {
502
572
  adminUrl: "https://a.run.app",
503
573
  mcpPublicUrl: "https://g.run.app",
504
574
  iapAudience: "/projects/42/locations/europe-west1/services/caelo-production-admin-aaa",
505
575
  };
506
- const serviceJson = (env: { name: string; value: string }[]) =>
507
- ok(JSON.stringify({ spec: { template: { spec: { containers: [{ env }] } } } }));
576
+ const full = remoteMcpIdentityEnv(identity);
577
+ const plain = (env: ReturnType<typeof remoteMcpIdentityEnv>["admin"]) =>
578
+ env.map((e) => ({ name: e.name, value: "value" in e ? e.value : "" }));
508
579
 
509
- it("updates only a service whose vars differ", async () => {
580
+ it("reports no drift when the services carry the vars, and never updates a service", async () => {
510
581
  const { run, calls } = fakeGcloud({
511
- "run services describe caelo-production-admin-aaa": serviceJson([
512
- { name: "CAELO_IAP_AUDIENCE", value: identity.iapAudience },
513
- { name: "CAELO_MCP_PUBLIC_URL", value: identity.mcpPublicUrl },
514
- { name: "CAELO_ADMIN_URL", value: identity.adminUrl },
515
- ]),
516
- "run services describe caelo-production-gateway-bbb": serviceJson([]),
517
- "run services update caelo-production-gateway-bbb": ok(),
582
+ [`run services describe caelo-production-admin-aaa ${DESCRIBE_JSON}`]: serviceJson(
583
+ plain(full.admin),
584
+ ),
585
+ [`run services describe caelo-production-gateway-bbb ${DESCRIBE_JSON}`]: serviceJson(
586
+ plain(full.gateway),
587
+ ),
518
588
  });
519
- const r = await applyRemoteMcpIdentityEnv(firebase, identity, { run });
520
- expect(r.ok).toBe(true);
521
- expect(calls.filter((c) => c.startsWith("run services update"))).toEqual([
522
- "run services update caelo-production-gateway-bbb --region=europe-west1 --project=acme --update-env-vars=CAELO_ADMIN_URL=https://a.run.app,CAELO_MCP_PUBLIC_URL=https://g.run.app --quiet",
589
+ const r = await checkRemoteMcpIdentityEnv(firebase, identity, { run });
590
+ expect(r).toEqual({ ok: true, drift: { admin: [], gateway: [] } });
591
+ expect(calls.some((c) => c.startsWith("run services update"))).toBe(false);
592
+ });
593
+
594
+ it("names what a service lacks", async () => {
595
+ const { run, calls } = fakeGcloud({
596
+ [`run services describe caelo-production-admin-aaa ${DESCRIBE_JSON}`]: serviceJson(
597
+ plain(full.admin),
598
+ ),
599
+ [`run services describe caelo-production-gateway-bbb ${DESCRIBE_JSON}`]: serviceJson([]),
600
+ });
601
+ const r = await checkRemoteMcpIdentityEnv(firebase, identity, { run });
602
+ expect(r.ok && r.drift.gateway.map((c) => c.name)).toEqual([
603
+ "CAELO_ADMIN_URL",
604
+ "CAELO_MCP_PUBLIC_URL",
523
605
  ]);
606
+ expect(calls.some((c) => c.startsWith("run services update"))).toBe(false);
524
607
  });
525
608
  });
526
609
 
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