@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.
- package/dist/adc-check.d.ts +36 -0
- package/dist/adc-check.d.ts.map +1 -0
- package/dist/adc-check.js +52 -0
- package/dist/adc-check.js.map +1 -0
- package/dist/adc-impersonation.d.ts +6 -2
- package/dist/adc-impersonation.d.ts.map +1 -1
- package/dist/adc-impersonation.js +8 -9
- package/dist/adc-impersonation.js.map +1 -1
- package/dist/agent-db.js +3 -3
- 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 +33 -3
- package/dist/state-command.js.map +1 -1
- package/dist/state-migrate.d.ts +12 -0
- package/dist/state-migrate.d.ts.map +1 -1
- package/dist/state-migrate.js +4 -0
- package/dist/state-migrate.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 +105 -53
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/adc-check.test.ts +54 -0
- package/src/adc-check.ts +70 -0
- package/src/adc-impersonation.ts +14 -11
- package/src/provisioner-identity.test.ts +25 -0
- 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.test.ts +12 -0
- package/src/state-command.ts +43 -3
- package/src/state-migrate.test.ts +53 -0
- package/src/state-migrate.ts +11 -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 +140 -64
- package/stacks/gcp/index.ts +13 -2
- package/stacks/gcp-firebase/index.ts +20 -6
package/src/adc-check.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Probe this machine's Application Default Credentials (#1078) — the
|
|
5
|
+
* credentials every local Pulumi run uses (the installer in local mode,
|
|
6
|
+
* `state migrate`). Without the probe, Pulumi starts with missing or
|
|
7
|
+
* expired ADC and prints one provider error per resource (`invalid_grant`
|
|
8
|
+
* … `invalid_rapt`) before failing, and the one line the Owner needs —
|
|
9
|
+
* `gcloud auth application-default login` — never appears.
|
|
10
|
+
*
|
|
11
|
+
* The probe runs `gcloud auth application-default print-access-token`,
|
|
12
|
+
* which refreshes the token exactly as Pulumi's Google provider would, so
|
|
13
|
+
* expired or reauth-blocked credentials fail here. The token is discarded.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { type GcloudResult, gcloudErrorLine, isGcloudAuthError } from "./gcloud.js";
|
|
17
|
+
|
|
18
|
+
/** The command that fixes both a missing and an expired login. */
|
|
19
|
+
export const ADC_LOGIN_COMMAND = "gcloud auth application-default login";
|
|
20
|
+
|
|
21
|
+
export type AdcProbe =
|
|
22
|
+
| { readonly ok: true }
|
|
23
|
+
| {
|
|
24
|
+
readonly ok: false;
|
|
25
|
+
/**
|
|
26
|
+
* `missing`: no ADC on this machine; `expired`: they exist but can no
|
|
27
|
+
* longer mint a token (expired/revoked refresh token, reauth policy);
|
|
28
|
+
* `error`: gcloud failed for another reason (its line is in `error`).
|
|
29
|
+
*/
|
|
30
|
+
readonly reason: "missing" | "expired" | "error";
|
|
31
|
+
/** One line naming {@link ADC_LOGIN_COMMAND}. */
|
|
32
|
+
readonly error: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** gcloud's wording when no ADC file exists (and google-auth's, for older SDKs). */
|
|
36
|
+
const ADC_MISSING = /credentials were not found|could not automatically determine credentials/i;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Classify the result of `gcloud auth application-default print-access-token`.
|
|
40
|
+
* Pure, so the async installer path and the sync attach path word it alike.
|
|
41
|
+
*/
|
|
42
|
+
export function adcProbeFromResult(r: GcloudResult): AdcProbe {
|
|
43
|
+
if (r.ok && r.stdout.trim().length > 0) return { ok: true };
|
|
44
|
+
if (ADC_MISSING.test(r.stderr) || (r.ok && r.stdout.trim().length === 0)) {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
reason: "missing",
|
|
48
|
+
error: `No Application Default Credentials on this machine; Pulumi needs them. Run \`${ADC_LOGIN_COMMAND}\` and re-run this command.`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (isGcloudAuthError(r.stderr)) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
reason: "expired",
|
|
55
|
+
error: `The Application Default Credentials on this machine have expired (gcloud: ${gcloudErrorLine(r.stderr)}). Run \`${ADC_LOGIN_COMMAND}\` and re-run this command.`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
reason: "error",
|
|
61
|
+
error: `Could not check the Application Default Credentials on this machine (gcloud: ${gcloudErrorLine(r.stderr)}). Run \`${ADC_LOGIN_COMMAND}\` if they are missing or expired, then re-run this command.`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The `print-access-token` arguments the probe runs. */
|
|
66
|
+
export const ADC_PROBE_ARGS: readonly string[] = [
|
|
67
|
+
"auth",
|
|
68
|
+
"application-default",
|
|
69
|
+
"print-access-token",
|
|
70
|
+
];
|
package/src/adc-impersonation.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* stay in local variables: never printed, logged, returned or written.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { ADC_PROBE_ARGS, adcProbeFromResult } from "./adc-check.js";
|
|
15
16
|
import { gcloud as defaultGcloud } from "./gcloud.js";
|
|
16
17
|
import { type GcloudRunner, realSleep, type Sleep } from "./gcloud-retry.js";
|
|
17
18
|
import {
|
|
@@ -30,8 +31,12 @@ export type AdcCheck =
|
|
|
30
31
|
| { readonly ok: true }
|
|
31
32
|
| {
|
|
32
33
|
readonly ok: false;
|
|
33
|
-
/**
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* `no-adc`: no Application Default Credentials; `expired-adc`: they
|
|
36
|
+
* exist but can no longer mint a token (#1078); `denied`: they can't
|
|
37
|
+
* impersonate.
|
|
38
|
+
*/
|
|
39
|
+
readonly reason: "no-adc" | "expired-adc" | "denied" | "error";
|
|
35
40
|
readonly error: string;
|
|
36
41
|
};
|
|
37
42
|
|
|
@@ -53,19 +58,17 @@ export async function checkAdcImpersonation(
|
|
|
53
58
|
const sa = provisionerServiceAccountEmail(target.projectId);
|
|
54
59
|
const env = withoutAmbientGoogleCredentials(deps.env ?? process.env);
|
|
55
60
|
|
|
56
|
-
const adc = await run([
|
|
57
|
-
const
|
|
58
|
-
if (!
|
|
61
|
+
const adc = await run([...ADC_PROBE_ARGS], { env });
|
|
62
|
+
const probe = adcProbeFromResult(adc);
|
|
63
|
+
if (!probe.ok) {
|
|
59
64
|
return {
|
|
60
65
|
ok: false,
|
|
61
|
-
reason: "no-adc",
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
`Infrastructure runs authenticate with your own Google login and impersonate ${sa}; no key file is used.`,
|
|
65
|
-
`Run \`gcloud auth application-default login\` (sign in as ${target.operatorAccount}), then re-run.`,
|
|
66
|
-
].join("\n"),
|
|
66
|
+
reason: probe.reason === "expired" ? "expired-adc" : "no-adc",
|
|
67
|
+
// One line (#1078): the login command must not drown in context.
|
|
68
|
+
error: `${probe.error} Sign in as ${target.operatorAccount}: Pulumi uses these credentials to impersonate ${sa}, no key file.`,
|
|
67
69
|
};
|
|
68
70
|
}
|
|
71
|
+
const sourceToken = adc.stdout.trim();
|
|
69
72
|
|
|
70
73
|
// The account to impersonate is the one IAM reports, not a string
|
|
71
74
|
// assembled from install.json: what goes into the request is an email
|
|
@@ -369,6 +369,31 @@ describe("checkAdcImpersonation", () => {
|
|
|
369
369
|
expect(env.GOOGLE_OAUTH_ACCESS_TOKEN).toBeUndefined();
|
|
370
370
|
});
|
|
371
371
|
|
|
372
|
+
it("expired ADC stop before IAM or the Credentials API is asked (#1078)", async () => {
|
|
373
|
+
const g = fakeGcloud({
|
|
374
|
+
"auth application-default print-access-token": [
|
|
375
|
+
fail(
|
|
376
|
+
"ERROR: (gcloud.auth.application-default.print-access-token) There was a problem refreshing your current auth tokens: ('invalid_grant: reauth related error (invalid_rapt)', {'error': 'invalid_grant'})",
|
|
377
|
+
),
|
|
378
|
+
],
|
|
379
|
+
});
|
|
380
|
+
const f = fakeFetch([]);
|
|
381
|
+
const r = await checkAdcImpersonation(
|
|
382
|
+
{ projectId: PROJECT, operatorAccount: OPERATOR, freshGrant: false },
|
|
383
|
+
{ run: g.run, sleep: noSleep, fetch: f.impl },
|
|
384
|
+
);
|
|
385
|
+
expect(r.ok).toBe(false);
|
|
386
|
+
if (r.ok) return;
|
|
387
|
+
expect(r.reason).toBe("expired-adc");
|
|
388
|
+
expect(r.error).toContain("have expired");
|
|
389
|
+
expect(r.error).toContain("`gcloud auth application-default login`");
|
|
390
|
+
expect(r.error).not.toContain("\n");
|
|
391
|
+
expect(g.calls.map((c) => c.args.join(" "))).toEqual([
|
|
392
|
+
"auth application-default print-access-token",
|
|
393
|
+
]);
|
|
394
|
+
expect(f.urls).toEqual([]);
|
|
395
|
+
});
|
|
396
|
+
|
|
372
397
|
it("succeeds when ADC may mint a token for the provisioner, and keeps no token", async () => {
|
|
373
398
|
const g = fakeGcloud({
|
|
374
399
|
"auth application-default print-access-token": [ok(`${SOURCE}\n`)],
|
|
@@ -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
|
|