@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.
- package/dist/remote-mcp.d.ts +27 -8
- package/dist/remote-mcp.d.ts.map +1 -1
- package/dist/remote-mcp.js +88 -56
- package/dist/remote-mcp.js.map +1 -1
- package/dist/stack-contract.d.ts +53 -39
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +110 -0
- package/dist/stack-contract.js.map +1 -1
- package/dist/state-command.d.ts.map +1 -1
- package/dist/state-command.js +24 -0
- package/dist/state-command.js.map +1 -1
- package/dist/state-pulumi.d.ts +7 -0
- package/dist/state-pulumi.d.ts.map +1 -1
- package/dist/state-pulumi.js +11 -0
- package/dist/state-pulumi.js.map +1 -1
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +4 -2
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/wizards/gcp-pulumi.d.ts +9 -1
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +8 -1
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +86 -41
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/remote-mcp-stack-config.test.ts +195 -0
- package/src/remote-mcp.test.ts +99 -16
- package/src/remote-mcp.ts +104 -58
- package/src/stack-contract-parity.test.ts +28 -3
- package/src/stack-contract.ts +107 -6
- package/src/state-command.ts +32 -0
- package/src/state-pulumi.ts +21 -0
- package/src/upgrade/converge.ts +4 -2
- package/src/wizards/gcp-pulumi.ts +21 -1
- package/src/wizards/gcp.ts +115 -51
- package/stacks/gcp/index.ts +13 -2
- 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
|
+
});
|
package/src/remote-mcp.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
507
|
-
|
|
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("
|
|
580
|
+
it("reports no drift when the services carry the vars, and never updates a service", async () => {
|
|
510
581
|
const { run, calls } = fakeGcloud({
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
|
520
|
-
expect(r
|
|
521
|
-
expect(calls.
|
|
522
|
-
|
|
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
|
|
7
|
-
*
|
|
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
|
|
10
|
-
* identities ({@link RemoteMcpIdentity}
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
231
|
-
*
|
|
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
|
|
235
|
-
|
|
236
|
-
identity: RemoteMcpIdentity,
|
|
259
|
+
export async function findRemoteMcpTarget(
|
|
260
|
+
base: Omit<RemoteMcpTarget, "services">,
|
|
237
261
|
deps: { run?: GcloudRunner } = {},
|
|
238
|
-
): Promise<RemoteMcpResult<{ readonly
|
|
262
|
+
): Promise<RemoteMcpResult<{ readonly target: RemoteMcpTarget }>> {
|
|
239
263
|
const run = deps.run ?? defaultGcloud;
|
|
240
|
-
const
|
|
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
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
"
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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 {
|
|
282
|
+
return {
|
|
283
|
+
ok: true,
|
|
284
|
+
target: { ...base, services: services as Record<CloudRunSlug, string> },
|
|
285
|
+
};
|
|
274
286
|
}
|
|
275
287
|
|
|
276
|
-
/**
|
|
277
|
-
|
|
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
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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:
|
|
294
|
-
|
|
295
|
-
|
|
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(
|
|
135
|
-
|
|
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
|
|