@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.
- package/dist/adopt-existing.d.ts +119 -0
- package/dist/adopt-existing.d.ts.map +1 -0
- package/dist/adopt-existing.js +118 -0
- package/dist/adopt-existing.js.map +1 -0
- package/dist/preview-plan.d.ts +7 -0
- package/dist/preview-plan.d.ts.map +1 -1
- package/dist/preview-plan.js +1 -0
- package/dist/preview-plan.js.map +1 -1
- 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 +27 -1
- package/dist/state-command.js.map +1 -1
- package/dist/state-migrate.d.ts +7 -0
- package/dist/state-migrate.d.ts.map +1 -1
- package/dist/state-migrate.js +18 -1
- 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 +28 -5
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +70 -8
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +100 -46
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/fixtures/viu-one-plan-state-migrate.txt +98 -0
- package/src/adopt-existing.test.ts +397 -0
- package/src/adopt-existing.ts +228 -0
- package/src/preview-plan.ts +8 -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.ts +38 -1
- package/src/state-migrate.test.ts +38 -0
- package/src/state-migrate.ts +24 -1
- package/src/state-pulumi.ts +21 -0
- package/src/upgrade/converge.ts +4 -2
- package/src/wizards/gcp-pulumi.ts +105 -9
- package/src/wizards/gcp.ts +126 -55
- package/stacks/gcp/index.ts +13 -2
- 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
|
|
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
|
|
package/src/stack-contract.ts
CHANGED
|
@@ -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.
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
* (
|
|
649
|
-
*
|
|
650
|
-
*
|
|
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
|
*
|
package/src/state-command.ts
CHANGED
|
@@ -14,7 +14,9 @@ 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 {
|
|
17
|
+
import { adoptionCandidates, existingCandidates } from "./adopt-existing.js";
|
|
18
|
+
import { checkGcloudAuth, gcloud } from "./gcloud.js";
|
|
19
|
+
import { GCP_STACK_ENV } from "./gcp-names.js";
|
|
18
20
|
import {
|
|
19
21
|
adoptStateBackend,
|
|
20
22
|
currentMetadata,
|
|
@@ -32,7 +34,13 @@ import { installIapAllowlist } from "./operator-access.js";
|
|
|
32
34
|
import { ensureOpsBackend } from "./ops-backend.js";
|
|
33
35
|
import { writePlanFile } from "./preview-plan.js";
|
|
34
36
|
import { provisionerServiceAccountEmail } from "./provisioner-identity.js";
|
|
37
|
+
import {
|
|
38
|
+
findRemoteMcpTarget,
|
|
39
|
+
type RemoteMcpTarget,
|
|
40
|
+
resolveStackRemoteMcpIdentity,
|
|
41
|
+
} from "./remote-mcp.js";
|
|
35
42
|
import { selfCommand } from "./self-command.js";
|
|
43
|
+
import type { RemoteMcpIdentity } from "./stack-contract.js";
|
|
36
44
|
import { attachInstall } from "./state-attach.js";
|
|
37
45
|
import {
|
|
38
46
|
gcsBackendRecord,
|
|
@@ -191,6 +199,14 @@ export async function stateCommand(argv: readonly string[]): Promise<void> {
|
|
|
191
199
|
iapAllowlist: installIapAllowlist(meta.ownerEmail),
|
|
192
200
|
imageDigests,
|
|
193
201
|
},
|
|
202
|
+
resolveRemoteMcpIdentity: () =>
|
|
203
|
+
resolveRemoteMcpIdentityForPreview({
|
|
204
|
+
provider,
|
|
205
|
+
projectId,
|
|
206
|
+
region,
|
|
207
|
+
env: GCP_STACK_ENV,
|
|
208
|
+
domain: meta.domain,
|
|
209
|
+
}),
|
|
194
210
|
}),
|
|
195
211
|
report: clackReporter(),
|
|
196
212
|
resolve: (record) =>
|
|
@@ -260,6 +276,10 @@ export async function stateCommand(argv: readonly string[]): Promise<void> {
|
|
|
260
276
|
return moved;
|
|
261
277
|
},
|
|
262
278
|
writePlan: (label, steps) => writePlanFile(root, label, steps),
|
|
279
|
+
existingInProject: async (steps) =>
|
|
280
|
+
(await existingCandidates(adoptionCandidates(steps, projectId), gcloud)).map(
|
|
281
|
+
(c) => `${c.label} ${c.display}`,
|
|
282
|
+
),
|
|
263
283
|
},
|
|
264
284
|
);
|
|
265
285
|
if (!result.ok) {
|
|
@@ -318,3 +338,20 @@ export function adcNote(run: SyncGcloud): string | null {
|
|
|
318
338
|
if (r.ok) return null;
|
|
319
339
|
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";
|
|
320
340
|
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* #1082 — the remote-MCP identity a preview gives the stack, read from the
|
|
344
|
+
* live install exactly as the installer reads it before `up`
|
|
345
|
+
* (remote-mcp.ts resolveStackRemoteMcpIdentity). Unreadable: null, with a
|
|
346
|
+
* warning — the stack's recorded config then stands, and the preview shows
|
|
347
|
+
* any difference from what the services carry.
|
|
348
|
+
*/
|
|
349
|
+
async function resolveRemoteMcpIdentityForPreview(
|
|
350
|
+
base: Omit<RemoteMcpTarget, "services">,
|
|
351
|
+
): Promise<RemoteMcpIdentity | null> {
|
|
352
|
+
const found = await findRemoteMcpTarget(base);
|
|
353
|
+
const resolved = found.ok ? await resolveStackRemoteMcpIdentity(found.target) : found;
|
|
354
|
+
if (resolved.ok) return resolved.identity;
|
|
355
|
+
log.warn(`Could not read the remote-MCP URLs and IAP audience: ${resolved.error}`);
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
@@ -154,6 +154,8 @@ function machine(initial: StateBackendRecord = { kind: "local" }) {
|
|
|
154
154
|
acceptCost: true,
|
|
155
155
|
bucketRecord: (initial.kind === "gcs" ? "gcs" : null) as "gcs" | "local" | null,
|
|
156
156
|
planFiles: [] as { label: string; steps: number }[],
|
|
157
|
+
/** What existingInProject answers (#1079); an Error is thrown. */
|
|
158
|
+
existing: [] as readonly string[] | Error,
|
|
157
159
|
};
|
|
158
160
|
const meta = (): MigrateInstall => ({
|
|
159
161
|
installId: "gcp-firebase-acme",
|
|
@@ -209,6 +211,10 @@ function machine(initial: StateBackendRecord = { kind: "local" }) {
|
|
|
209
211
|
m.planFiles.push({ label, steps: steps.length });
|
|
210
212
|
return `${ROOT}/plan-${label}.txt`;
|
|
211
213
|
},
|
|
214
|
+
existingInProject: async () => {
|
|
215
|
+
if (m.existing instanceof Error) throw m.existing;
|
|
216
|
+
return m.existing;
|
|
217
|
+
},
|
|
212
218
|
};
|
|
213
219
|
p.stacks.set(localUrl, {
|
|
214
220
|
outputs: {
|
|
@@ -380,6 +386,38 @@ describe("migrateState — failures leave the source authoritative", () => {
|
|
|
380
386
|
expect(t.m.backups).toBe(0);
|
|
381
387
|
});
|
|
382
388
|
|
|
389
|
+
it("the refusal names the planned creates the installer adopts (#1079)", async () => {
|
|
390
|
+
const t = machine();
|
|
391
|
+
const local = t.p.stacks.get(t.localUrl);
|
|
392
|
+
if (local) local.drift = 2;
|
|
393
|
+
t.m.existing = ["service account caelo-production-gateway-sa@acme.iam.gserviceaccount.com"];
|
|
394
|
+
const r = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
|
|
395
|
+
expect(r.ok).toBe(false);
|
|
396
|
+
if (r.ok) return;
|
|
397
|
+
expect(r.error).toContain(
|
|
398
|
+
"1 of the planned creates exist in the project already (service account caelo-production-gateway-sa@acme.iam.gserviceaccount.com): upgrade made them without Pulumi. The installer adopts them into the stack first",
|
|
399
|
+
);
|
|
400
|
+
expect(r.error.indexOf("adopts them")).toBeLessThan(r.error.indexOf("Run upgrade first"));
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("the refusal says nothing about adopting when nothing exists, and says so when it can't check", async () => {
|
|
404
|
+
const t = machine();
|
|
405
|
+
const local = t.p.stacks.get(t.localUrl);
|
|
406
|
+
if (local) local.drift = 1;
|
|
407
|
+
const none = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
|
|
408
|
+
expect(none.ok).toBe(false);
|
|
409
|
+
if (none.ok) return;
|
|
410
|
+
expect(none.error).not.toContain("adopt");
|
|
411
|
+
t.m.existing = new Error("gcloud: PERMISSION_DENIED");
|
|
412
|
+
const failed = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
|
|
413
|
+
expect(failed.ok).toBe(false);
|
|
414
|
+
if (failed.ok) return;
|
|
415
|
+
expect(failed.error).toContain(
|
|
416
|
+
"(Could not check which planned creates exist in the project already: gcloud: PERMISSION_DENIED. The installer checks again.)",
|
|
417
|
+
);
|
|
418
|
+
expect(failed.error).toContain("Run upgrade first");
|
|
419
|
+
});
|
|
420
|
+
|
|
383
421
|
it("refuses a stack with pending operations", async () => {
|
|
384
422
|
const t = machine();
|
|
385
423
|
const local = t.p.stacks.get(t.localUrl);
|
package/src/state-migrate.ts
CHANGED
|
@@ -117,6 +117,13 @@ export interface MigrateDeps {
|
|
|
117
117
|
* writePlanFile); returns its path.
|
|
118
118
|
*/
|
|
119
119
|
writePlan(label: string, steps: readonly PlannedStep[]): string;
|
|
120
|
+
/**
|
|
121
|
+
* #1079 — the planned creates whose resource exists in the project already
|
|
122
|
+
* (adopt-existing.ts: made by `upgrade` through gcloud), one
|
|
123
|
+
* `<kind> <name>` each. The installer adopts these into the stack before it
|
|
124
|
+
* applies anything. Throws when it can't tell.
|
|
125
|
+
*/
|
|
126
|
+
existingInProject(steps: readonly PlannedStep[]): Promise<readonly string[]>;
|
|
120
127
|
}
|
|
121
128
|
|
|
122
129
|
export interface MigrateOptions {
|
|
@@ -167,6 +174,22 @@ function planReport(deps: MigrateDeps, label: string, steps: readonly PlannedSte
|
|
|
167
174
|
return [...formatPlan(steps).map((l) => ` ${l}`), where].join("\n");
|
|
168
175
|
}
|
|
169
176
|
|
|
177
|
+
/**
|
|
178
|
+
* The refusal's line about planned creates the installer adopts instead
|
|
179
|
+
* (#1079), or "" when there are none. A failed check is said, not fatal: the
|
|
180
|
+
* installer checks again before it applies anything.
|
|
181
|
+
*/
|
|
182
|
+
async function adoptionNote(deps: MigrateDeps, steps: readonly PlannedStep[]): Promise<string> {
|
|
183
|
+
let existing: readonly string[];
|
|
184
|
+
try {
|
|
185
|
+
existing = await deps.existingInProject(steps);
|
|
186
|
+
} catch (e) {
|
|
187
|
+
return `(Could not check which planned creates exist in the project already: ${errorText(e)}. The installer checks again.)\n`;
|
|
188
|
+
}
|
|
189
|
+
if (existing.length === 0) return "";
|
|
190
|
+
return `${existing.length} of the planned creates exist in the project already (${existing.join(", ")}): upgrade made them without Pulumi. The installer adopts them into the stack first instead of creating them again, then shows what is left.\n`;
|
|
191
|
+
}
|
|
192
|
+
|
|
170
193
|
/**
|
|
171
194
|
* Run the migration. Never throws for an expected failure: the result says
|
|
172
195
|
* what failed, and at every failure point the source backend is still the
|
|
@@ -264,7 +287,7 @@ export async function migrateState(
|
|
|
264
287
|
pre.failed(`A preview shows ${sourcePlan.length} change(s)`);
|
|
265
288
|
return {
|
|
266
289
|
ok: false,
|
|
267
|
-
error: `The stack is not converged (a preview shows ${sourcePlan.length} change(s)), so a copy can't be proven identical. Nothing was changed. The planned changes:\n${planReport(deps, "state-migrate", sourcePlan)}\
|
|
290
|
+
error: `The stack is not converged (a preview shows ${sourcePlan.length} change(s)), so a copy can't be proven identical. Nothing was changed. The planned changes:\n${planReport(deps, "state-migrate", sourcePlan)}\n${await adoptionNote(deps, sourcePlan)}Run upgrade first, then re-run the installer (${selfCommand()}, pick this install): it shows this list again and asks before it replaces or deletes anything. Migrate once it reports no changes.`,
|
|
268
291
|
};
|
|
269
292
|
}
|
|
270
293
|
sourceDigest = await pulumi.outputsDigest(from);
|
package/src/state-pulumi.ts
CHANGED
|
@@ -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.
|
package/src/upgrade/converge.ts
CHANGED
|
@@ -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.
|
|
368
|
-
//
|
|
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
|
{
|