@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
@@ -0,0 +1,228 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Adopt cloud resources the stack declares but its Pulumi state lacks
5
+ * (#1079), before the installer's `pulumi up`.
6
+ *
7
+ * `cms-provision upgrade` converges an install through gcloud and never runs
8
+ * Pulumi (stack-converge.ts), so it creates some resources the stack also
9
+ * declares — the gateway's service account (runtime-secrets.ts), the MCP
10
+ * service account (mcp-iap.ts) — without them entering the state. The next
11
+ * installer run then plans to CREATE them, and `state migrate` refuses an
12
+ * install whose preview plans anything. So, after the preview and before
13
+ * the review:
14
+ *
15
+ * 1. {@link adoptionCandidates}: the planned creates of a kind in
16
+ * {@link ADOPTABLE_KINDS} whose inputs name the cloud resource. Only
17
+ * planned steps are looked at, so nothing the stack does not declare is
18
+ * ever adopted; every other kind is left to Pulumi.
19
+ * 2. {@link existingCandidates}: a gcloud describe per candidate. Exists →
20
+ * adopt; NOT_FOUND → a plain create; anything else stops the run (we
21
+ * can't tell, and guessing either way is wrong).
22
+ * 3. `pulumi import` of the existing ones into the stack, under the
23
+ * stack's explicit GCP provider (wizards/gcp-pulumi.ts). State only: the
24
+ * cloud resources are not touched.
25
+ * 4. A second preview: what the review shows and `up` applies. An adopted
26
+ * resource that is still planned as a create stops the run.
27
+ *
28
+ * Why only service accounts ({@link ADOPTABLE_KINDS}). A kind is adopted
29
+ * when the stack declares it AND the CLI also creates it through gcloud, and
30
+ * its name is unique in the project. Checked per kind against both stacks:
31
+ *
32
+ * - Secret Manager secrets: the stack declares only `postgres-password` and
33
+ * `secret-kek`, which the CLI never creates; the secrets the CLI makes
34
+ * (stack-contract.ts CLI_GENERATED_SECRETS) the stack only references by
35
+ * id. Adopting a secret without its value resource would also make `up`
36
+ * add a version with a freshly generated value — a new KEK.
37
+ * - Buckets, Cloud Run jobs, custom roles: the stack's buckets are made by
38
+ * Pulumi alone; the CLI's own (ops, audit results, the upgrader, converge,
39
+ * audit and operator-access jobs, their custom roles) are not declared.
40
+ * - The admin's Cloud Run domain mapping: owned by `admin-domain enable`
41
+ * (admin-domain.ts), the stack's copy stays off; Pulumi, running as the
42
+ * provisioner, could not manage it.
43
+ * - IAM member resources (`*IamMember`, `IAMMember`): non-authoritative.
44
+ * Their create is a read-modify-write that merges the member into the
45
+ * policy and succeeds when it is there already, so a planned create of
46
+ * one is an adoption already.
47
+ * - Cloud SQL users: the CLI's are IAM users named after service accounts,
48
+ * the stack's are the built-in `admin_role` / `public_role`.
49
+ */
50
+
51
+ import type { GcloudRunner } from "./gcloud-retry.js";
52
+ import type { PlannedStep } from "./preview-plan.js";
53
+
54
+ /** The cloud identity of a resource a planned create would make. */
55
+ export interface AdoptionTarget {
56
+ /** How the operator knows it, e.g. the service account's email. */
57
+ readonly display: string;
58
+ /** gcloud args that succeed exactly when it exists. */
59
+ readonly describeArgs: readonly string[];
60
+ /** The id `pulumi import` takes for the kind. */
61
+ readonly importId: string;
62
+ }
63
+
64
+ /** A resource kind the installer adopts when it exists already. */
65
+ export interface AdoptableKind {
66
+ /** What it is, for messages (`service account`). */
67
+ readonly label: string;
68
+ /**
69
+ * The resource a create with these inputs would make, or null when the
70
+ * inputs don't pin it down (then the create is left to Pulumi).
71
+ */
72
+ target(inputs: Readonly<Record<string, unknown>>, projectId: string): AdoptionTarget | null;
73
+ }
74
+
75
+ /** Kinds adopted before `up`, by Pulumi type token. See the file comment for the rest. */
76
+ export const ADOPTABLE_KINDS: Readonly<Record<string, AdoptableKind>> = {
77
+ "gcp:serviceaccount/account:Account": {
78
+ label: "service account",
79
+ target(inputs, projectId) {
80
+ // GCP's account-id format; also keeps out the engine's placeholder
81
+ // for a value only known after apply.
82
+ const accountId = inputs.accountId;
83
+ if (typeof accountId !== "string" || !/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(accountId)) {
84
+ return null;
85
+ }
86
+ // Unset means the provider's project, which is the install's
87
+ // (assertStackProject). Another project is never adopted.
88
+ const project = inputs.project ?? projectId;
89
+ if (project !== projectId) return null;
90
+ const email = `${accountId}@${projectId}.iam.gserviceaccount.com`;
91
+ return {
92
+ display: email,
93
+ describeArgs: [
94
+ "iam",
95
+ "service-accounts",
96
+ "describe",
97
+ email,
98
+ `--project=${projectId}`,
99
+ "--format=value(email)",
100
+ ],
101
+ importId: `projects/${projectId}/serviceAccounts/${email}`,
102
+ };
103
+ },
104
+ },
105
+ };
106
+
107
+ /** A planned create the installer may adopt instead. */
108
+ export interface AdoptionCandidate extends AdoptionTarget {
109
+ readonly step: PlannedStep;
110
+ readonly label: string;
111
+ }
112
+
113
+ /** Thrown when adoption cannot finish; nothing was applied. */
114
+ export class AdoptionError extends Error {
115
+ override readonly name = "AdoptionError";
116
+ }
117
+
118
+ /** The planned creates of an adoptable kind, with what each would create. */
119
+ export function adoptionCandidates(
120
+ steps: readonly PlannedStep[],
121
+ projectId: string,
122
+ ): AdoptionCandidate[] {
123
+ const out: AdoptionCandidate[] = [];
124
+ for (const step of steps) {
125
+ if (step.op !== "create") continue;
126
+ const kind = ADOPTABLE_KINDS[step.type];
127
+ if (!kind || !step.inputs) continue;
128
+ const target = kind.target(step.inputs, projectId);
129
+ if (target) out.push({ ...target, step, label: kind.label });
130
+ }
131
+ return out;
132
+ }
133
+
134
+ const NOT_FOUND = /NOT_FOUND|not found|does not exist/i;
135
+
136
+ /**
137
+ * The candidates that exist in the project.
138
+ *
139
+ * @throws AdoptionError when a describe fails for another reason than NOT_FOUND
140
+ */
141
+ export async function existingCandidates(
142
+ candidates: readonly AdoptionCandidate[],
143
+ run: GcloudRunner,
144
+ ): Promise<AdoptionCandidate[]> {
145
+ const existing: AdoptionCandidate[] = [];
146
+ for (const c of candidates) {
147
+ const r = await run([...c.describeArgs]);
148
+ if (r.ok) existing.push(c);
149
+ else if (!NOT_FOUND.test(r.stderr)) {
150
+ throw new AdoptionError(
151
+ `could not tell whether the ${c.label} ${c.display} exists (gcloud: ${r.stderr.trim()}). Nothing was imported or applied. Check that gcloud is signed in as the Owner (gcloud auth login) and re-run the installer.`,
152
+ );
153
+ }
154
+ }
155
+ return existing;
156
+ }
157
+
158
+ /** What adoption needs from Pulumi and gcloud; fakes in tests. */
159
+ export interface AdoptionDeps {
160
+ readonly projectId: string;
161
+ readonly gcloud: GcloudRunner;
162
+ /** A fresh preview of the stack. */
163
+ preview(): Promise<readonly PlannedStep[]>;
164
+ /** `pulumi import` the candidates into the stack (state only). */
165
+ importResources(candidates: readonly AdoptionCandidate[]): Promise<void>;
166
+ }
167
+
168
+ /** The plan to review after adoption, and what was adopted into it. */
169
+ export interface AdoptionResult {
170
+ readonly adopted: readonly AdoptionCandidate[];
171
+ readonly steps: readonly PlannedStep[];
172
+ }
173
+
174
+ /**
175
+ * The line of a failed Pulumi call that says what went wrong. The
176
+ * Automation API's error starts with `code: 255` and carries the CLI's
177
+ * stderr below; its first `error:` line is the reason.
178
+ */
179
+ export function pulumiErrorReason(e: unknown): string {
180
+ const lines = (e instanceof Error ? e.message : String(e))
181
+ .split("\n")
182
+ .map((l) => l.trim().replace(/^stderr:\s*/, ""))
183
+ .filter((l) => l !== "");
184
+ return lines.find((l) => /^error:/i.test(l)) ?? lines[0] ?? "no output";
185
+ }
186
+
187
+ /**
188
+ * Adopt the planned creates of `steps` that exist in the project already,
189
+ * and return the plan after it. With nothing to adopt, `steps` comes back
190
+ * unchanged and nothing else runs.
191
+ *
192
+ * @throws AdoptionError when a probe or the import fails, or a re-plan still
193
+ * creates an adopted resource; `up` must not run then
194
+ */
195
+ export async function adoptExistingResources(
196
+ steps: readonly PlannedStep[],
197
+ deps: AdoptionDeps,
198
+ ): Promise<AdoptionResult> {
199
+ const existing = await existingCandidates(adoptionCandidates(steps, deps.projectId), deps.gcloud);
200
+ if (existing.length === 0) return { adopted: [], steps };
201
+ const names = existing.map((c) => `${c.label} ${c.display}`).join(", ");
202
+ try {
203
+ await deps.importResources(existing);
204
+ } catch (e) {
205
+ throw new AdoptionError(
206
+ `could not adopt ${names} (already in the project) into the stack: pulumi import says "${pulumiErrorReason(e)}". Nothing was applied. Re-run the installer: what was adopted stays adopted. If it fails the same way, report it with this message.`,
207
+ );
208
+ }
209
+ const after = await deps.preview();
210
+ const stillCreated = after.filter(
211
+ (s) => s.op === "create" && existing.some((c) => c.step.urn === s.urn),
212
+ );
213
+ if (stillCreated.length > 0) {
214
+ throw new AdoptionError(
215
+ `imported ${names}, but the next preview still creates ${stillCreated.map((s) => s.name).join(", ")}. Nothing was applied. Report this with the message; re-running the installer does not fix it.`,
216
+ );
217
+ }
218
+ return { adopted: existing, steps: after };
219
+ }
220
+
221
+ /** The adopted resources as lines for the review, or none. */
222
+ export function formatAdopted(adopted: readonly AdoptionCandidate[]): string[] {
223
+ if (adopted.length === 0) return [];
224
+ return [
225
+ `Adopted (${adopted.length}) — already in the project, now in the stack instead of created again:`,
226
+ ...adopted.map((c) => ` ${c.label} ${c.display} (${c.step.name})`),
227
+ ];
228
+ }
@@ -31,6 +31,13 @@ export interface PlannedStep {
31
31
  readonly diffs: readonly string[];
32
32
  /** Properties that force the replace (replace steps only). */
33
33
  readonly replaceReasons: readonly string[];
34
+ /**
35
+ * The inputs the program declares, for create steps only (#1079: they name
36
+ * the cloud resource a create would make, e.g. a service account's
37
+ * `accountId`, so adopt-existing.ts can tell whether it exists already).
38
+ * Secret values arrive masked by the engine. Never written to a plan file.
39
+ */
40
+ readonly inputs?: Readonly<Record<string, unknown>>;
34
41
  }
35
42
 
36
43
  /**
@@ -116,6 +123,7 @@ export function collectPreviewSteps(): PreviewCollector {
116
123
  urn: m.urn,
117
124
  diffs: [...(m.diffs ?? [])],
118
125
  replaceReasons: op === "replace" ? [...(m.keys ?? [])] : [],
126
+ ...(op === "create" && m.new?.inputs ? { inputs: { ...m.new.inputs } } : {}),
119
127
  });
120
128
  },
121
129
  steps: () => [...byUrn.values()],
@@ -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