@monster-orange/den 0.1.0 → 0.2.1
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/CHANGELOG.md +204 -0
- package/README.md +1 -1
- package/bin/den +8 -0
- package/docs/distributions.md +283 -12
- package/package.json +4 -2
- package/src/pillars/journeys/sandbox.ts +2 -5
- package/src/pillars/security/discover.ts +17 -1
- package/src/pillars/security/run.ts +31 -3
- package/src/shared/agent-actor.ts +77 -0
- package/src/shared/den-config.ts +122 -0
- package/src/shared/edge/status.ts +74 -0
- package/src/shared/environment-manager/health.ts +54 -8
- package/src/shared/environment-manager/load-applications.ts +31 -1
- package/src/shared/environment-manager/log-tail.ts +5 -4
- package/src/shared/environment-manager/opctl.ts +2 -1
- package/src/shared/environment-manager/service-container.ts +47 -0
- package/src/shared/environment-manager/service-runner.ts +19 -2
- package/src/shared/environment-manager/sync-targets.ts +66 -0
- package/src/shared/environment-manager/up-plan.ts +25 -1
- package/src/shared/manifest/merge-override.ts +75 -0
- package/src/shared/manifest/placement.ts +333 -0
- package/src/shared/manifest/read-manifest.ts +156 -38
- package/src/shared/manifest/schema.ts +14 -0
- package/src/shared/manifest/validate-layers.ts +229 -0
- package/src/shared/manifest/validate-manifest.ts +66 -6
- package/src/shared/paths.ts +12 -0
- package/src/shared/redact.ts +107 -0
- package/src/shared/stacks/manifest-commands.ts +30 -0
- package/src/shared/stacks/status.ts +99 -0
- package/src/shared/types/index.ts +23 -0
- package/src/ui/cli/applications-command.ts +34 -2
- package/src/ui/cli/edge-command.ts +9 -19
- package/src/ui/cli/graph-command.ts +50 -4
- package/src/ui/cli/index.ts +184 -55
- package/src/ui/cli/mcp-connect.ts +350 -0
- package/src/ui/cli/mcp-main.ts +13 -0
- package/src/ui/cli/mcp-stdout-guard.ts +10 -0
- package/src/ui/cli/named-target.ts +11 -0
- package/src/ui/cli/stack-command.ts +13 -40
- package/src/ui/cli/test-runner.ts +8 -1
- package/src/ui/mcp/api.ts +286 -0
- package/src/ui/mcp/connect.ts +304 -0
- package/src/ui/mcp/guards.ts +145 -0
- package/src/ui/mcp/server.ts +149 -0
- package/src/ui/mcp/tools.ts +644 -0
- package/src/ui/server/audit.ts +137 -0
- package/src/ui/server/changelog-parser.ts +11 -0
- package/src/ui/server/graph-prefs.ts +89 -0
- package/src/ui/server/health-row.ts +15 -0
- package/src/ui/server/index.ts +256 -61
- package/src/ui/server/lifecycle-exec.ts +188 -0
- package/src/ui/server/log-history.ts +4 -3
- package/src/ui/server/public/environments.html +4 -2
- package/src/ui/server/public/graph-model.js +416 -0
- package/src/ui/server/public/graph.css +980 -0
- package/src/ui/server/public/graph.js +986 -0
- package/src/ui/server/public/header.js +7 -5
- package/src/ui/server/public/local-dev.html +130 -2191
- package/src/ui/server/public/logs.html +11 -4
- package/src/ui/server/public/panel.js +790 -0
- package/src/ui/server/public/services.html +6 -1
- package/src/ui/server/readonly-routes.ts +209 -0
- package/src/ui/server/ui-assets.ts +73 -0
- package/src/ui/server/workspace-routes.ts +39 -8
|
@@ -35,7 +35,11 @@ import type {
|
|
|
35
35
|
TestResult,
|
|
36
36
|
} from "../../shared/types/index.js";
|
|
37
37
|
import { getDistRoot } from "../../shared/paths.js";
|
|
38
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
isProductionEnv,
|
|
40
|
+
readSecurityAutoTargets,
|
|
41
|
+
} from "../../shared/den-config.js";
|
|
42
|
+
import { isProtectedRemote } from "../../shared/manifest/placement.js";
|
|
39
43
|
import { listServicesWithManifests } from "../../shared/manifest/workspace-manifests.js";
|
|
40
44
|
import {
|
|
41
45
|
autoSecurityTargets,
|
|
@@ -67,6 +71,7 @@ import {
|
|
|
67
71
|
} from "./plan.js";
|
|
68
72
|
|
|
69
73
|
import { envKey, product } from "../../shared/product.js";
|
|
74
|
+
import { containsValue, redactValues } from "../../shared/redact.js";
|
|
70
75
|
// ── Exit codes ───────────────────────────────────────────────────────────────
|
|
71
76
|
|
|
72
77
|
export const EXIT_CLEAN = 0;
|
|
@@ -189,6 +194,8 @@ export interface EntrypointDeps {
|
|
|
189
194
|
now: () => Date;
|
|
190
195
|
log: (message: string) => void;
|
|
191
196
|
logError: (message: string) => void;
|
|
197
|
+
/** Whether the org counts an env as production (default: org config). */
|
|
198
|
+
isProduction?: (env: string) => boolean;
|
|
192
199
|
}
|
|
193
200
|
|
|
194
201
|
/** Production wiring: real docker, real token minting, real result writer. */
|
|
@@ -291,8 +298,8 @@ function redactFile(
|
|
|
291
298
|
): void {
|
|
292
299
|
if (secret === undefined || secret.length === 0) return;
|
|
293
300
|
const contents = deps.readFileIfExists(filePath);
|
|
294
|
-
if (contents !== undefined && contents
|
|
295
|
-
deps.writeFile(filePath, contents
|
|
301
|
+
if (contents !== undefined && containsValue(contents, [secret])) {
|
|
302
|
+
deps.writeFile(filePath, redactValues(contents, [secret]));
|
|
296
303
|
}
|
|
297
304
|
}
|
|
298
305
|
|
|
@@ -574,7 +581,28 @@ export async function runSecurityCli(
|
|
|
574
581
|
const units: ScanUnit[] = [];
|
|
575
582
|
try {
|
|
576
583
|
const suffixes = deps.ownedHosts();
|
|
584
|
+
const isProduction =
|
|
585
|
+
deps.isProduction ?? ((e: string) => isProductionEnv(e));
|
|
577
586
|
for (const target of targets) {
|
|
587
|
+
// R13: a remote service whose target env counts as production is never
|
|
588
|
+
// scanned; any other remote one is scanned only in its own env, never
|
|
589
|
+
// as if it ran locally.
|
|
590
|
+
const placed = target.placement;
|
|
591
|
+
if (placed?.runs === "remote" && placed.target) {
|
|
592
|
+
const where = placed.target.env;
|
|
593
|
+
if (isProtectedRemote(placed, isProduction)) {
|
|
594
|
+
deps.log(
|
|
595
|
+
`↷ skipping ${securityTargetId(target)}: runs in ${where}, which counts as production`,
|
|
596
|
+
);
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (args.env === "local") {
|
|
600
|
+
deps.log(
|
|
601
|
+
`↷ skipping ${securityTargetId(target)}: runs in ${where}, not on this machine`,
|
|
602
|
+
);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
578
606
|
// A target that declares no host for THIS env (e.g. only staging/prod)
|
|
579
607
|
// is simply not in scope here — skip it, never abort the batch.
|
|
580
608
|
const inEnv =
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who asked: the headers the MCP server (`<cli> mcp`) sends with every web
|
|
3
|
+
* API call, so the server can record an action as an agent's. The client
|
|
4
|
+
* name and version are what the MCP client reported about itself at
|
|
5
|
+
* initialize: a label for the audit trail, never used for authorization.
|
|
6
|
+
*/
|
|
7
|
+
import type { IncomingHttpHeaders } from "http";
|
|
8
|
+
|
|
9
|
+
export const AUDIT_HEADERS = {
|
|
10
|
+
actor: "x-den-actor",
|
|
11
|
+
client: "x-den-mcp-client",
|
|
12
|
+
clientVersion: "x-den-mcp-client-version",
|
|
13
|
+
pid: "x-den-mcp-pid",
|
|
14
|
+
session: "x-den-mcp-session",
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export interface AgentInfo {
|
|
18
|
+
/** The MCP client's self-reported name (clientInfo.name). */
|
|
19
|
+
client: string;
|
|
20
|
+
/** The MCP client's self-reported version (clientInfo.version). */
|
|
21
|
+
clientVersion: string;
|
|
22
|
+
/** The MCP server process's pid. */
|
|
23
|
+
mcpPid: number | null;
|
|
24
|
+
/** The MCP server's session id (one per `<cli> mcp` process). */
|
|
25
|
+
session: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AgentActor extends AgentInfo {
|
|
29
|
+
kind: "agent";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** null: a person in the web UI (no agent headers). */
|
|
33
|
+
export type Actor = AgentActor | null;
|
|
34
|
+
|
|
35
|
+
/** Printable, single-line, bounded: these values land in log lines. */
|
|
36
|
+
function clean(v: unknown, max = 64): string {
|
|
37
|
+
const s = Array.isArray(v) ? v[0] : v;
|
|
38
|
+
return String(s ?? "")
|
|
39
|
+
.replace(/[^\x20-\x7e]/g, "")
|
|
40
|
+
.trim()
|
|
41
|
+
.slice(0, max);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The headers the MCP client sends (see AUDIT_HEADERS). */
|
|
45
|
+
export function agentAuditHeaders(info: AgentInfo): Record<string, string> {
|
|
46
|
+
return {
|
|
47
|
+
[AUDIT_HEADERS.actor]: "agent",
|
|
48
|
+
[AUDIT_HEADERS.client]: clean(info.client) || "unknown",
|
|
49
|
+
[AUDIT_HEADERS.clientVersion]: clean(info.clientVersion) || "unknown",
|
|
50
|
+
[AUDIT_HEADERS.pid]: info.mcpPid === null ? "" : String(info.mcpPid),
|
|
51
|
+
[AUDIT_HEADERS.session]: clean(info.session).replace(/[^A-Za-z0-9-]/g, ""),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The actor an incoming request names, or null for a person in the UI. */
|
|
56
|
+
export function actorFromHeaders(
|
|
57
|
+
headers: IncomingHttpHeaders | Record<string, string | undefined>,
|
|
58
|
+
): Actor {
|
|
59
|
+
const h = headers as Record<string, string | string[] | undefined>;
|
|
60
|
+
if (clean(h[AUDIT_HEADERS.actor]) !== "agent") return null;
|
|
61
|
+
const pid = clean(h[AUDIT_HEADERS.pid], 10);
|
|
62
|
+
return {
|
|
63
|
+
kind: "agent",
|
|
64
|
+
client: clean(h[AUDIT_HEADERS.client]) || "unknown",
|
|
65
|
+
clientVersion: clean(h[AUDIT_HEADERS.clientVersion], 32) || "unknown",
|
|
66
|
+
mcpPid: /^\d+$/.test(pid) ? Number(pid) : null,
|
|
67
|
+
session: clean(h[AUDIT_HEADERS.session]).replace(/[^A-Za-z0-9-]/g, ""),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** "agent <client> <version> (mcp pid <pid>, session <id>)". */
|
|
72
|
+
export function actorLabel(actor: AgentActor): string {
|
|
73
|
+
return (
|
|
74
|
+
`agent ${actor.client} ${actor.clientVersion} ` +
|
|
75
|
+
`(mcp pid ${actor.mcpPid ?? "?"}, session ${actor.session || "?"})`
|
|
76
|
+
);
|
|
77
|
+
}
|
package/src/shared/den-config.ts
CHANGED
|
@@ -178,6 +178,12 @@ export interface DenConfig {
|
|
|
178
178
|
secrets?: SecretsConfig;
|
|
179
179
|
/** Environments enabled for this deployment. */
|
|
180
180
|
environments: Environment[];
|
|
181
|
+
/**
|
|
182
|
+
* The environments that count as production (default: prod, production).
|
|
183
|
+
* Production is an attribute of an env, never a name check: Den refuses to
|
|
184
|
+
* start, stop, scan or exec a remote service whose target env is listed.
|
|
185
|
+
*/
|
|
186
|
+
production_envs?: string[];
|
|
181
187
|
/**
|
|
182
188
|
* Root directory for local repo discovery, relative to the den directory.
|
|
183
189
|
* Defaults to ".." (the parent of den), which covers the common layout where
|
|
@@ -227,6 +233,11 @@ export interface DenConfig {
|
|
|
227
233
|
env_health_urls?: Record<string, string>;
|
|
228
234
|
/** Security pillar (OWASP ZAP) policy — see readSecurityOwnedHosts. */
|
|
229
235
|
security?: SecurityConfig;
|
|
236
|
+
/**
|
|
237
|
+
* The MCP server (`<cli> mcp`). Without this block agents get read tools
|
|
238
|
+
* only; `actions` opts in to named actions. See readMcpConfig.
|
|
239
|
+
*/
|
|
240
|
+
mcp?: McpConfig;
|
|
230
241
|
/**
|
|
231
242
|
* Shared test accounts (email + secrets-backend item per role) the test
|
|
232
243
|
* pillars log in as — see src/shared/auth/test-accounts.ts.
|
|
@@ -299,6 +310,16 @@ export interface UserConfig {
|
|
|
299
310
|
* local and never get committed. Merged field-wise over the team base.
|
|
300
311
|
*/
|
|
301
312
|
release?: Partial<ReleaseConfig>;
|
|
313
|
+
/**
|
|
314
|
+
* Graph layout preferences (direction, grouping, card density, panel
|
|
315
|
+
* width), set from the Local Dev graph. See src/ui/server/graph-prefs.ts.
|
|
316
|
+
*/
|
|
317
|
+
graph?: {
|
|
318
|
+
direction?: string;
|
|
319
|
+
group?: string;
|
|
320
|
+
density?: string;
|
|
321
|
+
panelWidth?: number;
|
|
322
|
+
};
|
|
302
323
|
}
|
|
303
324
|
|
|
304
325
|
const ALL_ENVIRONMENTS: Environment[] = [
|
|
@@ -311,6 +332,11 @@ const ALL_ENVIRONMENTS: Environment[] = [
|
|
|
311
332
|
"production",
|
|
312
333
|
];
|
|
313
334
|
|
|
335
|
+
/** Den's built-in environment names (used when the org config lists none). */
|
|
336
|
+
export function builtinEnvironments(): Environment[] {
|
|
337
|
+
return [...ALL_ENVIRONMENTS];
|
|
338
|
+
}
|
|
339
|
+
|
|
314
340
|
const DEFAULT_CONFIG: DenConfig = {
|
|
315
341
|
environments: ALL_ENVIRONMENTS,
|
|
316
342
|
};
|
|
@@ -388,6 +414,7 @@ export function readDenConfig(): DenConfig {
|
|
|
388
414
|
environments: envs.filter((e): e is Environment =>
|
|
389
415
|
ALL_ENVIRONMENTS.includes(e as Environment),
|
|
390
416
|
),
|
|
417
|
+
production_envs: parsed?.production_envs,
|
|
391
418
|
repos_root: parsed?.repos_root,
|
|
392
419
|
github_org: parsed?.github_org,
|
|
393
420
|
orgs: parsed?.orgs,
|
|
@@ -398,6 +425,7 @@ export function readDenConfig(): DenConfig {
|
|
|
398
425
|
service_env: parsed?.service_env,
|
|
399
426
|
env_health_urls: parsed?.env_health_urls,
|
|
400
427
|
security: parsed?.security,
|
|
428
|
+
mcp: parsed?.mcp,
|
|
401
429
|
test_accounts: parsed?.test_accounts,
|
|
402
430
|
defaults: parsed?.defaults,
|
|
403
431
|
secrets: parsed?.secrets,
|
|
@@ -428,6 +456,39 @@ export function getReposRoot(): string {
|
|
|
428
456
|
return path.resolve(ROOT, config.repos_root ?? "..");
|
|
429
457
|
}
|
|
430
458
|
|
|
459
|
+
const DEFAULT_PRODUCTION_ENVS = ["prod", "production"];
|
|
460
|
+
|
|
461
|
+
/** The envs the org counts as production (`production_envs`). */
|
|
462
|
+
export function productionEnvs(config: DenConfig = readDenConfig()): string[] {
|
|
463
|
+
const listed = config.production_envs;
|
|
464
|
+
return Array.isArray(listed) &&
|
|
465
|
+
listed.every((e): e is string => typeof e === "string")
|
|
466
|
+
? listed
|
|
467
|
+
: DEFAULT_PRODUCTION_ENVS;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Whether the org counts `env` as production. An unreadable org config falls
|
|
472
|
+
* back to the default list rather than letting a guard throw.
|
|
473
|
+
*/
|
|
474
|
+
export function isProductionEnv(env: string, config?: DenConfig): boolean {
|
|
475
|
+
let listed: string[];
|
|
476
|
+
try {
|
|
477
|
+
listed = productionEnvs(config ?? readDenConfig());
|
|
478
|
+
} catch {
|
|
479
|
+
listed = DEFAULT_PRODUCTION_ENVS;
|
|
480
|
+
}
|
|
481
|
+
return listed.includes(env);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The org's environment names, for validating manifests: the config's
|
|
486
|
+
* `environments` (Den's built-in list when unset), always with `local`.
|
|
487
|
+
*/
|
|
488
|
+
export function orgEnvironments(config: DenConfig = readDenConfig()): string[] {
|
|
489
|
+
return [...new Set<string>(["local", ...config.environments])];
|
|
490
|
+
}
|
|
491
|
+
|
|
431
492
|
/** True when the `local` environment is enabled for this engineer. */
|
|
432
493
|
export function isLocalEnabled(): boolean {
|
|
433
494
|
return readDenConfig().environments.includes("local");
|
|
@@ -480,6 +541,67 @@ export function readSecurityAutoTargets(
|
|
|
480
541
|
return config.security?.auto_targets === true;
|
|
481
542
|
}
|
|
482
543
|
|
|
544
|
+
// ── MCP config (`<cli> mcp`) ───────────────────────────────────────────────────
|
|
545
|
+
|
|
546
|
+
/** The action tools an org can opt in to. Read tools are always on. */
|
|
547
|
+
export const MCP_ACTIONS = [
|
|
548
|
+
"service_up",
|
|
549
|
+
"service_down",
|
|
550
|
+
"service_restart",
|
|
551
|
+
"workspace_sync",
|
|
552
|
+
"run_tests",
|
|
553
|
+
] as const;
|
|
554
|
+
export type McpAction = (typeof MCP_ACTIONS)[number];
|
|
555
|
+
|
|
556
|
+
/** `mcp:` in the org config. */
|
|
557
|
+
export interface McpConfig {
|
|
558
|
+
/** Action tools to register; none when absent. */
|
|
559
|
+
actions?: string[];
|
|
560
|
+
/** Environments `run_tests` may target (default [local]; production never). */
|
|
561
|
+
run_tests?: { envs?: string[] };
|
|
562
|
+
/** `remote: true` lets the log tools read non-local sources (e.g. CloudWatch). */
|
|
563
|
+
logs?: { remote?: boolean };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** `mcp:` with its defaults applied. */
|
|
567
|
+
export interface ResolvedMcpConfig {
|
|
568
|
+
actions: McpAction[];
|
|
569
|
+
runTestsEnvs: Environment[];
|
|
570
|
+
logsRemote: boolean;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* The effective MCP config. An unknown action or environment name throws:
|
|
575
|
+
* a typo must not silently turn a guard off (or an action on).
|
|
576
|
+
*/
|
|
577
|
+
export function readMcpConfig(
|
|
578
|
+
config: DenConfig = readDenConfig(),
|
|
579
|
+
): ResolvedMcpConfig {
|
|
580
|
+
const raw = config.mcp ?? {};
|
|
581
|
+
const actions = (Array.isArray(raw.actions) ? raw.actions : []).map((a) => {
|
|
582
|
+
if (!(MCP_ACTIONS as readonly string[]).includes(String(a)))
|
|
583
|
+
throw new Error(
|
|
584
|
+
`mcp.actions: unknown action "${String(a)}" (known: ${MCP_ACTIONS.join(", ")})`,
|
|
585
|
+
);
|
|
586
|
+
return a as McpAction;
|
|
587
|
+
});
|
|
588
|
+
const envList = raw.run_tests?.envs;
|
|
589
|
+
const runTestsEnvs = (Array.isArray(envList) ? envList : ["local"]).map(
|
|
590
|
+
(e) => {
|
|
591
|
+
if (!ALL_ENVIRONMENTS.includes(e as Environment))
|
|
592
|
+
throw new Error(
|
|
593
|
+
`mcp.run_tests.envs: unknown environment "${String(e)}" (known: ${ALL_ENVIRONMENTS.join(", ")})`,
|
|
594
|
+
);
|
|
595
|
+
return e as Environment;
|
|
596
|
+
},
|
|
597
|
+
);
|
|
598
|
+
return {
|
|
599
|
+
actions: [...new Set(actions)],
|
|
600
|
+
runTestsEnvs: [...new Set(runTestsEnvs)],
|
|
601
|
+
logsRemote: raw.logs?.remote === true,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
483
605
|
// ── Release config (Release tab) ──────────────────────────────────────────────
|
|
484
606
|
|
|
485
607
|
/**
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only edge status: what `edge status` shows, as data. The CLI prints
|
|
3
|
+
* it; the web server serves it at /api/edge (and through it the MCP
|
|
4
|
+
* `edge_status` tool).
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from "fs";
|
|
7
|
+
import * as path from "path";
|
|
8
|
+
import { certNames, edgeDomains, edgeNames, routeUrl } from "./render.js";
|
|
9
|
+
import { caTrusted, containerState } from "./runtime.js";
|
|
10
|
+
import type { EdgeConfig, EdgePorts } from "./types.js";
|
|
11
|
+
|
|
12
|
+
export interface EdgeStatus {
|
|
13
|
+
org: string;
|
|
14
|
+
container: string;
|
|
15
|
+
/** Docker's container state ("running", "exited", …) or "absent". */
|
|
16
|
+
state: string;
|
|
17
|
+
up: boolean;
|
|
18
|
+
/** Published host ports, or null when another proxy fronts the edge. */
|
|
19
|
+
ports: EdgePorts | null;
|
|
20
|
+
network: string;
|
|
21
|
+
domains: string[];
|
|
22
|
+
routes: { name: string; url: string; upstream: string }[];
|
|
23
|
+
/** Names on the issued cert, or the planned ones when none is issued yet. */
|
|
24
|
+
certNames: string[];
|
|
25
|
+
certIssued: boolean;
|
|
26
|
+
/** Whether the CA is trusted on this machine (null: can't tell). */
|
|
27
|
+
caTrusted: boolean | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface EdgeStatusDeps {
|
|
31
|
+
containerState: (name: string) => string;
|
|
32
|
+
caTrusted: (cfg: EdgeConfig) => boolean | null;
|
|
33
|
+
/** Names on the issued cert (its stamp file), or null when none is issued. */
|
|
34
|
+
issuedNames: (cfg: EdgeConfig) => string[] | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The names stamp `ensureCerts` writes beside the cert. */
|
|
38
|
+
export function issuedCertNames(cfg: EdgeConfig): string[] | null {
|
|
39
|
+
const stamp = path.join(cfg.stateDir, "certs", "names.txt");
|
|
40
|
+
if (!fs.existsSync(stamp)) return null;
|
|
41
|
+
return fs.readFileSync(stamp, "utf-8").split("\n").filter(Boolean);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const defaultDeps: EdgeStatusDeps = {
|
|
45
|
+
containerState,
|
|
46
|
+
caTrusted,
|
|
47
|
+
issuedNames: issuedCertNames,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function edgeStatus(
|
|
51
|
+
cfg: EdgeConfig,
|
|
52
|
+
deps: EdgeStatusDeps = defaultDeps,
|
|
53
|
+
): EdgeStatus {
|
|
54
|
+
const n = edgeNames(cfg.org);
|
|
55
|
+
const state = deps.containerState(n.container);
|
|
56
|
+
const issued = deps.issuedNames(cfg);
|
|
57
|
+
return {
|
|
58
|
+
org: cfg.org,
|
|
59
|
+
container: n.container,
|
|
60
|
+
state,
|
|
61
|
+
up: state === "running",
|
|
62
|
+
ports: cfg.ports,
|
|
63
|
+
network: n.network,
|
|
64
|
+
domains: edgeDomains(cfg),
|
|
65
|
+
routes: Object.entries(cfg.routes).map(([name, upstream]) => ({
|
|
66
|
+
name,
|
|
67
|
+
url: routeUrl(name, cfg),
|
|
68
|
+
upstream,
|
|
69
|
+
})),
|
|
70
|
+
certNames: issued ?? certNames(cfg),
|
|
71
|
+
certIssued: issued !== null,
|
|
72
|
+
caTrusted: deps.caTrusted(cfg),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -14,8 +14,18 @@ import * as fs from "fs";
|
|
|
14
14
|
import * as path from "path";
|
|
15
15
|
import * as dns from "dns";
|
|
16
16
|
import { spawn } from "child_process";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
HEALTH_CHECK_TIMEOUT_MS,
|
|
19
|
+
HOSTED_HEALTH_CHECK_TIMEOUT_MS,
|
|
20
|
+
CLI_SPAWN_TIMEOUT_MS,
|
|
21
|
+
} from "../constants.js";
|
|
18
22
|
import { readManifest } from "../manifest/read-manifest.js";
|
|
23
|
+
import {
|
|
24
|
+
expectMatches,
|
|
25
|
+
resolveHealthCheck,
|
|
26
|
+
type Expect,
|
|
27
|
+
type Placement,
|
|
28
|
+
} from "../manifest/placement.js";
|
|
19
29
|
import { readCatalog } from "./catalog.js";
|
|
20
30
|
import {
|
|
21
31
|
getActiveWorkspace,
|
|
@@ -42,6 +52,9 @@ export interface ServiceHealthResult {
|
|
|
42
52
|
branch: string | null;
|
|
43
53
|
health: "ok" | "error" | "unknown";
|
|
44
54
|
healthDetail?: string;
|
|
55
|
+
/** Where the service runs for the env checked (R13). */
|
|
56
|
+
runs?: Placement["runs"];
|
|
57
|
+
target?: Placement["target"];
|
|
45
58
|
}
|
|
46
59
|
|
|
47
60
|
// ── Name resolution ───────────────────────────────────────────────────────────
|
|
@@ -288,7 +301,16 @@ const LOCALHOST_FALLBACK_TIMEOUT_MS = 1_500;
|
|
|
288
301
|
export async function pingHttp(
|
|
289
302
|
url: string,
|
|
290
303
|
timeoutMs: number = HEALTH_CHECK_TIMEOUT_MS,
|
|
304
|
+
expect?: Expect,
|
|
291
305
|
): Promise<PingResult> {
|
|
306
|
+
// A remote target's probe (target.url): up only on the expected codes, and
|
|
307
|
+
// never a localhost fallback (it is not on this machine).
|
|
308
|
+
if (expect !== undefined) {
|
|
309
|
+
const r = await pingOnce(url, timeoutMs);
|
|
310
|
+
return r.status === undefined
|
|
311
|
+
? r
|
|
312
|
+
: { ok: expectMatches(r.status, expect), status: r.status };
|
|
313
|
+
}
|
|
292
314
|
const result = await pingOnce(url, timeoutMs);
|
|
293
315
|
if (!result.ok) {
|
|
294
316
|
// Fall back to localhost for Docker-style hostnames regardless of how the
|
|
@@ -432,12 +454,25 @@ export async function checkServiceHealth(
|
|
|
432
454
|
getFocusedWorkspaceName() ?? "default",
|
|
433
455
|
)
|
|
434
456
|
: null;
|
|
435
|
-
const
|
|
436
|
-
manifest
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
457
|
+
const plan = resolveHealthCheck(
|
|
458
|
+
manifest,
|
|
459
|
+
resolved.subsystem,
|
|
460
|
+
env,
|
|
461
|
+
composeHealth,
|
|
462
|
+
);
|
|
463
|
+
const placed: Pick<ServiceHealthResult, "runs" | "target"> = {
|
|
464
|
+
runs: plan.placement.runs,
|
|
465
|
+
target: plan.placement.target,
|
|
466
|
+
};
|
|
467
|
+
const healthCfg = plan.check;
|
|
440
468
|
if (!healthCfg) {
|
|
469
|
+
if (plan.placement.runs === "remote")
|
|
470
|
+
return {
|
|
471
|
+
...base,
|
|
472
|
+
...placed,
|
|
473
|
+
health: "unknown",
|
|
474
|
+
healthDetail: "no health check configured",
|
|
475
|
+
};
|
|
441
476
|
// Conventional local discovery: opctl exposes each system on the host at
|
|
442
477
|
// http://<repo> (port 80), and stack services serve /healthz. When the
|
|
443
478
|
// PRIMARY service (entrypoint, or a repo's only service) declares no local
|
|
@@ -460,6 +495,7 @@ export async function checkServiceHealth(
|
|
|
460
495
|
if (probe.ok && probe.status !== undefined && probe.status < 300) {
|
|
461
496
|
return {
|
|
462
497
|
...base,
|
|
498
|
+
...placed,
|
|
463
499
|
health: "ok",
|
|
464
500
|
healthDetail: `conventional ${conventional}`,
|
|
465
501
|
};
|
|
@@ -467,6 +503,7 @@ export async function checkServiceHealth(
|
|
|
467
503
|
}
|
|
468
504
|
return {
|
|
469
505
|
...base,
|
|
506
|
+
...placed,
|
|
470
507
|
health: "unknown",
|
|
471
508
|
healthDetail: "no health check configured",
|
|
472
509
|
};
|
|
@@ -475,10 +512,17 @@ export async function checkServiceHealth(
|
|
|
475
512
|
const result =
|
|
476
513
|
healthCfg.type === "shell"
|
|
477
514
|
? await pingShell(healthCfg.cmd, {}, resolved.repoPath)
|
|
478
|
-
: await pingHttp(
|
|
515
|
+
: await pingHttp(
|
|
516
|
+
healthCfg.url,
|
|
517
|
+
env === "local" && plan.placement.runs !== "remote"
|
|
518
|
+
? HEALTH_CHECK_TIMEOUT_MS
|
|
519
|
+
: HOSTED_HEALTH_CHECK_TIMEOUT_MS,
|
|
520
|
+
plan.expect,
|
|
521
|
+
);
|
|
479
522
|
|
|
480
523
|
return {
|
|
481
524
|
...base,
|
|
525
|
+
...placed,
|
|
482
526
|
health: result.ok ? "ok" : "error",
|
|
483
527
|
healthDetail:
|
|
484
528
|
result.error ??
|
|
@@ -554,8 +598,10 @@ export function rollupRepoHealth(
|
|
|
554
598
|
? rows.find((r) => r.subsystem === entrypoint)
|
|
555
599
|
: undefined;
|
|
556
600
|
if (epRow) {
|
|
557
|
-
// Show the entrypoint's health under the clean repo-name address.
|
|
601
|
+
// Show the entrypoint's health under the clean repo-name address. A
|
|
602
|
+
// remote service is not a sidecar: it keeps its own row (R13).
|
|
558
603
|
out.push({ ...epRow, address: repoName });
|
|
604
|
+
out.push(...rows.filter((r) => r !== epRow && r.runs === "remote"));
|
|
559
605
|
} else {
|
|
560
606
|
// No entrypoint → infra provider; keep the subsystem breakdown.
|
|
561
607
|
out.push(...rows);
|
|
@@ -8,6 +8,12 @@ import { deriveGraph } from "./derivation/derive-graph.js";
|
|
|
8
8
|
import type { DerivedGraph } from "./derivation/types.js";
|
|
9
9
|
import { getWorkspaceServices } from "./service-runner.js";
|
|
10
10
|
import { readOrg, type Org } from "../org.js";
|
|
11
|
+
import {
|
|
12
|
+
rollupPlacement,
|
|
13
|
+
servicePlacement,
|
|
14
|
+
type Placement,
|
|
15
|
+
type RollupPlacement,
|
|
16
|
+
} from "../manifest/placement.js";
|
|
11
17
|
|
|
12
18
|
export function loadApplications(workspace?: string): Application[] {
|
|
13
19
|
const services = getWorkspaceServices(workspace);
|
|
@@ -47,6 +53,12 @@ export interface ApplicationView extends Application {
|
|
|
47
53
|
addresses: string[];
|
|
48
54
|
/** Repos the team config lists for it that are not in this workspace. */
|
|
49
55
|
absent: string[];
|
|
56
|
+
/** address → where that service runs locally, and where a remote one is. */
|
|
57
|
+
placements: Record<string, Placement>;
|
|
58
|
+
/** local | remote | hybrid | none, over its services (R13). */
|
|
59
|
+
placement: RollupPlacement;
|
|
60
|
+
/** "shop-frontend local · shop-core remote (dev)" */
|
|
61
|
+
summary: string;
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
/** Org > Application > Repo > Services for a workspace (CLI `apps`, web UI). */
|
|
@@ -55,9 +67,18 @@ export function loadApplicationTree(workspace?: string): {
|
|
|
55
67
|
applications: ApplicationView[];
|
|
56
68
|
} {
|
|
57
69
|
const byRepo = new Map<string, string[]>();
|
|
70
|
+
const placed = new Map<
|
|
71
|
+
string,
|
|
72
|
+
Placement & { repo: string; service: string }
|
|
73
|
+
>();
|
|
58
74
|
for (const s of getWorkspaceServices(workspace)) {
|
|
59
75
|
if (!byRepo.has(s.repoName)) byRepo.set(s.repoName, []);
|
|
60
76
|
byRepo.get(s.repoName)!.push(s.address);
|
|
77
|
+
placed.set(s.address, {
|
|
78
|
+
repo: s.repoName,
|
|
79
|
+
service: s.subsystem,
|
|
80
|
+
...servicePlacement(readManifest(s.repoPath), s.subsystem, "local"),
|
|
81
|
+
});
|
|
61
82
|
}
|
|
62
83
|
const config = readDenConfig().applications ?? {};
|
|
63
84
|
const org = readOrg();
|
|
@@ -67,11 +88,20 @@ export function loadApplicationTree(workspace?: string): {
|
|
|
67
88
|
const services = Object.fromEntries(
|
|
68
89
|
a.repos.map((r) => [r, byRepo.get(r) ?? []]),
|
|
69
90
|
);
|
|
91
|
+
const addresses = Object.values(services).flat();
|
|
92
|
+
const rows = addresses.flatMap((addr) => placed.get(addr) ?? []);
|
|
70
93
|
return {
|
|
71
94
|
...a,
|
|
72
95
|
services,
|
|
73
|
-
addresses
|
|
96
|
+
addresses,
|
|
74
97
|
absent: (config[a.name]?.repos ?? []).filter((r) => !byRepo.has(r)),
|
|
98
|
+
placements: Object.fromEntries(
|
|
99
|
+
addresses.flatMap((addr) => {
|
|
100
|
+
const p = placed.get(addr);
|
|
101
|
+
return p ? [[addr, { runs: p.runs, target: p.target }]] : [];
|
|
102
|
+
}),
|
|
103
|
+
),
|
|
104
|
+
...rollupPlacement(rows),
|
|
75
105
|
};
|
|
76
106
|
}),
|
|
77
107
|
};
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* Live log tailing for a local service — the CLI-side counterpart of the web
|
|
3
3
|
* /api/service/taillog SSE endpoint. Source resolution, in order:
|
|
4
4
|
* 1. The service's manifest `logs.local` shell command (e.g. `docker logs -f`)
|
|
5
|
-
* 2.
|
|
6
|
-
*
|
|
5
|
+
* 2. The service's running container → `docker logs -f`: a compose stack's
|
|
6
|
+
* `<stack>-<workspace>-<service>-1`, else an `opctl_<base>_<hash>` one —
|
|
7
|
+
* real runtime logs with zero manifest config
|
|
7
8
|
* 3. The den daemon startup log (~/.den/logs/<repo>/<subsystem>.log) —
|
|
8
9
|
* last resort, covers host-run processes with no container
|
|
9
10
|
* A source whose command exits within the quick-exit window (container not
|
|
@@ -13,7 +14,7 @@
|
|
|
13
14
|
import * as fs from "fs";
|
|
14
15
|
import { DAEMON_QUICK_EXIT_MS, LIVE_TAIL_LINES } from "../constants.js";
|
|
15
16
|
import { readManifest } from "../manifest/read-manifest.js";
|
|
16
|
-
import {
|
|
17
|
+
import { findServiceContainer } from "./service-container.js";
|
|
17
18
|
import {
|
|
18
19
|
getServiceLogPath,
|
|
19
20
|
spawnStreaming,
|
|
@@ -85,7 +86,7 @@ export function tailSourceCandidates(
|
|
|
85
86
|
export function resolveTailSources(resolved: ResolvedService): TailSource[] {
|
|
86
87
|
const manifest = readManifest(resolved.repoPath);
|
|
87
88
|
const logSource = manifest?.services?.[resolved.subsystem]?.logs?.local;
|
|
88
|
-
const container =
|
|
89
|
+
const container = findServiceContainer(
|
|
89
90
|
resolved.repoName,
|
|
90
91
|
resolved.repoPath,
|
|
91
92
|
resolved.subsystem,
|
|
@@ -100,7 +100,8 @@ export function isOpctlContainerFor(name: string, base: string): boolean {
|
|
|
100
100
|
return suffix.length > 0 && !suffix.includes("_");
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
|
|
103
|
+
/** Names of the running docker containers (throws when docker is unavailable). */
|
|
104
|
+
export function runningDockerNames(): string[] {
|
|
104
105
|
return execSync('docker ps --format "{{.Names}}"', {
|
|
105
106
|
encoding: "utf-8",
|
|
106
107
|
stdio: ["ignore", "pipe", "ignore"],
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The running container that holds a service's logs. A compose-stack service
|
|
3
|
+
* runs as `<stack>-<workspace>-<service>-1` (the stacks naming helpers); any
|
|
4
|
+
* other container-run service is found by opctl's naming convention.
|
|
5
|
+
*/
|
|
6
|
+
import { readManifest } from "../manifest/read-manifest.js";
|
|
7
|
+
import { composeServiceContainers } from "../stacks/manifest-commands.js";
|
|
8
|
+
import { resolveStackWorkspaces } from "../stacks/status.js";
|
|
9
|
+
import { findConventionalContainer, runningDockerNames } from "./opctl.js";
|
|
10
|
+
|
|
11
|
+
/** The stack's container for `subsystem` when one is running, else null. */
|
|
12
|
+
export function findStackServiceContainer(
|
|
13
|
+
repoName: string,
|
|
14
|
+
repoPath: string | undefined,
|
|
15
|
+
subsystem: string | undefined,
|
|
16
|
+
): string | null {
|
|
17
|
+
if (!repoPath || !subsystem) return null;
|
|
18
|
+
const manifest = readManifest(repoPath);
|
|
19
|
+
if (!manifest?.compose) return null;
|
|
20
|
+
let candidates: string[];
|
|
21
|
+
let running: string[];
|
|
22
|
+
try {
|
|
23
|
+
const { stackWorkspace } = resolveStackWorkspaces();
|
|
24
|
+
candidates = composeServiceContainers(
|
|
25
|
+
manifest,
|
|
26
|
+
subsystem,
|
|
27
|
+
repoName,
|
|
28
|
+
stackWorkspace,
|
|
29
|
+
);
|
|
30
|
+
running = runningDockerNames();
|
|
31
|
+
} catch {
|
|
32
|
+
return null; // bad workspace name, or docker unavailable
|
|
33
|
+
}
|
|
34
|
+
return candidates.find((c) => running.includes(c)) ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A service's running container: its compose stack's, else opctl's. */
|
|
38
|
+
export function findServiceContainer(
|
|
39
|
+
repoName: string,
|
|
40
|
+
repoPath?: string,
|
|
41
|
+
subsystem?: string,
|
|
42
|
+
): string | null {
|
|
43
|
+
return (
|
|
44
|
+
findStackServiceContainer(repoName, repoPath, subsystem) ??
|
|
45
|
+
findConventionalContainer(repoName, repoPath, subsystem)
|
|
46
|
+
);
|
|
47
|
+
}
|