@mesh-tech/mesh-cli 0.26.0 → 0.28.0
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/bin/mesh.js +1083 -782
- package/dist/bin/mesh.js.map +3 -3
- package/dist/build-info.json +2 -2
- package/dist/src/commands/app-check.js +2 -1
- package/dist/src/commands/hub/index.d.ts +2 -0
- package/dist/src/commands/hub/index.js +16 -1
- package/dist/src/commands/local/index.d.ts +5 -3
- package/dist/src/commands/local/index.js +61 -23
- package/dist/src/commands/local/mock-idps.d.ts +48 -0
- package/dist/src/commands/local/mock-idps.js +119 -0
- package/dist/src/commands/local/seed-zitadel.d.ts +6 -0
- package/dist/src/commands/local/seed-zitadel.js +76 -0
- package/dist/src/commands/local/stack.d.ts +11 -3
- package/dist/src/commands/local/stack.js +29 -15
- package/dist/src/commands/temporal.js +10 -0
- package/dist/src/commands/temporal.js.map +2 -2
- package/fragments/base/index.ts.hbs +13 -1
- package/package.json +2 -2
- package/skills/core/SKILL.md +1 -1
- package/stack/docker-compose.idps.yml +104 -0
- package/stack/idps/dex.yaml +53 -0
- package/templates/api-auth/index.ts.hbs +5 -1
- package/templates/api-role-gating/index.ts.hbs +5 -1
- package/templates/external-service/index.ts.hbs +5 -1
- package/templates/platform-repo/AGENTS.md.hbs +20 -4
- package/templates/workflow/index.ts.hbs +5 -1
package/dist/build-info.json
CHANGED
|
@@ -23,7 +23,8 @@ export const APP_CHECK_RULES = {
|
|
|
23
23
|
gate: "0.2",
|
|
24
24
|
title: "App registers with the Hub",
|
|
25
25
|
severity: "block",
|
|
26
|
-
remediation: "end index.ts with `export const app = env.register({ description })`"
|
|
26
|
+
remediation: "end index.ts with `export const app = env.register({ displayName, description })` — " +
|
|
27
|
+
"one sentence a non-technical operator can act on; put the caveats in `operatorNote`",
|
|
27
28
|
scope: "app",
|
|
28
29
|
},
|
|
29
30
|
CUSTOM_SESSION_AUTH: {
|
|
@@ -19,7 +19,9 @@ export interface HubDevEnvOptions {
|
|
|
19
19
|
tenants?: string;
|
|
20
20
|
uiPort: number;
|
|
21
21
|
apiPort: number;
|
|
22
|
+
audience?: string;
|
|
22
23
|
}
|
|
24
|
+
export declare function resolveHubAudience(raw: string | undefined): "operator" | "tenant";
|
|
23
25
|
export interface AssembledHubEnv {
|
|
24
26
|
apiEnv: Record<string, string>;
|
|
25
27
|
uiEnv: Record<string, string>;
|
|
@@ -99,6 +99,11 @@ export function forwardedSessionVars(sessionEnv) {
|
|
|
99
99
|
}
|
|
100
100
|
return out;
|
|
101
101
|
}
|
|
102
|
+
export function resolveHubAudience(raw) {
|
|
103
|
+
if (raw === undefined || raw === "")
|
|
104
|
+
return "operator";
|
|
105
|
+
return raw.trim().toLowerCase() === "operator" ? "operator" : "tenant";
|
|
106
|
+
}
|
|
102
107
|
export function assembleHubEnv(session, sessionEnv, opts) {
|
|
103
108
|
const warnings = [];
|
|
104
109
|
const notes = [];
|
|
@@ -141,6 +146,7 @@ export function assembleHubEnv(session, sessionEnv, opts) {
|
|
|
141
146
|
if (!temporalAddress) {
|
|
142
147
|
throw new MeshCliError(`Dev session '${session.name}' exposes no Temporal tunnel — the Hub can't reach the stack's Temporal.`, { remediation: { command: "mesh dev # relaunch; the session records tunnels.temporal in its state" } });
|
|
143
148
|
}
|
|
149
|
+
const audienceEnv = { HUB_AUDIENCE: resolveHubAudience(opts.audience) };
|
|
144
150
|
const apiEnv = {
|
|
145
151
|
...forwarded,
|
|
146
152
|
PORT: String(opts.apiPort),
|
|
@@ -148,6 +154,7 @@ export function assembleHubEnv(session, sessionEnv, opts) {
|
|
|
148
154
|
HUB_TENANT: hubTenant,
|
|
149
155
|
HUB_SCOPE_ENV: scopeEnv,
|
|
150
156
|
HUB_SCOPE_TENANTS: scopeTenants.join(","),
|
|
157
|
+
...audienceEnv,
|
|
151
158
|
...(isLocalPlatform ? localPlatformEnv(sessionEnv) : {}),
|
|
152
159
|
};
|
|
153
160
|
const uiEnv = {
|
|
@@ -157,6 +164,7 @@ export function assembleHubEnv(session, sessionEnv, opts) {
|
|
|
157
164
|
HUB_TENANT: hubTenant,
|
|
158
165
|
DEFAULT_ORG: scopeTenants[0],
|
|
159
166
|
DEFAULT_ENV: scopeEnv,
|
|
167
|
+
...audienceEnv,
|
|
160
168
|
};
|
|
161
169
|
return { apiEnv, uiEnv, scope: { hubTenant, scopeEnv, scopeTenants }, temporalAddress, warnings, notes };
|
|
162
170
|
}
|
|
@@ -282,7 +290,12 @@ async function hubDevAction(opts) {
|
|
|
282
290
|
throw new MeshCliError(`Dev session '${session.name}' has a state file but no live tmux session — its tunnels and token-server are gone.`, { remediation: { command: `mesh dev # from ${session.state.appRoot}` } });
|
|
283
291
|
}
|
|
284
292
|
const sessionEnv = readTmuxSessionEnv(session.name);
|
|
285
|
-
const assembled = assembleHubEnv(session, sessionEnv, {
|
|
293
|
+
const assembled = assembleHubEnv(session, sessionEnv, {
|
|
294
|
+
tenants: opts.tenants,
|
|
295
|
+
uiPort,
|
|
296
|
+
apiPort,
|
|
297
|
+
audience: opts.audience ?? process.env.HUB_AUDIENCE,
|
|
298
|
+
});
|
|
286
299
|
if (opts.printEnv) {
|
|
287
300
|
console.log(chalk.bold("\nhub-api env:"));
|
|
288
301
|
for (const [k, v] of Object.entries(redactEnv(assembled.apiEnv)))
|
|
@@ -308,6 +321,7 @@ async function hubDevAction(opts) {
|
|
|
308
321
|
logInfo(`Dev session : ${session.name} (${session.state.appRoot})`);
|
|
309
322
|
logInfo(`Hub source : ${platformDir}`);
|
|
310
323
|
logInfo(`Scope : HUB_TENANT=${assembled.scope.hubTenant} HUB_SCOPE_ENV=${assembled.scope.scopeEnv} HUB_SCOPE_TENANTS=${assembled.scope.scopeTenants.join(",")}`);
|
|
324
|
+
logInfo(`Audience : ${assembled.apiEnv.HUB_AUDIENCE} (--audience tenant for a client's view)`);
|
|
311
325
|
logInfo(`Temporal : ${assembled.temporalAddress}`);
|
|
312
326
|
for (const note of assembled.notes)
|
|
313
327
|
logInfo(note);
|
|
@@ -372,6 +386,7 @@ export function registerHubCommands(program) {
|
|
|
372
386
|
.description("Launch the current-code Hub (api + ui) against a running `mesh dev` session — env auto-assembled, zero hand-set vars")
|
|
373
387
|
.option("--session <name>", "dev session to observe (default: auto-detect from cwd)")
|
|
374
388
|
.option("--tenants <list>", "comma-separated HUB_SCOPE_TENANTS override (default: the session's tenant)")
|
|
389
|
+
.option("--audience <who>", "who the Hub renders for: `operator` (platform staff — the default here) or `tenant` (a client's operators). Anything else reads as `tenant`")
|
|
375
390
|
.option("--port <port>", `Hub UI host port (default: $MESH_HUB_DEV_PORT or ${DEFAULT_HUB_UI_PORT})`)
|
|
376
391
|
.option("--api-port <port>", `Hub API port (default: ${DEFAULT_HUB_API_PORT})`)
|
|
377
392
|
.option("--platform-dir <dir>", "mesh-platform checkout to run the Hub from (default: $MESH_PLATFORM_DIR or walk up from cwd)")
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
import { type ServiceStatus, type StackEndpoint } from "./stack.js";
|
|
2
|
+
import { type OverlayState, type ServiceStatus, type StackEndpoint } from "./stack.js";
|
|
3
3
|
export interface WaitForStackIo {
|
|
4
4
|
probe: (endpoint: StackEndpoint) => Promise<boolean>;
|
|
5
5
|
services: () => ServiceStatus[];
|
|
6
|
-
|
|
6
|
+
overlays: () => readonly OverlayState[];
|
|
7
7
|
}
|
|
8
|
-
export declare function waitForStack(
|
|
8
|
+
export declare function waitForStack(opts: {
|
|
9
|
+
idpsStarted: boolean;
|
|
10
|
+
}, io?: WaitForStackIo): Promise<void>;
|
|
9
11
|
export declare function startHereLines(hubRunning: boolean, up?: ReadonlyMap<string, boolean>): string[];
|
|
10
12
|
export declare function registerLocalCommands(program: Command): void;
|
|
@@ -3,12 +3,13 @@ import * as path from "path";
|
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import { logError, logInfo, logSuccess, logWarn } from "../../utils/log.js";
|
|
5
5
|
import { MeshCliError } from "../../utils/errors.js";
|
|
6
|
-
import { compose, COMPOSE_PROJECT, composeStreamed, crashedServices, DEFAULT_HUB_PORT, ensureDockerAvailable, findPackageRoot, hubApiRunning, hubEndpoints,
|
|
6
|
+
import { compose, COMPOSE_PROJECT, composeStreamed, crashedServices, DEFAULT_HUB_PORT, ensureDockerAvailable, findPackageRoot, hubApiRunning, hubEndpoints, hubOverlayServices, hubPort, idpsOverlayServices, ONE_SHOT_SERVICES, overlayRunning, probeEndpoint, STACK_ENDPOINTS, stackOwnedElsewhere, stackServices, } from "./stack.js";
|
|
7
7
|
import { probeRegistryToken, REGISTRY_LOGIN_FIX } from "../../utils/auth-preflight.js";
|
|
8
8
|
import { ensureHubAuthImage, ensureHubImages, buildHubImagesFromSource, readHubCompiledAuthz, hasRegistryAuth, localHubVersion, planWithHubRefresh, } from "./hub-local.js";
|
|
9
9
|
import { reconcileRegistryFromZitadel } from "./auth-provision.js";
|
|
10
10
|
import { LOCAL_AWS_CONFIG, localAwsEnv, seedLocalPlatform, verifyFabric } from "./seed.js";
|
|
11
|
-
import { LOGIN_CONTEXT, readHubAuth, seedZitadel, TEST_USERS } from "./seed-zitadel.js";
|
|
11
|
+
import { LOGIN_CONTEXT, readHubAuth, seedMockIdps, seedZitadel, TEST_USERS } from "./seed-zitadel.js";
|
|
12
|
+
import { idpEndpoints, mockIdpComposeEnv, MOCK_OIDC_PORT, MOCK_SAML_PORT } from "./mock-idps.js";
|
|
12
13
|
import { publishHubAuthzCatalog } from "./seed-hub-catalog.js";
|
|
13
14
|
import { describePortConflicts, findPortConflicts, hostPortsOf } from "./helpers.js";
|
|
14
15
|
const WAIT_TIMEOUT_MS = 180_000;
|
|
@@ -20,10 +21,13 @@ function crashError(crashed) {
|
|
|
20
21
|
},
|
|
21
22
|
});
|
|
22
23
|
}
|
|
23
|
-
export async function waitForStack(io = {
|
|
24
|
+
export async function waitForStack(opts, io = {
|
|
24
25
|
probe: probeEndpoint,
|
|
25
26
|
services: stackServices,
|
|
26
|
-
|
|
27
|
+
overlays: () => [
|
|
28
|
+
{ services: hubOverlayServices(), started: false },
|
|
29
|
+
{ services: idpsOverlayServices(), started: opts.idpsStarted },
|
|
30
|
+
],
|
|
27
31
|
}) {
|
|
28
32
|
const deadline = Date.now() + WAIT_TIMEOUT_MS;
|
|
29
33
|
let pending = STACK_ENDPOINTS.filter((e) => e.probe.kind !== "none");
|
|
@@ -39,10 +43,7 @@ export async function waitForStack(io = {
|
|
|
39
43
|
}
|
|
40
44
|
}
|
|
41
45
|
pending = stillDown;
|
|
42
|
-
const crashed = crashedServices(io.services(), {
|
|
43
|
-
overlayServices: io.overlayServices(),
|
|
44
|
-
overlayStarted: false,
|
|
45
|
-
});
|
|
46
|
+
const crashed = crashedServices(io.services(), { overlays: io.overlays() });
|
|
46
47
|
if (crashed.length > 0)
|
|
47
48
|
throw crashError(crashed);
|
|
48
49
|
if (pending.length === 0)
|
|
@@ -112,6 +113,11 @@ function portMoveHints() {
|
|
|
112
113
|
Number(hubPort()),
|
|
113
114
|
"MESH_HUB_PORT=<free port> mesh start (or mesh start --no-hub, which starts everything else)",
|
|
114
115
|
],
|
|
116
|
+
[MOCK_SAML_PORT, "mesh start (without --with-idps — the mock identity providers are opt-in)"],
|
|
117
|
+
[
|
|
118
|
+
MOCK_OIDC_PORT,
|
|
119
|
+
"mesh start (without --with-idps; 5556 is Dex's own default port, so a Dex of your own is the usual holder)",
|
|
120
|
+
],
|
|
115
121
|
]);
|
|
116
122
|
}
|
|
117
123
|
function repoRootForHubSource() {
|
|
@@ -133,6 +139,7 @@ export function registerLocalCommands(program) {
|
|
|
133
139
|
.option("--no-hub", "start without the Hub (API + UI)")
|
|
134
140
|
.option("--with-hub", "force-refresh the Hub to the latest published @mesh-tech/hub (the Hub is included by default when its images exist or registry auth is available)")
|
|
135
141
|
.option("--hub-from-source", "build the Hub images from THIS checkout's apps/hub instead of the published tarball (the only way to exercise a Hub change locally)")
|
|
142
|
+
.option("--with-idps", "also run mock SAML and OIDC identity providers, and federate the local Zitadel with both (for testing enterprise SSO without a cloud tenant or an IdP admin)", false)
|
|
136
143
|
.option("--takeover", "re-own a stack that another checkout started (may recreate shared containers with THIS checkout's config)", false)
|
|
137
144
|
.option("--skip-port-check", "start even though a host port the stack wants is already held (the service that loses the bind will be broken — use when the holder is something you cannot stop)", false)
|
|
138
145
|
.action(async (opts) => {
|
|
@@ -151,7 +158,11 @@ export function registerLocalCommands(program) {
|
|
|
151
158
|
logWarn(`Taking over the running stack (previously driven from ${foreign}).`);
|
|
152
159
|
}
|
|
153
160
|
if (!opts.skipPortCheck && !stackServices().some((s) => s.state === "running")) {
|
|
154
|
-
const ports = hostPortsOf([
|
|
161
|
+
const ports = hostPortsOf([
|
|
162
|
+
...STACK_ENDPOINTS,
|
|
163
|
+
...(opts.hub ? hubEndpoints() : []),
|
|
164
|
+
...(opts.withIdps ? idpEndpoints() : []),
|
|
165
|
+
]);
|
|
155
166
|
const conflicts = await findPortConflicts(ports, `${COMPOSE_PROJECT}-`);
|
|
156
167
|
if (conflicts.length > 0) {
|
|
157
168
|
throw new MeshCliError(describePortConflicts(conflicts, portMoveHints()), {
|
|
@@ -197,9 +208,10 @@ export function registerLocalCommands(program) {
|
|
|
197
208
|
}
|
|
198
209
|
}
|
|
199
210
|
logInfo("Starting the local Mesh platform (docker compose project: mesh-local)…");
|
|
200
|
-
|
|
211
|
+
const idpEnv = opts.withIdps ? mockIdpComposeEnv() : undefined;
|
|
212
|
+
await composeStreamed(["up", "-d"], { idps: opts.withIdps, env: idpEnv });
|
|
201
213
|
compose(["restart", "prometheus", "blackbox-exporter"], { inherit: true });
|
|
202
|
-
await waitForStack();
|
|
214
|
+
await waitForStack({ idpsStarted: opts.withIdps });
|
|
203
215
|
let hubAuth = readHubAuth();
|
|
204
216
|
if (opts.seed) {
|
|
205
217
|
logInfo("Seeding local platform (tenant registry, artifacts bucket)…");
|
|
@@ -207,6 +219,15 @@ export function registerLocalCommands(program) {
|
|
|
207
219
|
logInfo("Seeding Zitadel (Platform project, Mesh CLI app, Hub auth, test users)…");
|
|
208
220
|
const zitadel = await seedZitadel(LOCAL_AWS_CONFIG);
|
|
209
221
|
hubAuth = zitadel.hubAuth;
|
|
222
|
+
if (opts.withIdps) {
|
|
223
|
+
logInfo("Federating local Zitadel with the mock SAML and OIDC identity providers…");
|
|
224
|
+
await seedMockIdps();
|
|
225
|
+
logInfo(`Mock SAML IdP: http://localhost:${MOCK_SAML_PORT} · ` +
|
|
226
|
+
`mock OIDC IdP: http://localhost:${MOCK_OIDC_PORT}/dex · ` +
|
|
227
|
+
"sign in at http://localhost:8080 and both federation buttons are offered");
|
|
228
|
+
logInfo("Both are listed in the Zitadel console under Settings → Identity Providers (owner: Instance): " +
|
|
229
|
+
"http://localhost:8080/ui/console/org-settings?id=idp");
|
|
230
|
+
}
|
|
210
231
|
if (hubVersion) {
|
|
211
232
|
const compiled = readHubCompiledAuthz(hubVersion);
|
|
212
233
|
if (compiled) {
|
|
@@ -246,19 +267,23 @@ export function registerLocalCommands(program) {
|
|
|
246
267
|
hubEnv.MESH_HUB_OAUTH2_CLIENT_SECRET = hubAuth.clientSecret;
|
|
247
268
|
hubEnv.MESH_HUB_OAUTH2_COOKIE_SECRET = hubAuth.cookieSecret;
|
|
248
269
|
}
|
|
249
|
-
await composeStreamed(["up", "-d"], {
|
|
270
|
+
await composeStreamed(["up", "-d"], {
|
|
271
|
+
hub: true,
|
|
272
|
+
idps: opts.withIdps,
|
|
273
|
+
env: { ...idpEnv, ...hubEnv },
|
|
274
|
+
});
|
|
250
275
|
if (opts.seed) {
|
|
251
276
|
compose(["restart", "hub-api"], { hub: true, env: hubEnv });
|
|
252
277
|
}
|
|
253
|
-
const
|
|
278
|
+
const overlays = [
|
|
279
|
+
{ services: hubOverlayServices(), started: true },
|
|
280
|
+
{ services: idpsOverlayServices(), started: opts.withIdps },
|
|
281
|
+
];
|
|
254
282
|
for (const endpoint of hubEndpoints()) {
|
|
255
283
|
logInfo(`Waiting for ${endpoint.label}…`);
|
|
256
284
|
let up = await probeEndpoint(endpoint);
|
|
257
285
|
for (let i = 0; i < 60 && !up; i++) {
|
|
258
|
-
const crashed = crashedServices(stackServices(), {
|
|
259
|
-
overlayServices,
|
|
260
|
-
overlayStarted: true,
|
|
261
|
-
});
|
|
286
|
+
const crashed = crashedServices(stackServices(), { overlays });
|
|
262
287
|
if (crashed.length > 0)
|
|
263
288
|
throw crashError(crashed);
|
|
264
289
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
@@ -310,11 +335,20 @@ export function registerLocalCommands(program) {
|
|
|
310
335
|
remediation: { command: "mesh start" },
|
|
311
336
|
});
|
|
312
337
|
}
|
|
313
|
-
const
|
|
314
|
-
const
|
|
315
|
-
const
|
|
338
|
+
const hubOverlay = hubOverlayServices();
|
|
339
|
+
const idpsOverlay = idpsOverlayServices();
|
|
340
|
+
const idpsStarted = overlayRunning(services, idpsOverlay);
|
|
341
|
+
const overlays = [
|
|
342
|
+
{ services: hubOverlay, started: overlayRunning(services, hubOverlay) },
|
|
343
|
+
{ services: idpsOverlay, started: idpsStarted },
|
|
344
|
+
];
|
|
345
|
+
const notStarted = (s) => s.state === "exited" && overlays.some((o) => !o.started && o.services.has(s.name));
|
|
316
346
|
const hasHub = hubApiRunning(services);
|
|
317
|
-
const endpoints =
|
|
347
|
+
const endpoints = [
|
|
348
|
+
...STACK_ENDPOINTS,
|
|
349
|
+
...(hasHub ? hubEndpoints() : []),
|
|
350
|
+
...(idpsStarted ? idpEndpoints() : []),
|
|
351
|
+
];
|
|
318
352
|
const probes = await Promise.all(endpoints.map(async (endpoint) => ({
|
|
319
353
|
service: endpoint.service,
|
|
320
354
|
label: endpoint.label,
|
|
@@ -337,6 +371,7 @@ export function registerLocalCommands(program) {
|
|
|
337
371
|
services,
|
|
338
372
|
endpoints: probes,
|
|
339
373
|
fabric,
|
|
374
|
+
mockIdps: idpsStarted,
|
|
340
375
|
awsEnv: localAwsEnv(),
|
|
341
376
|
}));
|
|
342
377
|
if (fabric && !fabric.ok)
|
|
@@ -352,7 +387,7 @@ export function registerLocalCommands(program) {
|
|
|
352
387
|
const idle = notStarted(service);
|
|
353
388
|
const icon = ok || oneShotDone ? chalk.green("●") : idle ? chalk.dim("○") : chalk.red("●");
|
|
354
389
|
const health = service.health ? ` (${service.health})` : "";
|
|
355
|
-
const note = idle ? chalk.dim(" (
|
|
390
|
+
const note = idle ? chalk.dim(" (not started this run)") : "";
|
|
356
391
|
console.log(` ${icon} ${service.name.padEnd(22)} ${service.state}${health}${note}`);
|
|
357
392
|
}
|
|
358
393
|
console.log("");
|
|
@@ -366,8 +401,11 @@ export function registerLocalCommands(program) {
|
|
|
366
401
|
const detail = fabric.ok ? "8 KB Advanced-tier round-trip OK" : fabric.error;
|
|
367
402
|
console.log(` ${icon} ${"AWS fabric check".padEnd(34)} ${chalk.dim(detail ?? "")}`);
|
|
368
403
|
}
|
|
404
|
+
if (!idpsStarted) {
|
|
405
|
+
console.log(` ${chalk.dim("○")} ${"Mock IdPs (SAML + OIDC)".padEnd(34)} ${chalk.dim("not started — mesh start --with-idps")}`);
|
|
406
|
+
}
|
|
369
407
|
const down = probes.filter((p) => !p.up);
|
|
370
|
-
const crashed = crashedServices(services, {
|
|
408
|
+
const crashed = crashedServices(services, { overlays });
|
|
371
409
|
if (fabric && !fabric.ok)
|
|
372
410
|
process.exitCode = 1;
|
|
373
411
|
if (down.length > 0 || crashed.length > 0) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { StackEndpoint } from "./stack.js";
|
|
2
|
+
export declare const MOCK_SAML_PORT = 4569;
|
|
3
|
+
export declare const MOCK_OIDC_PORT = 5556;
|
|
4
|
+
export declare const idpEndpoints: () => StackEndpoint[];
|
|
5
|
+
export declare const MOCK_SAML_IDP_NAME = "Mock SAML (local)";
|
|
6
|
+
export declare const MOCK_OIDC_IDP_NAME = "Mock OIDC (local)";
|
|
7
|
+
export declare const mockSamlMetadataUrl: () => string;
|
|
8
|
+
export declare const mockOidcEndpoints: () => {
|
|
9
|
+
authorization: string;
|
|
10
|
+
token: string;
|
|
11
|
+
user: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function ensureMockSamlSigningKey(): {
|
|
14
|
+
publicKeyB64: string;
|
|
15
|
+
privateKeyB64: string;
|
|
16
|
+
};
|
|
17
|
+
export declare const mockIdpComposeEnv: () => Record<string, string>;
|
|
18
|
+
export declare const buildMockSamlIdpBody: (metadataXml: string) => {
|
|
19
|
+
name: string;
|
|
20
|
+
metadataXml: string;
|
|
21
|
+
binding: string;
|
|
22
|
+
withSignedRequest: boolean;
|
|
23
|
+
providerOptions: {
|
|
24
|
+
isLinkingAllowed: boolean;
|
|
25
|
+
isCreationAllowed: boolean;
|
|
26
|
+
isAutoCreation: boolean;
|
|
27
|
+
isAutoUpdate: boolean;
|
|
28
|
+
autoLinking: string;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
export declare const buildMockOauthIdpBody: () => {
|
|
32
|
+
name: string;
|
|
33
|
+
clientId: string;
|
|
34
|
+
clientSecret: string;
|
|
35
|
+
authorizationEndpoint: string;
|
|
36
|
+
tokenEndpoint: string;
|
|
37
|
+
userEndpoint: string;
|
|
38
|
+
scopes: string[];
|
|
39
|
+
idAttribute: string;
|
|
40
|
+
providerOptions: {
|
|
41
|
+
isLinkingAllowed: boolean;
|
|
42
|
+
isCreationAllowed: boolean;
|
|
43
|
+
isAutoCreation: boolean;
|
|
44
|
+
isAutoUpdate: boolean;
|
|
45
|
+
autoLinking: string;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
export declare const missingLoginPolicyIdps: (attached: readonly string[], wanted: readonly string[]) => string[];
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { execFileSync } from "child_process";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { meshCacheDir } from "../../utils/cache-home.js";
|
|
5
|
+
import { MeshCliError } from "../../utils/errors.js";
|
|
6
|
+
export const MOCK_SAML_PORT = 4569;
|
|
7
|
+
export const MOCK_OIDC_PORT = 5556;
|
|
8
|
+
const MOCK_OIDC_SERVICE = "mock-oidc";
|
|
9
|
+
export const idpEndpoints = () => [
|
|
10
|
+
{
|
|
11
|
+
service: "mock-saml",
|
|
12
|
+
label: "Mock SAML IdP",
|
|
13
|
+
url: `http://localhost:${MOCK_SAML_PORT}`,
|
|
14
|
+
probe: { kind: "http", url: mockSamlMetadataUrl() },
|
|
15
|
+
hint: "sign in as any email you type; federation metadata at /api/saml/metadata",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
service: MOCK_OIDC_SERVICE,
|
|
19
|
+
label: "Mock OIDC IdP",
|
|
20
|
+
url: `http://localhost:${MOCK_OIDC_PORT}/dex`,
|
|
21
|
+
probe: {
|
|
22
|
+
kind: "http",
|
|
23
|
+
url: `http://localhost:${MOCK_OIDC_PORT}/dex/.well-known/openid-configuration`,
|
|
24
|
+
},
|
|
25
|
+
hint: "dev@local.mesh or ops@local.mesh / password",
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
export const MOCK_SAML_IDP_NAME = "Mock SAML (local)";
|
|
29
|
+
export const MOCK_OIDC_IDP_NAME = "Mock OIDC (local)";
|
|
30
|
+
const MOCK_OIDC_CLIENT_ID = "mesh-local";
|
|
31
|
+
const MOCK_OIDC_CLIENT_SECRET = "mesh-local-secret";
|
|
32
|
+
export const mockSamlMetadataUrl = () => `http://localhost:${MOCK_SAML_PORT}/api/saml/metadata`;
|
|
33
|
+
export const mockOidcEndpoints = () => ({
|
|
34
|
+
authorization: `http://localhost:${MOCK_OIDC_PORT}/dex/auth`,
|
|
35
|
+
token: `http://${MOCK_OIDC_SERVICE}:${MOCK_OIDC_PORT}/dex/token`,
|
|
36
|
+
user: `http://${MOCK_OIDC_SERVICE}:${MOCK_OIDC_PORT}/dex/userinfo`,
|
|
37
|
+
});
|
|
38
|
+
const keyDir = () => path.join(meshCacheDir("mesh-local"), "mock-idp");
|
|
39
|
+
export function ensureMockSamlSigningKey() {
|
|
40
|
+
const dir = keyDir();
|
|
41
|
+
const certPath = path.join(dir, "cert.pem");
|
|
42
|
+
const keyPath = path.join(dir, "key.pem");
|
|
43
|
+
if (!fs.existsSync(certPath) || !fs.existsSync(keyPath)) {
|
|
44
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
45
|
+
try {
|
|
46
|
+
execFileSync("openssl", [
|
|
47
|
+
"req",
|
|
48
|
+
"-x509",
|
|
49
|
+
"-newkey",
|
|
50
|
+
"rsa:2048",
|
|
51
|
+
"-keyout",
|
|
52
|
+
keyPath,
|
|
53
|
+
"-out",
|
|
54
|
+
certPath,
|
|
55
|
+
"-days",
|
|
56
|
+
"3650",
|
|
57
|
+
"-nodes",
|
|
58
|
+
"-subj",
|
|
59
|
+
"/CN=mesh-local-mock-saml",
|
|
60
|
+
], { stdio: "ignore" });
|
|
61
|
+
}
|
|
62
|
+
catch (cause) {
|
|
63
|
+
throw new MeshCliError("Could not generate the mock SAML signing key.", {
|
|
64
|
+
remediation: {
|
|
65
|
+
command: "openssl version # openssl must be on PATH for --with-idps",
|
|
66
|
+
},
|
|
67
|
+
cause,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
publicKeyB64: fs.readFileSync(certPath).toString("base64"),
|
|
73
|
+
privateKeyB64: fs.readFileSync(keyPath).toString("base64"),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export const mockIdpComposeEnv = () => {
|
|
77
|
+
const { publicKeyB64, privateKeyB64 } = ensureMockSamlSigningKey();
|
|
78
|
+
return {
|
|
79
|
+
MESH_MOCK_SAML_PUBLIC_KEY: publicKeyB64,
|
|
80
|
+
MESH_MOCK_SAML_PRIVATE_KEY: privateKeyB64,
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
export const buildMockSamlIdpBody = (metadataXml) => ({
|
|
84
|
+
name: MOCK_SAML_IDP_NAME,
|
|
85
|
+
metadataXml: Buffer.from(metadataXml, "utf-8").toString("base64"),
|
|
86
|
+
binding: "SAML_BINDING_POST",
|
|
87
|
+
withSignedRequest: true,
|
|
88
|
+
providerOptions: {
|
|
89
|
+
isLinkingAllowed: true,
|
|
90
|
+
isCreationAllowed: true,
|
|
91
|
+
isAutoCreation: true,
|
|
92
|
+
isAutoUpdate: true,
|
|
93
|
+
autoLinking: "AUTO_LINKING_OPTION_EMAIL",
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
export const buildMockOauthIdpBody = () => {
|
|
97
|
+
const endpoints = mockOidcEndpoints();
|
|
98
|
+
return {
|
|
99
|
+
name: MOCK_OIDC_IDP_NAME,
|
|
100
|
+
clientId: MOCK_OIDC_CLIENT_ID,
|
|
101
|
+
clientSecret: MOCK_OIDC_CLIENT_SECRET,
|
|
102
|
+
authorizationEndpoint: endpoints.authorization,
|
|
103
|
+
tokenEndpoint: endpoints.token,
|
|
104
|
+
userEndpoint: endpoints.user,
|
|
105
|
+
scopes: ["openid", "profile", "email"],
|
|
106
|
+
idAttribute: "sub",
|
|
107
|
+
providerOptions: {
|
|
108
|
+
isLinkingAllowed: true,
|
|
109
|
+
isCreationAllowed: true,
|
|
110
|
+
isAutoCreation: true,
|
|
111
|
+
isAutoUpdate: true,
|
|
112
|
+
autoLinking: "AUTO_LINKING_OPTION_EMAIL",
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
export const missingLoginPolicyIdps = (attached, wanted) => {
|
|
117
|
+
const have = new Set(attached);
|
|
118
|
+
return [...new Set(wanted)].filter((id) => !have.has(id));
|
|
119
|
+
};
|
|
@@ -73,4 +73,10 @@ export interface ZitadelSeedResult {
|
|
|
73
73
|
hubAuth: HubAuthConfig;
|
|
74
74
|
}
|
|
75
75
|
export declare function seedZitadel(awsConfig: object): Promise<ZitadelSeedResult>;
|
|
76
|
+
export declare const IDP_SEARCH_PATH = "/admin/v1/idps/templates/_search";
|
|
77
|
+
export declare const pickIdpIdByName: (searchResult: any, name: string) => string | undefined;
|
|
78
|
+
export declare function seedMockIdps(): Promise<{
|
|
79
|
+
samlIdpId: string;
|
|
80
|
+
oidcIdpId: string;
|
|
81
|
+
}>;
|
|
76
82
|
export {};
|
|
@@ -8,6 +8,7 @@ import { writeContextConfig } from "../login.js";
|
|
|
8
8
|
import { compose, hubPort, DEFAULT_HUB_PORT } from "./stack.js";
|
|
9
9
|
import { LOCAL_TENANT, LOCAL_ENV } from "./seed.js";
|
|
10
10
|
import { meshCacheDir } from "../../utils/cache-home.js";
|
|
11
|
+
import { buildMockOauthIdpBody, buildMockSamlIdpBody, missingLoginPolicyIdps, MOCK_OIDC_IDP_NAME, MOCK_SAML_IDP_NAME, mockSamlMetadataUrl, } from "./mock-idps.js";
|
|
11
12
|
export const ZITADEL_ISSUER = "http://localhost:8080";
|
|
12
13
|
export const LOGIN_CONTEXT = "local";
|
|
13
14
|
export const PLATFORM_ORG = "mesh";
|
|
@@ -441,3 +442,78 @@ export async function seedZitadel(awsConfig) {
|
|
|
441
442
|
logSuccess(`Login context '${LOGIN_CONTEXT}' configured → try: mesh login ${LOGIN_CONTEXT}`);
|
|
442
443
|
return { projectId, cliClientId, hubAuth };
|
|
443
444
|
}
|
|
445
|
+
async function fetchMockSamlMetadata() {
|
|
446
|
+
const url = mockSamlMetadataUrl();
|
|
447
|
+
let lastError = "no attempt made";
|
|
448
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
449
|
+
try {
|
|
450
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(5_000) });
|
|
451
|
+
const body = await res.text();
|
|
452
|
+
if (res.ok && body.includes("EntityDescriptor")) {
|
|
453
|
+
if (/<ds:X509Certificate\s*\/>|<ds:X509Certificate>\s*<\/ds:X509Certificate>/.test(body)) {
|
|
454
|
+
throw new MeshCliError("The mock SAML IdP served metadata with an empty signing certificate.", {
|
|
455
|
+
remediation: {
|
|
456
|
+
command: "rm -rf ~/.cache/mesh/mesh-local/mock-idp && mesh stop && mesh start --with-idps",
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
return body;
|
|
461
|
+
}
|
|
462
|
+
lastError = `HTTP ${res.status}`;
|
|
463
|
+
}
|
|
464
|
+
catch (cause) {
|
|
465
|
+
if (cause instanceof MeshCliError)
|
|
466
|
+
throw cause;
|
|
467
|
+
lastError = String(cause);
|
|
468
|
+
}
|
|
469
|
+
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
|
470
|
+
}
|
|
471
|
+
throw new MeshCliError(`Could not read mock SAML metadata from ${url} (${lastError}).`, {
|
|
472
|
+
remediation: { command: "mesh status # is mock-saml running?" },
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
async function attachedLoginPolicyIdps(pat) {
|
|
476
|
+
const res = await api(pat, "POST", "/admin/v1/policies/login/idps/_search", {});
|
|
477
|
+
return (res?.result ?? []).map((idp) => String(idp.idpId ?? idp.id)).filter(Boolean);
|
|
478
|
+
}
|
|
479
|
+
export const IDP_SEARCH_PATH = "/admin/v1/idps/templates/_search";
|
|
480
|
+
export const pickIdpIdByName = (searchResult, name) => {
|
|
481
|
+
const hit = (searchResult?.result ?? []).find((idp) => idp?.name === name);
|
|
482
|
+
return hit?.id ? String(hit.id) : undefined;
|
|
483
|
+
};
|
|
484
|
+
async function findIdpByName(pat, name) {
|
|
485
|
+
return pickIdpIdByName(await api(pat, "POST", IDP_SEARCH_PATH, {}), name);
|
|
486
|
+
}
|
|
487
|
+
export async function seedMockIdps() {
|
|
488
|
+
const pat = readSeederPat();
|
|
489
|
+
const metadataXml = await fetchMockSamlMetadata();
|
|
490
|
+
let samlIdpId = await findIdpByName(pat, MOCK_SAML_IDP_NAME);
|
|
491
|
+
if (!samlIdpId) {
|
|
492
|
+
const created = await api(pat, "POST", "/admin/v1/idps/saml", buildMockSamlIdpBody(metadataXml));
|
|
493
|
+
samlIdpId = String(created.id);
|
|
494
|
+
}
|
|
495
|
+
logSuccess(`Mock SAML IdP ready (${MOCK_SAML_IDP_NAME}, id ${samlIdpId})`);
|
|
496
|
+
let oidcIdpId = await findIdpByName(pat, MOCK_OIDC_IDP_NAME);
|
|
497
|
+
if (!oidcIdpId) {
|
|
498
|
+
const created = await api(pat, "POST", "/admin/v1/idps/oauth", buildMockOauthIdpBody());
|
|
499
|
+
oidcIdpId = String(created.id);
|
|
500
|
+
}
|
|
501
|
+
logSuccess(`Mock OIDC IdP ready (${MOCK_OIDC_IDP_NAME}, id ${oidcIdpId})`);
|
|
502
|
+
const policy = await api(pat, "GET", "/admin/v1/policies/login");
|
|
503
|
+
if (policy?.policy?.allowExternalIdp !== true) {
|
|
504
|
+
await api(pat, "PUT", "/admin/v1/policies/login", {
|
|
505
|
+
...policy.policy,
|
|
506
|
+
allowExternalIdp: true,
|
|
507
|
+
});
|
|
508
|
+
logInfo("Instance login policy: allowExternalIdp turned on (federation buttons were hidden)");
|
|
509
|
+
}
|
|
510
|
+
const attached = await attachedLoginPolicyIdps(pat);
|
|
511
|
+
const toAttach = missingLoginPolicyIdps(attached, [samlIdpId, oidcIdpId]);
|
|
512
|
+
for (const idpId of toAttach) {
|
|
513
|
+
await api(pat, "POST", "/admin/v1/policies/login/idps", { idpId, ownerType: "IDP_OWNER_TYPE_SYSTEM" });
|
|
514
|
+
}
|
|
515
|
+
const finalIdps = await attachedLoginPolicyIdps(pat);
|
|
516
|
+
logSuccess(`Login policy carries ${finalIdps.length} federation(s) — both mocks attached, ` +
|
|
517
|
+
"which is the state a login-policy change has to preserve");
|
|
518
|
+
return { samlIdpId, oidcIdpId };
|
|
519
|
+
}
|
|
@@ -29,20 +29,25 @@ export declare function hubPort(): string;
|
|
|
29
29
|
export declare function hubEndpoints(): StackEndpoint[];
|
|
30
30
|
export declare function findPackageRoot(startDir?: string): string;
|
|
31
31
|
export declare function stackDir(): string;
|
|
32
|
+
export declare const BASE_COMPOSE_FILE = "docker-compose.yml";
|
|
32
33
|
export declare const HUB_OVERLAY_FILE = "docker-compose.hub.yml";
|
|
34
|
+
export declare const IDPS_OVERLAY_FILE = "docker-compose.idps.yml";
|
|
33
35
|
export declare function parseComposeServiceNames(yamlText: string): string[];
|
|
34
36
|
export declare function hubOverlayServices(): ReadonlySet<string>;
|
|
37
|
+
export declare function idpsOverlayServices(): ReadonlySet<string>;
|
|
35
38
|
export declare function stackOwnedElsewhere(): string | undefined;
|
|
36
39
|
export declare function ensureDockerAvailable(): void;
|
|
37
40
|
export declare function compose(args: string[], opts?: {
|
|
38
41
|
inherit?: boolean;
|
|
39
42
|
hub?: boolean;
|
|
43
|
+
idps?: boolean;
|
|
40
44
|
env?: Record<string, string>;
|
|
41
45
|
}): string;
|
|
42
46
|
export declare function summarizeComposeFailure(output: string): string;
|
|
43
47
|
export declare function composeLogPath(op: string): string;
|
|
44
48
|
export declare function composeStreamed(args: string[], opts?: {
|
|
45
49
|
hub?: boolean;
|
|
50
|
+
idps?: boolean;
|
|
46
51
|
env?: Record<string, string>;
|
|
47
52
|
}): Promise<void>;
|
|
48
53
|
export interface ServiceStatus {
|
|
@@ -53,10 +58,13 @@ export interface ServiceStatus {
|
|
|
53
58
|
export declare function parseComposePs(output: string): ServiceStatus[];
|
|
54
59
|
export declare function stackServices(): ServiceStatus[];
|
|
55
60
|
export declare const ONE_SHOT_SERVICES: Set<string>;
|
|
61
|
+
export interface OverlayState {
|
|
62
|
+
services: ReadonlySet<string>;
|
|
63
|
+
started: boolean;
|
|
64
|
+
}
|
|
56
65
|
export declare function crashedServices(services: ServiceStatus[], opts: {
|
|
57
|
-
|
|
58
|
-
overlayStarted: boolean;
|
|
66
|
+
overlays: readonly OverlayState[];
|
|
59
67
|
}): ServiceStatus[];
|
|
60
|
-
export declare function
|
|
68
|
+
export declare function overlayRunning(services: ServiceStatus[], overlayServices: ReadonlySet<string>): boolean;
|
|
61
69
|
export declare function hubApiRunning(services: ServiceStatus[]): boolean;
|
|
62
70
|
export declare function probeEndpoint(endpoint: StackEndpoint): Promise<boolean>;
|