@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
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
|
@@ -16,6 +16,7 @@ import { confirm, isCancel, log, note } from "@clack/prompts";
|
|
|
16
16
|
import { bold, red } from "kleur/colors";
|
|
17
17
|
import { adoptionCandidates, existingCandidates } from "./adopt-existing.js";
|
|
18
18
|
import { checkGcloudAuth, gcloud } from "./gcloud.js";
|
|
19
|
+
import { GCP_STACK_ENV } from "./gcp-names.js";
|
|
19
20
|
import {
|
|
20
21
|
adoptStateBackend,
|
|
21
22
|
currentMetadata,
|
|
@@ -33,7 +34,13 @@ import { installIapAllowlist } from "./operator-access.js";
|
|
|
33
34
|
import { ensureOpsBackend } from "./ops-backend.js";
|
|
34
35
|
import { writePlanFile } from "./preview-plan.js";
|
|
35
36
|
import { provisionerServiceAccountEmail } from "./provisioner-identity.js";
|
|
37
|
+
import {
|
|
38
|
+
findRemoteMcpTarget,
|
|
39
|
+
type RemoteMcpTarget,
|
|
40
|
+
resolveStackRemoteMcpIdentity,
|
|
41
|
+
} from "./remote-mcp.js";
|
|
36
42
|
import { selfCommand } from "./self-command.js";
|
|
43
|
+
import type { RemoteMcpIdentity } from "./stack-contract.js";
|
|
37
44
|
import { attachInstall } from "./state-attach.js";
|
|
38
45
|
import {
|
|
39
46
|
gcsBackendRecord,
|
|
@@ -192,6 +199,14 @@ export async function stateCommand(argv: readonly string[]): Promise<void> {
|
|
|
192
199
|
iapAllowlist: installIapAllowlist(meta.ownerEmail),
|
|
193
200
|
imageDigests,
|
|
194
201
|
},
|
|
202
|
+
resolveRemoteMcpIdentity: () =>
|
|
203
|
+
resolveRemoteMcpIdentityForPreview({
|
|
204
|
+
provider,
|
|
205
|
+
projectId,
|
|
206
|
+
region,
|
|
207
|
+
env: GCP_STACK_ENV,
|
|
208
|
+
domain: meta.domain,
|
|
209
|
+
}),
|
|
195
210
|
}),
|
|
196
211
|
report: clackReporter(),
|
|
197
212
|
resolve: (record) =>
|
|
@@ -323,3 +338,20 @@ export function adcNote(run: SyncGcloud): string | null {
|
|
|
323
338
|
if (r.ok) return null;
|
|
324
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";
|
|
325
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
|
+
}
|
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
|
{
|
|
@@ -30,7 +30,12 @@ import {
|
|
|
30
30
|
withoutAmbientGoogleCredentials,
|
|
31
31
|
} from "../provisioner-identity.js";
|
|
32
32
|
import { ensureGeneratedSecrets, stackSecretReplication } from "../runtime-secrets.js";
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
ADMIN_MEMORY_DEFAULT,
|
|
35
|
+
REMOTE_MCP_IDENTITY_CONFIG,
|
|
36
|
+
type RemoteMcpIdentity,
|
|
37
|
+
serializeRemoteMcpIdentity,
|
|
38
|
+
} from "../stack-contract.js";
|
|
34
39
|
import type { ResolvedBackend } from "../state-backend.js";
|
|
35
40
|
|
|
36
41
|
/**
|
|
@@ -96,6 +101,13 @@ export interface PulumiUpInputs {
|
|
|
96
101
|
* Defaults to 'gcp' for backwards compatibility.
|
|
97
102
|
*/
|
|
98
103
|
provider?: "gcp" | "gcp-firebase";
|
|
104
|
+
/**
|
|
105
|
+
* #1082 — the remote-MCP identity the stack declares as env
|
|
106
|
+
* (stack-contract.ts REMOTE_MCP_IDENTITY_CONFIG), resolved from the live
|
|
107
|
+
* install. Null when it could not be resolved (a fresh install before its
|
|
108
|
+
* first `up`): the config keeps what an earlier run wrote.
|
|
109
|
+
*/
|
|
110
|
+
remoteMcpIdentity?: RemoteMcpIdentity | null;
|
|
99
111
|
}
|
|
100
112
|
|
|
101
113
|
export interface PulumiUpResult {
|
|
@@ -456,6 +468,7 @@ export type StackConfigInputs = Pick<
|
|
|
456
468
|
| "wafAdaptiveProtection"
|
|
457
469
|
| "iapAllowlist"
|
|
458
470
|
| "imageDigests"
|
|
471
|
+
| "remoteMcpIdentity"
|
|
459
472
|
> & { provider: "gcp" | "gcp-firebase" };
|
|
460
473
|
|
|
461
474
|
/**
|
|
@@ -494,6 +507,13 @@ export async function applyStackConfig(
|
|
|
494
507
|
// v0.3.2 — anthropicApiKey config dropped. Runtime path is
|
|
495
508
|
// /settings/ai → ai_providers (KEK-encrypted).
|
|
496
509
|
...imageDigestConfig(provider, inputs.imageDigests),
|
|
510
|
+
...(inputs.remoteMcpIdentity
|
|
511
|
+
? {
|
|
512
|
+
[`${ns}:${REMOTE_MCP_IDENTITY_CONFIG}`]: {
|
|
513
|
+
value: serializeRemoteMcpIdentity(inputs.remoteMcpIdentity),
|
|
514
|
+
},
|
|
515
|
+
}
|
|
516
|
+
: {}),
|
|
497
517
|
});
|
|
498
518
|
|
|
499
519
|
// Read back only after the required keys are set: `pulumi config` refuses to
|
package/src/wizards/gcp.ts
CHANGED
|
@@ -105,10 +105,20 @@ import {
|
|
|
105
105
|
} from "../provisioner-identity.js";
|
|
106
106
|
import { revokePublicRoleMemberships } from "../public-role-isolation.js";
|
|
107
107
|
import { decideRegion, detectCliRegion, installableRegions, suggestRegion } from "../regions.js";
|
|
108
|
-
import {
|
|
108
|
+
import {
|
|
109
|
+
checkRemoteMcpIdentityEnv,
|
|
110
|
+
ensureRemoteMcpEdge,
|
|
111
|
+
findRemoteMcpTarget,
|
|
112
|
+
type RemoteMcpTarget,
|
|
113
|
+
resolveStackRemoteMcpIdentity,
|
|
114
|
+
} from "../remote-mcp.js";
|
|
109
115
|
import type { RunMode } from "../run-mode.js";
|
|
110
116
|
import { selfCommand } from "../self-command.js";
|
|
111
|
-
import {
|
|
117
|
+
import {
|
|
118
|
+
ABSENT_DEFAULT_FIREWALL_RULES,
|
|
119
|
+
type RemoteMcpIdentity,
|
|
120
|
+
sameRemoteMcpIdentity,
|
|
121
|
+
} from "../stack-contract.js";
|
|
112
122
|
import {
|
|
113
123
|
chooseInstallBackend,
|
|
114
124
|
describeBackend,
|
|
@@ -120,7 +130,6 @@ import {
|
|
|
120
130
|
resolveBackend,
|
|
121
131
|
} from "../state-backend.js";
|
|
122
132
|
import { acquireStateLock, GcsStateStore } from "../state-store.js";
|
|
123
|
-
import { lookupFailure, resolveGcpResourceName } from "../upgrade/gcp-lookup.js";
|
|
124
133
|
import { createReleaseImageForms, platformDigestsOf } from "../upgrade/image-forms.js";
|
|
125
134
|
import {
|
|
126
135
|
releaseVersionForDigest,
|
|
@@ -379,12 +388,22 @@ export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
|
|
|
379
388
|
// post-cleanup re-runs both pass through cleanly).
|
|
380
389
|
await stepDrainLegacyCaeloAdminUser(projectId, region);
|
|
381
390
|
|
|
391
|
+
// === 9.8. The remote-MCP identity the stack declares (#1082) ===
|
|
392
|
+
// The stack owns the identity env vars (stack-contract.ts
|
|
393
|
+
// RemoteMcpIdentity) and declares them from config the CLI resolves from
|
|
394
|
+
// the live services, so this `up` keeps what they carry. A fresh install
|
|
395
|
+
// has no services yet: the identity is resolved after the first `up` and
|
|
396
|
+
// applied by a second one below.
|
|
397
|
+
const identityBefore = deployed
|
|
398
|
+
? await stepResolveRemoteMcpIdentity(projectId, region, provider, domain, "before")
|
|
399
|
+
: null;
|
|
400
|
+
|
|
382
401
|
// === 10. Pulumi up via Automation SDK ===
|
|
383
402
|
// Pre-built signed images live at the Caelo-team public AR repo
|
|
384
403
|
// (caelo-website/caelo-cms-images by default). Cloud Run reads them
|
|
385
404
|
// directly with no operator-side IAM binding — anonymous public pull.
|
|
386
405
|
phase("Provisioning the infrastructure (pulumi up, 8-15 min)");
|
|
387
|
-
|
|
406
|
+
const upOpts: PulumiUpOpts = {
|
|
388
407
|
projectId,
|
|
389
408
|
domain,
|
|
390
409
|
// #650 — the wizard recorded it (or the install predates the setting).
|
|
@@ -406,9 +425,24 @@ export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
|
|
|
406
425
|
// matching config namespace.
|
|
407
426
|
provider,
|
|
408
427
|
nonInteractive: opts.nonInteractive,
|
|
409
|
-
}
|
|
428
|
+
};
|
|
429
|
+
await stepPulumiUp(installId, { ...upOpts, remoteMcpIdentity: identityBefore?.identity ?? null });
|
|
410
430
|
recordImageDigests(installId, imageDigests);
|
|
411
431
|
|
|
432
|
+
// === 10.05. The identity once the services exist (#1082) ===
|
|
433
|
+
// Read again after every `up`: on a fresh install (or one whose identity
|
|
434
|
+
// could not be read before) this is the first time it can be, and an `up`
|
|
435
|
+
// that replaced a service changed it. A second `up` then applies it
|
|
436
|
+
// through the stack — never through gcloud, which the next `up` would
|
|
437
|
+
// undo.
|
|
438
|
+
const identity = await stepResolveRemoteMcpIdentity(projectId, region, provider, domain, "after");
|
|
439
|
+
if (identity && !sameRemoteMcpIdentity(identity.identity, identityBefore?.identity ?? null)) {
|
|
440
|
+
log.info(
|
|
441
|
+
"Applying the remote-MCP URLs and IAP audience through the stack (second pulumi up)...",
|
|
442
|
+
);
|
|
443
|
+
await stepPulumiUp(installId, { ...upOpts, remoteMcpIdentity: identity.identity });
|
|
444
|
+
}
|
|
445
|
+
|
|
412
446
|
// === 10.1. Remove the default network's pre-populated firewall rules ===
|
|
413
447
|
// GCP creates them when Compute Engine is enabled (step 5). Run after
|
|
414
448
|
// Pulumi rather than right after enabling the API: the default network
|
|
@@ -489,11 +523,9 @@ export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
|
|
|
489
523
|
keepSigner ? [`serviceAccount:${upgraderServiceAccountEmail(projectId, GCP_STACK_ENV)}`] : [],
|
|
490
524
|
);
|
|
491
525
|
|
|
492
|
-
// === 11.6. Remote MCP (#806): LB rules (gcp) + the identity env ===
|
|
493
|
-
//
|
|
494
|
-
|
|
495
|
-
// service's template dropped it (remote-mcp.ts).
|
|
496
|
-
const remoteMcpReady = await stepRemoteMcp(projectId, region, provider, domain);
|
|
526
|
+
// === 11.6. Remote MCP (#806): LB rules (gcp) + a check of the identity env ===
|
|
527
|
+
// The env itself came with `pulumi up` (step 10.05, #1082).
|
|
528
|
+
const remoteMcpReady = await stepRemoteMcp(projectId, provider, domain, identity);
|
|
497
529
|
|
|
498
530
|
// A fresh install ran every converge step above, so it is at this CLI's
|
|
499
531
|
// infrastructure contract — recorded only when the upgrader and the
|
|
@@ -1166,74 +1198,104 @@ async function stepOperatorAccessSync(
|
|
|
1166
1198
|
s.stop(green(`Operator-access sync job ready (${r.done.length} steps)`));
|
|
1167
1199
|
}
|
|
1168
1200
|
|
|
1201
|
+
/** The install's identity for the stack, with the deployed services it was read from. */
|
|
1202
|
+
interface ResolvedRemoteMcpIdentity {
|
|
1203
|
+
readonly target: RemoteMcpTarget;
|
|
1204
|
+
readonly identity: RemoteMcpIdentity;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1169
1207
|
/**
|
|
1170
|
-
* #
|
|
1171
|
-
*
|
|
1172
|
-
*
|
|
1173
|
-
*
|
|
1174
|
-
*
|
|
1175
|
-
* or `upgrade` retries. Idempotent.
|
|
1208
|
+
* #1082 — resolve the remote-MCP identity (remote-mcp.ts
|
|
1209
|
+
* resolveStackRemoteMcpIdentity) for the stack config: the deployed
|
|
1210
|
+
* services' names, then their URLs / the IAP backend and the organisation.
|
|
1211
|
+
* Read-only. Not fatal: remote MCP stays off until the Owner sets it up, so
|
|
1212
|
+
* a failure warns and the `up` keeps the identity the config last recorded.
|
|
1176
1213
|
*
|
|
1177
|
-
* @
|
|
1214
|
+
* @param when `before` the `up` (the services exist), or `after` it
|
|
1215
|
+
* @returns the identity, or null when it could not be read
|
|
1178
1216
|
*/
|
|
1179
|
-
async function
|
|
1217
|
+
async function stepResolveRemoteMcpIdentity(
|
|
1180
1218
|
projectId: string,
|
|
1181
1219
|
region: string,
|
|
1182
1220
|
provider: "gcp" | "gcp-firebase",
|
|
1183
1221
|
domain: string,
|
|
1184
|
-
|
|
1222
|
+
when: "before" | "after",
|
|
1223
|
+
): Promise<ResolvedRemoteMcpIdentity | null> {
|
|
1185
1224
|
const s = spinner();
|
|
1186
1225
|
s.start(
|
|
1187
|
-
"
|
|
1226
|
+
"Reading the remote-MCP URLs and IAP audience (Google sign-in, ChatGPT / claude.ai connectors)...",
|
|
1188
1227
|
);
|
|
1189
|
-
const name = (slug: "admin" | "gateway") =>
|
|
1190
|
-
resolveGcpResourceName(
|
|
1191
|
-
gcloud,
|
|
1192
|
-
"run-service",
|
|
1193
|
-
`caelo-${GCP_STACK_ENV}-${slug}`,
|
|
1194
|
-
projectId,
|
|
1195
|
-
region,
|
|
1196
|
-
);
|
|
1197
|
-
const [adminLookup, gatewayLookup] = [await name("admin"), await name("gateway")];
|
|
1198
1228
|
const failed = (error: string) => {
|
|
1199
|
-
s.stop(yellow(`Remote
|
|
1229
|
+
s.stop(yellow(`Remote-MCP URLs not read: ${error}`));
|
|
1200
1230
|
log.warn(
|
|
1201
|
-
|
|
1231
|
+
when === "before"
|
|
1232
|
+
? "pulumi up keeps the remote-MCP env the stack recorded last; the installer reads it again after the update."
|
|
1233
|
+
: "The install works; Settings → Google sign-in and Settings → MCP report this install as not prepared until a re-run of the installer reads them.",
|
|
1202
1234
|
);
|
|
1203
|
-
return
|
|
1235
|
+
return null;
|
|
1204
1236
|
};
|
|
1205
|
-
|
|
1206
|
-
return failed(
|
|
1207
|
-
lookupFailure(adminLookup, `caelo-${GCP_STACK_ENV}-admin* Cloud Run service`, "operator"),
|
|
1208
|
-
);
|
|
1209
|
-
}
|
|
1210
|
-
if (gatewayLookup.status !== "found") {
|
|
1211
|
-
return failed(
|
|
1212
|
-
lookupFailure(gatewayLookup, `caelo-${GCP_STACK_ENV}-gateway* Cloud Run service`, "operator"),
|
|
1213
|
-
);
|
|
1214
|
-
}
|
|
1215
|
-
const [admin, gateway] = [adminLookup.name, gatewayLookup.name];
|
|
1216
|
-
const edge = await ensureRemoteMcpEdge({ provider, projectId, env: GCP_STACK_ENV, domain });
|
|
1217
|
-
if (!edge.ok) return failed(edge.error);
|
|
1218
|
-
const env = await ensureRemoteMcpIdentityEnv({
|
|
1237
|
+
const found = await findRemoteMcpTarget({
|
|
1219
1238
|
provider,
|
|
1220
1239
|
projectId,
|
|
1221
1240
|
region,
|
|
1222
1241
|
env: GCP_STACK_ENV,
|
|
1223
1242
|
domain,
|
|
1224
|
-
services: { admin, gateway },
|
|
1225
1243
|
});
|
|
1226
|
-
if (!
|
|
1227
|
-
|
|
1228
|
-
|
|
1244
|
+
if (!found.ok) return failed(found.error);
|
|
1245
|
+
const { target } = found;
|
|
1246
|
+
const resolved = await resolveStackRemoteMcpIdentity(target);
|
|
1247
|
+
if (!resolved.ok) return failed(resolved.error);
|
|
1248
|
+
s.stop(green(`Remote-MCP URLs read (${resolved.identity.mcpPublicUrl})`));
|
|
1249
|
+
if (resolved.orgDomainError && when === "after") {
|
|
1229
1250
|
// #914 — without the organisation's domain the admin cannot tell who
|
|
1230
1251
|
// IAP's built-in sign-in admits, so it refuses new users until the
|
|
1231
1252
|
// Owner sets up Google sign-in. Say so now rather than at the first
|
|
1232
1253
|
// invite.
|
|
1233
1254
|
log.warn(
|
|
1234
|
-
`Could not read this project's Google organisation (${
|
|
1255
|
+
`Could not read this project's Google organisation (${resolved.orgDomainError}). Set up Google sign-in under Settings → Google sign-in before adding users, or re-run ${bold(selfCommand("upgrade"))} as an account with the Organization Viewer role.`,
|
|
1235
1256
|
);
|
|
1236
1257
|
}
|
|
1258
|
+
return { target, identity: resolved.identity };
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* #806 — the CLI's part of remote MCP (remote-mcp.ts): on `gcp` the load
|
|
1263
|
+
* balancer's discovery routes and Cloud Armor rule. The identity env vars
|
|
1264
|
+
* (admin URL, the broker's public URL, the IAP audience) come with `pulumi
|
|
1265
|
+
* up` from the stack config (#1082); this only checks that the services
|
|
1266
|
+
* carry them. Remote MCP stays off until the Owner sets it up under
|
|
1267
|
+
* Settings → MCP, so a failure does not stop the install: it warns, and the
|
|
1268
|
+
* next pass or `upgrade` retries. Idempotent.
|
|
1269
|
+
*
|
|
1270
|
+
* @returns whether every part is in place
|
|
1271
|
+
*/
|
|
1272
|
+
async function stepRemoteMcp(
|
|
1273
|
+
projectId: string,
|
|
1274
|
+
provider: "gcp" | "gcp-firebase",
|
|
1275
|
+
domain: string,
|
|
1276
|
+
identity: ResolvedRemoteMcpIdentity | null,
|
|
1277
|
+
): Promise<boolean> {
|
|
1278
|
+
const s = spinner();
|
|
1279
|
+
s.start(
|
|
1280
|
+
"Preparing remote MCP (ChatGPT / claude.ai connectors; off until the Owner enables it)...",
|
|
1281
|
+
);
|
|
1282
|
+
const failed = (error: string) => {
|
|
1283
|
+
s.stop(yellow(`Remote MCP not prepared: ${error}`));
|
|
1284
|
+
log.warn(
|
|
1285
|
+
`The install works; Settings → Google sign-in and Settings → MCP report this install as not prepared until ${bold(selfCommand("upgrade"))} prepares it.`,
|
|
1286
|
+
);
|
|
1287
|
+
return false;
|
|
1288
|
+
};
|
|
1289
|
+
if (!identity) return failed("its URLs and IAP audience could not be read (see above)");
|
|
1290
|
+
const edge = await ensureRemoteMcpEdge({ provider, projectId, env: GCP_STACK_ENV, domain });
|
|
1291
|
+
if (!edge.ok) return failed(edge.error);
|
|
1292
|
+
const check = await checkRemoteMcpIdentityEnv(identity.target, identity.identity);
|
|
1293
|
+
if (!check.ok) return failed(check.error);
|
|
1294
|
+
const missing = [...check.drift.admin, ...check.drift.gateway].map((c) => c.name);
|
|
1295
|
+
if (missing.length > 0) {
|
|
1296
|
+
return failed(`pulumi up left ${[...new Set(missing)].join(", ")} out of the services' env`);
|
|
1297
|
+
}
|
|
1298
|
+
s.stop(green(`Remote MCP and Google sign-in prepared (${identity.identity.mcpPublicUrl})`));
|
|
1237
1299
|
return true;
|
|
1238
1300
|
}
|
|
1239
1301
|
|
|
@@ -1481,6 +1543,8 @@ interface PulumiUpOpts {
|
|
|
1481
1543
|
provider?: "gcp" | "gcp-firebase";
|
|
1482
1544
|
/** No terminal to ask (the install job, `--non-interactive`): see planGate. */
|
|
1483
1545
|
nonInteractive: boolean;
|
|
1546
|
+
/** #1082 — the remote-MCP identity the stack declares (gcp-pulumi.ts PulumiUpInputs). */
|
|
1547
|
+
remoteMcpIdentity?: RemoteMcpIdentity | null;
|
|
1484
1548
|
}
|
|
1485
1549
|
|
|
1486
1550
|
/**
|
package/stacks/gcp/index.ts
CHANGED
|
@@ -37,13 +37,16 @@ import {
|
|
|
37
37
|
adminEnvContract,
|
|
38
38
|
adminMediaVolume,
|
|
39
39
|
adminMediaVolumeTemplate,
|
|
40
|
+
CLOUD_RUN_IGNORE_CHANGES,
|
|
40
41
|
type CloudRunEnvVar,
|
|
41
42
|
type CloudRunSlug,
|
|
42
43
|
databaseUrls,
|
|
43
44
|
defaultStagingHost,
|
|
44
45
|
GATEWAY_TIMEOUT_SECONDS,
|
|
45
46
|
gatewayEnvContract,
|
|
47
|
+
REMOTE_MCP_IDENTITY_CONFIG,
|
|
46
48
|
type RuntimeSecret,
|
|
49
|
+
remoteMcpIdentityStackEnv,
|
|
47
50
|
runtimeSecretBindings,
|
|
48
51
|
STATIC_CDN_POLICY,
|
|
49
52
|
secretFileVolumesTemplate,
|
|
@@ -685,6 +688,12 @@ const envContractInputs = {
|
|
|
685
688
|
},
|
|
686
689
|
} as const;
|
|
687
690
|
|
|
691
|
+
// #1082 — the remote-MCP identity env (stack-contract.ts RemoteMcpIdentity):
|
|
692
|
+
// names generated identities no resource here can take as input, so the CLI
|
|
693
|
+
// resolves them from the live install and writes them as config before `up`.
|
|
694
|
+
// Declared here so the stack is their one owner and `up` keeps them.
|
|
695
|
+
const remoteMcpEnv = remoteMcpIdentityStackEnv(cfg.get(REMOTE_MCP_IDENTITY_CONFIG));
|
|
696
|
+
|
|
688
697
|
function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
|
|
689
698
|
return new gcp.cloudrunv2.Service(
|
|
690
699
|
`${namePrefix}-${args.serviceName}`,
|
|
@@ -740,6 +749,8 @@ function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
|
|
|
740
749
|
},
|
|
741
750
|
{
|
|
742
751
|
...opts,
|
|
752
|
+
// #1082 — which tool deployed last; see CLOUD_RUN_IGNORE_CHANGES.
|
|
753
|
+
ignoreChanges: [...CLOUD_RUN_IGNORE_CHANGES],
|
|
743
754
|
dependsOn: [
|
|
744
755
|
pgAdminUser,
|
|
745
756
|
cmsAdminDb,
|
|
@@ -771,7 +782,7 @@ const adminSvc = cloudRunService({
|
|
|
771
782
|
// in Cloud Run logs. 3600s is the platform max; no cost penalty for
|
|
772
783
|
// unused timeout (Cloud Run bills running time, not allocated time).
|
|
773
784
|
timeout: "3600s",
|
|
774
|
-
contractEnv: adminEnvContract(envContractInputs),
|
|
785
|
+
contractEnv: [...adminEnvContract(envContractInputs), ...remoteMcpEnv.admin],
|
|
775
786
|
mediaVolume: adminMediaVolumeTemplate(adminMediaVolume(project, env)),
|
|
776
787
|
});
|
|
777
788
|
const gatewaySvc = cloudRunService({
|
|
@@ -786,7 +797,7 @@ const gatewaySvc = cloudRunService({
|
|
|
786
797
|
timeout: `${GATEWAY_TIMEOUT_SECONDS}s`,
|
|
787
798
|
// public_role on cms_public only: no cms_admin login of any role
|
|
788
799
|
// (#613, #635, CLAUDE.md §2).
|
|
789
|
-
contractEnv: gatewayEnvContract(envContractInputs),
|
|
800
|
+
contractEnv: [...gatewayEnvContract(envContractInputs), ...remoteMcpEnv.gateway],
|
|
790
801
|
});
|
|
791
802
|
|
|
792
803
|
// =========================================================================
|
|
@@ -42,13 +42,16 @@ import {
|
|
|
42
42
|
adminEnvContract,
|
|
43
43
|
adminMediaVolume,
|
|
44
44
|
adminMediaVolumeTemplate,
|
|
45
|
+
CLOUD_RUN_IGNORE_CHANGES,
|
|
45
46
|
type CloudRunEnvVar,
|
|
46
47
|
type CloudRunSlug,
|
|
47
48
|
databaseUrls,
|
|
48
49
|
defaultStagingHost,
|
|
49
50
|
GATEWAY_TIMEOUT_SECONDS,
|
|
50
51
|
gatewayEnvContract,
|
|
52
|
+
REMOTE_MCP_IDENTITY_CONFIG,
|
|
51
53
|
type RuntimeSecret,
|
|
54
|
+
remoteMcpIdentityStackEnv,
|
|
52
55
|
runtimeSecretBindings,
|
|
53
56
|
secretFileVolumesTemplate,
|
|
54
57
|
} from "../../dist/stack-contract.js";
|
|
@@ -552,6 +555,12 @@ function imageRef(service: string): string {
|
|
|
552
555
|
return digest ? `${base}@${digest}` : `${base}:${cfg.get("image-tag") ?? "latest"}`;
|
|
553
556
|
}
|
|
554
557
|
|
|
558
|
+
// #1082 — the remote-MCP identity env (stack-contract.ts RemoteMcpIdentity):
|
|
559
|
+
// names generated identities no resource here can take as input, so the CLI
|
|
560
|
+
// resolves them from the live install and writes them as config before `up`.
|
|
561
|
+
// Declared here so the stack is their one owner and `up` keeps them.
|
|
562
|
+
const remoteMcpEnv = remoteMcpIdentityStackEnv(cfg.get(REMOTE_MCP_IDENTITY_CONFIG));
|
|
563
|
+
|
|
555
564
|
function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
|
|
556
565
|
return new gcp.cloudrunv2.Service(
|
|
557
566
|
`${namePrefix}-${args.serviceName}`,
|
|
@@ -600,6 +609,8 @@ function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
|
|
|
600
609
|
},
|
|
601
610
|
{
|
|
602
611
|
...opts,
|
|
612
|
+
// #1082 — which tool deployed last; see CLOUD_RUN_IGNORE_CHANGES.
|
|
613
|
+
ignoreChanges: [...CLOUD_RUN_IGNORE_CHANGES],
|
|
603
614
|
dependsOn: [
|
|
604
615
|
pgAdminUser,
|
|
605
616
|
cmsAdminDb,
|
|
@@ -654,7 +665,7 @@ const gatewaySvc = cloudRunService({
|
|
|
654
665
|
// request passes its rate limits, captcha and honeypot; staging requests
|
|
655
666
|
// pass the staging access gate.
|
|
656
667
|
ingress: "INGRESS_TRAFFIC_ALL",
|
|
657
|
-
contractEnv: gatewayEnvContract(envContractInputs),
|
|
668
|
+
contractEnv: [...gatewayEnvContract(envContractInputs), ...remoteMcpEnv.gateway],
|
|
658
669
|
});
|
|
659
670
|
|
|
660
671
|
const adminSvc = cloudRunService({
|
|
@@ -670,11 +681,14 @@ const adminSvc = cloudRunService({
|
|
|
670
681
|
// Use the input string (known at config time) for the site — the
|
|
671
682
|
// resource output is Output<string | undefined>, which Input<string>
|
|
672
683
|
// rejects.
|
|
673
|
-
contractEnv:
|
|
674
|
-
...
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
684
|
+
contractEnv: [
|
|
685
|
+
...adminEnvContract({
|
|
686
|
+
...envContractInputs,
|
|
687
|
+
firebaseSiteId,
|
|
688
|
+
gatewayService: gatewaySvc.name,
|
|
689
|
+
}),
|
|
690
|
+
...remoteMcpEnv.admin,
|
|
691
|
+
],
|
|
678
692
|
mediaVolume: adminMediaVolumeTemplate(adminMediaVolume(project, env)),
|
|
679
693
|
});
|
|
680
694
|
|