@caelo-cms/provisioning 0.10.70 → 0.10.72

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 (58) hide show
  1. package/dist/adc-check.d.ts +36 -0
  2. package/dist/adc-check.d.ts.map +1 -0
  3. package/dist/adc-check.js +52 -0
  4. package/dist/adc-check.js.map +1 -0
  5. package/dist/adc-impersonation.d.ts +6 -2
  6. package/dist/adc-impersonation.d.ts.map +1 -1
  7. package/dist/adc-impersonation.js +8 -9
  8. package/dist/adc-impersonation.js.map +1 -1
  9. package/dist/agent-db.js +3 -3
  10. package/dist/remote-mcp.d.ts +27 -8
  11. package/dist/remote-mcp.d.ts.map +1 -1
  12. package/dist/remote-mcp.js +88 -56
  13. package/dist/remote-mcp.js.map +1 -1
  14. package/dist/stack-contract.d.ts +53 -39
  15. package/dist/stack-contract.d.ts.map +1 -1
  16. package/dist/stack-contract.js +110 -0
  17. package/dist/stack-contract.js.map +1 -1
  18. package/dist/state-command.d.ts.map +1 -1
  19. package/dist/state-command.js +33 -3
  20. package/dist/state-command.js.map +1 -1
  21. package/dist/state-migrate.d.ts +12 -0
  22. package/dist/state-migrate.d.ts.map +1 -1
  23. package/dist/state-migrate.js +4 -0
  24. package/dist/state-migrate.js.map +1 -1
  25. package/dist/state-pulumi.d.ts +7 -0
  26. package/dist/state-pulumi.d.ts.map +1 -1
  27. package/dist/state-pulumi.js +11 -0
  28. package/dist/state-pulumi.js.map +1 -1
  29. package/dist/upgrade/converge.d.ts.map +1 -1
  30. package/dist/upgrade/converge.js +4 -2
  31. package/dist/upgrade/converge.js.map +1 -1
  32. package/dist/wizards/gcp-pulumi.d.ts +9 -1
  33. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  34. package/dist/wizards/gcp-pulumi.js +8 -1
  35. package/dist/wizards/gcp-pulumi.js.map +1 -1
  36. package/dist/wizards/gcp.d.ts.map +1 -1
  37. package/dist/wizards/gcp.js +105 -53
  38. package/dist/wizards/gcp.js.map +1 -1
  39. package/package.json +2 -2
  40. package/src/adc-check.test.ts +54 -0
  41. package/src/adc-check.ts +70 -0
  42. package/src/adc-impersonation.ts +14 -11
  43. package/src/provisioner-identity.test.ts +25 -0
  44. package/src/remote-mcp-stack-config.test.ts +195 -0
  45. package/src/remote-mcp.test.ts +99 -16
  46. package/src/remote-mcp.ts +104 -58
  47. package/src/stack-contract-parity.test.ts +28 -3
  48. package/src/stack-contract.ts +107 -6
  49. package/src/state-command.test.ts +12 -0
  50. package/src/state-command.ts +43 -3
  51. package/src/state-migrate.test.ts +53 -0
  52. package/src/state-migrate.ts +11 -0
  53. package/src/state-pulumi.ts +21 -0
  54. package/src/upgrade/converge.ts +4 -2
  55. package/src/wizards/gcp-pulumi.ts +21 -1
  56. package/src/wizards/gcp.ts +140 -64
  57. package/stacks/gcp/index.ts +13 -2
  58. 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
  *
@@ -28,4 +28,16 @@ describe("adcNote (#816)", () => {
28
28
  expect(note).toContain("gcloud auth application-default login");
29
29
  expect(note).toContain("upgrade, status and backup don't need them");
30
30
  });
31
+
32
+ it("expired ADC say so instead of 'no ADC' (#1078)", () => {
33
+ const note = adcNote(() => ({
34
+ ok: false,
35
+ stdout: "",
36
+ stderr:
37
+ "ERROR: (gcloud.auth.application-default.print-access-token) Reauthentication failed. cannot prompt during non-interactive execution.",
38
+ exitCode: 1,
39
+ }));
40
+ expect(note).toContain("have expired");
41
+ expect(note).toContain("gcloud auth application-default login");
42
+ });
31
43
  });
@@ -14,8 +14,11 @@ 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 { ADC_LOGIN_COMMAND, ADC_PROBE_ARGS, adcProbeFromResult } from "./adc-check.js";
18
+ import { checkAdcImpersonation } from "./adc-impersonation.js";
17
19
  import { adoptionCandidates, existingCandidates } from "./adopt-existing.js";
18
20
  import { checkGcloudAuth, gcloud } from "./gcloud.js";
21
+ import { GCP_STACK_ENV } from "./gcp-names.js";
19
22
  import {
20
23
  adoptStateBackend,
21
24
  currentMetadata,
@@ -33,7 +36,13 @@ import { installIapAllowlist } from "./operator-access.js";
33
36
  import { ensureOpsBackend } from "./ops-backend.js";
34
37
  import { writePlanFile } from "./preview-plan.js";
35
38
  import { provisionerServiceAccountEmail } from "./provisioner-identity.js";
39
+ import {
40
+ findRemoteMcpTarget,
41
+ type RemoteMcpTarget,
42
+ resolveStackRemoteMcpIdentity,
43
+ } from "./remote-mcp.js";
36
44
  import { selfCommand } from "./self-command.js";
45
+ import type { RemoteMcpIdentity } from "./stack-contract.js";
37
46
  import { attachInstall } from "./state-attach.js";
38
47
  import {
39
48
  gcsBackendRecord,
@@ -192,8 +201,18 @@ export async function stateCommand(argv: readonly string[]): Promise<void> {
192
201
  iapAllowlist: installIapAllowlist(meta.ownerEmail),
193
202
  imageDigests,
194
203
  },
204
+ resolveRemoteMcpIdentity: () =>
205
+ resolveRemoteMcpIdentityForPreview({
206
+ provider,
207
+ projectId,
208
+ region,
209
+ env: GCP_STACK_ENV,
210
+ domain: meta.domain,
211
+ }),
195
212
  }),
196
213
  report: clackReporter(),
214
+ checkAdc: () =>
215
+ checkAdcImpersonation({ projectId, operatorAccount: operator, freshGrant: false }),
197
216
  resolve: (record) =>
198
217
  resolveBackend(
199
218
  { installId, stateBackend: record },
@@ -319,7 +338,28 @@ export function attachProject(projectId: string): string {
319
338
  * do not, so a note on every attach sent Owners on a detour (#816).
320
339
  */
321
340
  export function adcNote(run: SyncGcloud): string | null {
322
- const r = run(["auth", "application-default", "print-access-token"]);
323
- if (r.ok) return null;
324
- 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";
341
+ const probe = adcProbeFromResult(run([...ADC_PROBE_ARGS]));
342
+ if (probe.ok) return null;
343
+ const state =
344
+ probe.reason === "expired"
345
+ ? "The Application Default Credentials on this machine have expired."
346
+ : "No Application Default Credentials on this machine.";
347
+ return `${state} upgrade, status and backup don't need them; re-running the installer or \`state migrate\` does: ${ADC_LOGIN_COMMAND}`;
348
+ }
349
+
350
+ /**
351
+ * #1082 — the remote-MCP identity a preview gives the stack, read from the
352
+ * live install exactly as the installer reads it before `up`
353
+ * (remote-mcp.ts resolveStackRemoteMcpIdentity). Unreadable: null, with a
354
+ * warning — the stack's recorded config then stands, and the preview shows
355
+ * any difference from what the services carry.
356
+ */
357
+ async function resolveRemoteMcpIdentityForPreview(
358
+ base: Omit<RemoteMcpTarget, "services">,
359
+ ): Promise<RemoteMcpIdentity | null> {
360
+ const found = await findRemoteMcpTarget(base);
361
+ const resolved = found.ok ? await resolveStackRemoteMcpIdentity(found.target) : found;
362
+ if (resolved.ok) return resolved.identity;
363
+ log.warn(`Could not read the remote-MCP URLs and IAP audience: ${resolved.error}`);
364
+ return null;
325
365
  }
@@ -156,6 +156,9 @@ function machine(initial: StateBackendRecord = { kind: "local" }) {
156
156
  planFiles: [] as { label: string; steps: number }[],
157
157
  /** What existingInProject answers (#1079); an Error is thrown. */
158
158
  existing: [] as readonly string[] | Error,
159
+ /** What checkAdc answers (#1078). */
160
+ adc: { ok: true } as { ok: true } | { ok: false; error: string },
161
+ adcChecks: 0,
159
162
  };
160
163
  const meta = (): MigrateInstall => ({
161
164
  installId: "gcp-firebase-acme",
@@ -169,6 +172,10 @@ function machine(initial: StateBackendRecord = { kind: "local" }) {
169
172
  const deps: MigrateDeps = {
170
173
  pulumi: p.ops,
171
174
  report,
175
+ checkAdc: async () => {
176
+ m.adcChecks++;
177
+ return m.adc;
178
+ },
172
179
  resolve: (record) =>
173
180
  resolveBackend(
174
181
  { installId: "gcp-firebase-acme", stateBackend: record },
@@ -566,3 +573,49 @@ describe("helpers", () => {
566
573
  expect(localStateNotice({ installId: "x", provider: "self-hosted" })).toBeNull();
567
574
  });
568
575
  });
576
+
577
+ describe("migrateState — Application Default Credentials (#1078)", () => {
578
+ /** A Pulumi workspace that fails the test on any call. */
579
+ const untouchable = (): StatePulumiOps =>
580
+ new Proxy({} as StatePulumiOps, {
581
+ get: (_t, name) => () => {
582
+ throw new Error(`Pulumi ${String(name)} must not run without working ADC`);
583
+ },
584
+ });
585
+
586
+ for (const [what, error] of [
587
+ [
588
+ "missing",
589
+ "No Application Default Credentials on this machine; Pulumi needs them. Run `gcloud auth application-default login` and re-run this command.",
590
+ ],
591
+ [
592
+ "expired",
593
+ "The Application Default Credentials on this machine have expired (gcloud: invalid_grant: reauth related error (invalid_rapt)). Run `gcloud auth application-default login` and re-run this command.",
594
+ ],
595
+ ] as const) {
596
+ for (const dryRun of [false, true]) {
597
+ it(`${what} ADC stop the run before any Pulumi call${dryRun ? " (dry run)" : ""}`, async () => {
598
+ const t = machine();
599
+ t.m.adc = { ok: false, error };
600
+ const r = await migrateState(
601
+ t.meta(),
602
+ { to: "remote", dryRun },
603
+ { ...t.deps, pulumi: untouchable() },
604
+ );
605
+ expect(r).toEqual({ ok: false, error: `${error} Nothing was changed.` });
606
+ expect(t.m.adcChecks).toBe(1);
607
+ expect(t.m.backend).toEqual({ kind: "local" });
608
+ expect(t.m.backups).toBe(0);
609
+ expect(t.m.ensureRemoteCalls).toEqual([]);
610
+ expect(t.m.locked).toBe(false);
611
+ });
612
+ }
613
+ }
614
+
615
+ it("working ADC let the migration run", async () => {
616
+ const t = machine();
617
+ const r = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
618
+ expect(r).toEqual({ ok: true, changed: true });
619
+ expect(t.m.adcChecks).toBe(1);
620
+ });
621
+ });
@@ -82,6 +82,13 @@ export interface StatePulumiOps {
82
82
  export interface MigrateDeps {
83
83
  readonly pulumi: StatePulumiOps;
84
84
  readonly report: UpgradeReporter;
85
+ /**
86
+ * #1078 — whether this machine's Application Default Credentials (which
87
+ * every Pulumi call here uses) work now. Checked before the first Pulumi
88
+ * call, so missing or expired ones stop the run with the login command
89
+ * instead of a page of per-resource provider errors.
90
+ */
91
+ checkAdc(): Promise<{ readonly ok: true } | { readonly ok: false; readonly error: string }>;
85
92
  /** The install's backend resolved for Pulumi. */
86
93
  resolve(record: StateBackendRecord): ResolvedBackend;
87
94
  /** Ask the operator to accept the ops backend's cost; false = cancel. */
@@ -241,6 +248,10 @@ export async function migrateState(
241
248
  };
242
249
  }
243
250
  }
251
+ const adc = await deps.checkAdc();
252
+ if (!adc.ok) {
253
+ return { ok: false, error: `${adc.error} Nothing was changed.` };
254
+ }
244
255
  const pre = report.step("Preflight: Pulumi CLI, source stack, pending operations, drift");
245
256
  let from: ResolvedBackend;
246
257
  let mode: "stack-migrate" | "export-import";
@@ -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
  {