@monster-orange/den 0.2.3 → 0.2.5
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 +208 -0
- package/docs/distributions.md +64 -16
- package/package.json +1 -1
- package/service/install.sh +4 -2
- package/src/shared/constants.ts +7 -0
- package/src/shared/den-config.ts +130 -12
- package/src/shared/edge/config.ts +16 -0
- package/src/shared/engine.ts +18 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -87
- package/src/shared/environment-manager/health.ts +229 -30
- package/src/shared/environment-manager/opctl.ts +83 -1
- package/src/shared/environment-manager/service-runner.ts +49 -4
- package/src/shared/git/origin-url.ts +77 -0
- package/src/shared/manifest/read-manifest.ts +39 -15
- package/src/shared/paths.ts +42 -0
- package/src/shared/product.ts +7 -1
- package/src/shared/profile.ts +8 -0
- package/src/shared/read-cache.ts +77 -0
- package/src/shared/stacks/compose-health.ts +150 -0
- package/src/shared/stacks/runtime.ts +71 -24
- package/src/shared/stacks/status.ts +112 -3
- package/src/ui/cli/health-display.ts +1 -0
- package/src/ui/cli/index.ts +181 -31
- package/src/ui/cli/mcp-connect.ts +77 -15
- package/src/ui/cli/stack-command.ts +40 -12
- package/src/ui/cli/web-command.ts +25 -7
- package/src/ui/cli/workspace-notes.ts +43 -0
- package/src/ui/server/graph-order.ts +22 -0
- package/src/ui/server/index.ts +174 -197
- package/src/ui/server/public/changelog-view.js +16 -0
- package/src/ui/server/public/header.js +121 -12
- package/src/ui/server/services-list.ts +210 -0
- package/src/ui/server/single-flight.ts +53 -0
- package/src/ui/server/ui-assets.ts +1 -0
- package/src/ui/server/workspace-routes.ts +247 -181
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
|
|
16
16
|
import * as fs from "fs";
|
|
17
17
|
import * as path from "path";
|
|
18
|
-
import {
|
|
18
|
+
import { readOriginUrl } from "../git/origin-url.js";
|
|
19
|
+
import { cachedRead } from "../read-cache.js";
|
|
19
20
|
import { getDistRoot } from "../paths.js";
|
|
20
21
|
import { parse as parseYaml } from "yaml";
|
|
21
22
|
import type { RawManifest } from "../types/index.js";
|
|
@@ -48,21 +49,15 @@ const originCache = new Map<string, string | null>();
|
|
|
48
49
|
/** Repo name of `repoPath`'s git origin (`…/<name>.git`), cached. */
|
|
49
50
|
export function originRepoName(repoPath: string): string | null {
|
|
50
51
|
if (originCache.has(repoPath)) return originCache.get(repoPath)!;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
{ encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 2000 },
|
|
57
|
-
).trim();
|
|
58
|
-
name =
|
|
59
|
-
url
|
|
52
|
+
// Read from the git config file, never by spawning git: this runs on the
|
|
53
|
+
// web server's request path for every repo without its own manifest.
|
|
54
|
+
const url = readOriginUrl(repoPath);
|
|
55
|
+
const name = url
|
|
56
|
+
? url
|
|
60
57
|
.replace(/\.git$/, "")
|
|
61
58
|
.split(/[/:]/)
|
|
62
|
-
.pop() || null
|
|
63
|
-
|
|
64
|
-
/* not a git checkout, or no origin */
|
|
65
|
-
}
|
|
59
|
+
.pop() || null
|
|
60
|
+
: null;
|
|
66
61
|
originCache.set(repoPath, name);
|
|
67
62
|
return name;
|
|
68
63
|
}
|
|
@@ -169,6 +164,29 @@ export function parseManifestText(
|
|
|
169
164
|
return parsed as Record<string, unknown>;
|
|
170
165
|
}
|
|
171
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Parsed manifests by file, reused while the file's text is unchanged. A
|
|
169
|
+
* single web request reads the same manifests several times (kind, health
|
|
170
|
+
* plan, stack names, edge links), and parsing YAML is the expensive part:
|
|
171
|
+
* it held the event loop ~100-200ms per /api/health run. Callers get their
|
|
172
|
+
* own copy, so mutating a result never leaks into the cache.
|
|
173
|
+
*/
|
|
174
|
+
const parsedCache = new Map<
|
|
175
|
+
string,
|
|
176
|
+
{ raw: string; parsed: Record<string, unknown> | null }
|
|
177
|
+
>();
|
|
178
|
+
|
|
179
|
+
function parseCached(
|
|
180
|
+
file: string,
|
|
181
|
+
raw: string,
|
|
182
|
+
): Record<string, unknown> | null {
|
|
183
|
+
const hit = parsedCache.get(file);
|
|
184
|
+
if (hit && hit.raw === raw) return hit.parsed && structuredClone(hit.parsed);
|
|
185
|
+
const parsed = parseManifestText(file, raw);
|
|
186
|
+
parsedCache.set(file, { raw, parsed });
|
|
187
|
+
return parsed && structuredClone(parsed);
|
|
188
|
+
}
|
|
189
|
+
|
|
172
190
|
/** The message for an override that has no base to apply to. */
|
|
173
191
|
export function orphanOverrideMessage(): string {
|
|
174
192
|
return `an override with no base manifest is not a manifest: add a ${product().manifestYml} to the repo, or to manifests/`;
|
|
@@ -198,7 +216,7 @@ function combine(
|
|
|
198
216
|
): ManifestLoad {
|
|
199
217
|
const out: ManifestLoad = { manifest: null, layers: [], ...at };
|
|
200
218
|
const parse = (file: string, raw: string): Record<string, unknown> => {
|
|
201
|
-
const parsed =
|
|
219
|
+
const parsed = parseCached(file, raw);
|
|
202
220
|
if (!parsed) throw new Error(`${file}: not a map`);
|
|
203
221
|
return parsed;
|
|
204
222
|
};
|
|
@@ -224,6 +242,12 @@ function combine(
|
|
|
224
242
|
|
|
225
243
|
/** Resolve, read and merge `repoPath`'s manifest layers. Never throws. */
|
|
226
244
|
export function loadManifest(repoPath: string): ManifestLoad {
|
|
245
|
+
// The web server resolves the same manifests many times per request; it
|
|
246
|
+
// reuses them for the read cache's TTL (see read-cache.ts).
|
|
247
|
+
return cachedRead(`manifest:${repoPath}`, () => loadManifestFresh(repoPath));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function loadManifestFresh(repoPath: string): ManifestLoad {
|
|
227
251
|
const at = locate(repoPath);
|
|
228
252
|
try {
|
|
229
253
|
return combine(
|
package/src/shared/paths.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import {
|
|
4
4
|
loadProfile,
|
|
5
|
+
onProfileCacheReset,
|
|
5
6
|
PROFILE_FILE,
|
|
6
7
|
readEnv,
|
|
7
8
|
type ProductProfile,
|
|
@@ -74,6 +75,34 @@ function rootFromEnv(): string | null {
|
|
|
74
75
|
* anchor fails loudly.
|
|
75
76
|
*/
|
|
76
77
|
export function getDistRoot(): string {
|
|
78
|
+
// Resolved once per set of inputs: the walk and its den.yml checks used to
|
|
79
|
+
// run on every call (hundreds per web request, via product()). The inputs
|
|
80
|
+
// are the env vars it reads and the entry script; change any and it
|
|
81
|
+
// re-resolves. A failure is never cached.
|
|
82
|
+
const key = distRootKey();
|
|
83
|
+
if (distRootMemo?.key === key) return distRootMemo.root;
|
|
84
|
+
const root = resolveDistRoot();
|
|
85
|
+
distRootMemo = { key, root };
|
|
86
|
+
return root;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let distRootMemo: { key: string; root: string } | null = null;
|
|
90
|
+
|
|
91
|
+
/** Everything getDistRoot's answer depends on, as one string. */
|
|
92
|
+
function distRootKey(): string {
|
|
93
|
+
let key = `${process.argv[1] ?? ""}\0${process.env.DEN_PROFILE ?? ""}`;
|
|
94
|
+
for (const name in process.env)
|
|
95
|
+
if (name.endsWith("_ROOT")) key += `\0${name}=${process.env[name]}`;
|
|
96
|
+
return key;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Forget the resolved root (tests that rebuild a root in place). */
|
|
100
|
+
export function resetDistRootCache(): void {
|
|
101
|
+
distRootMemo = null;
|
|
102
|
+
}
|
|
103
|
+
onProfileCacheReset(resetDistRootCache);
|
|
104
|
+
|
|
105
|
+
function resolveDistRoot(): string {
|
|
77
106
|
const fromEnv = rootFromEnv();
|
|
78
107
|
if (fromEnv) return fromEnv;
|
|
79
108
|
const start = entryDir();
|
|
@@ -128,6 +157,19 @@ function isProductRootVar(dir: string, key: string): boolean {
|
|
|
128
157
|
* node_modules/@monster-orange/den; in Den's own checkout, its root.
|
|
129
158
|
*/
|
|
130
159
|
export function getEngineRoot(): string {
|
|
160
|
+
const key = `${process.env.DEN_ENGINE_ROOT ?? ""}\0${process.argv[1] ?? ""}\0${process.cwd()}`;
|
|
161
|
+
if (engineRootMemo?.key === key) return engineRootMemo.root;
|
|
162
|
+
const root = resolveEngineRoot();
|
|
163
|
+
engineRootMemo = { key, root };
|
|
164
|
+
return root;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let engineRootMemo: { key: string; root: string } | null = null;
|
|
168
|
+
onProfileCacheReset(() => {
|
|
169
|
+
engineRootMemo = null;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
function resolveEngineRoot(): string {
|
|
131
173
|
const env = process.env.DEN_ENGINE_ROOT?.trim();
|
|
132
174
|
if (env) return path.resolve(env);
|
|
133
175
|
for (const start of [entryDir(), process.cwd()]) {
|
package/src/shared/product.ts
CHANGED
|
@@ -35,10 +35,16 @@ function withDerived(p: ProductProfile): Product {
|
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
let derived: { profile: ProductProfile; product: Product } | null = null;
|
|
39
|
+
|
|
38
40
|
/** The running product. Never throws. */
|
|
39
41
|
export function product(): Product {
|
|
40
42
|
try {
|
|
41
|
-
|
|
43
|
+
const profile = loadProfile(getDistRoot());
|
|
44
|
+
// Same profile object (loadProfile caches it): same derived names.
|
|
45
|
+
if (derived?.profile !== profile)
|
|
46
|
+
derived = { profile, product: withDerived(profile) };
|
|
47
|
+
return derived.product;
|
|
42
48
|
} catch {
|
|
43
49
|
const pinned = process.env.DEN_PROFILE?.trim();
|
|
44
50
|
try {
|
package/src/shared/profile.ts
CHANGED
|
@@ -122,8 +122,16 @@ export function loadProfile(root: string): ProductProfile {
|
|
|
122
122
|
return cached.profile;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
const resetHooks: (() => void)[] = [];
|
|
126
|
+
|
|
127
|
+
/** Run `hook` whenever resetProfileCache runs (caches derived from it). */
|
|
128
|
+
export function onProfileCacheReset(hook: () => void): void {
|
|
129
|
+
resetHooks.push(hook);
|
|
130
|
+
}
|
|
131
|
+
|
|
125
132
|
export function resetProfileCache(): void {
|
|
126
133
|
cached = null;
|
|
134
|
+
for (const hook of resetHooks) hook();
|
|
127
135
|
}
|
|
128
136
|
|
|
129
137
|
/** `${PREFIX}_${key}`: the env-var name this distribution uses for `key`. */
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A short-lived cache for the web server's repeated reads (manifests, the
|
|
3
|
+
* edge config, the derived graph), so a request does not re-stat and
|
|
4
|
+
* re-read the same files dozens of times, and polls a few seconds apart
|
|
5
|
+
* share one read.
|
|
6
|
+
*
|
|
7
|
+
* Off unless the web server enables it: a CLI command and the tests read
|
|
8
|
+
* fresh every time, as before. The server clears it on every mutating
|
|
9
|
+
* request, so a write made through the UI is seen by the very next read;
|
|
10
|
+
* an edit made outside the server (an editor, the CLI) is seen within the
|
|
11
|
+
* TTL. Values are cloned on the way out, so a caller that mutates its copy
|
|
12
|
+
* never changes what the next caller gets.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
let ttlMs = 0;
|
|
16
|
+
const entries = new Map<string, { at: number; value: unknown }>();
|
|
17
|
+
/** cachedReadStale's values: kept across a clear, only marked stale. */
|
|
18
|
+
const stale = new Map<string, { at: number; value: unknown }>();
|
|
19
|
+
const refreshing = new Set<string>();
|
|
20
|
+
|
|
21
|
+
/** Turn the cache on (web server only). */
|
|
22
|
+
export function enableReadCache(ms: number): void {
|
|
23
|
+
ttlMs = ms;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Drop every cached read (after a mutation, or in tests). */
|
|
27
|
+
export function clearReadCache(): void {
|
|
28
|
+
entries.clear();
|
|
29
|
+
for (const e of stale.values()) e.at = 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function copy<T>(v: T): T {
|
|
33
|
+
return v !== null && typeof v === "object" ? structuredClone(v) : v;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `load()`'s value for `key`, reused for the TTL when the cache is on. */
|
|
37
|
+
export function cachedRead<T>(key: string, load: () => T): T {
|
|
38
|
+
if (ttlMs <= 0) return load();
|
|
39
|
+
const hit = entries.get(key);
|
|
40
|
+
if (hit && Date.now() - hit.at < ttlMs) return copy(hit.value as T);
|
|
41
|
+
const value = load();
|
|
42
|
+
entries.set(key, { at: Date.now(), value });
|
|
43
|
+
return copy(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Like cachedRead, but past the TTL it still answers with the last value at
|
|
48
|
+
* once and recomputes after the current request (setImmediate), so no
|
|
49
|
+
* request waits on the recompute. For expensive, slow-changing results
|
|
50
|
+
* (the derived dependency graph) whose staleness only affects ordering.
|
|
51
|
+
*/
|
|
52
|
+
export function cachedReadStale<T>(
|
|
53
|
+
key: string,
|
|
54
|
+
maxAgeMs: number,
|
|
55
|
+
load: () => T,
|
|
56
|
+
): T {
|
|
57
|
+
if (ttlMs <= 0) return load();
|
|
58
|
+
const hit = stale.get(key);
|
|
59
|
+
if (!hit) {
|
|
60
|
+
const value = load();
|
|
61
|
+
stale.set(key, { at: Date.now(), value });
|
|
62
|
+
return copy(value);
|
|
63
|
+
}
|
|
64
|
+
if (Date.now() - hit.at >= maxAgeMs && !refreshing.has(key)) {
|
|
65
|
+
refreshing.add(key);
|
|
66
|
+
setImmediate(() => {
|
|
67
|
+
try {
|
|
68
|
+
stale.set(key, { at: Date.now(), value: load() });
|
|
69
|
+
} catch {
|
|
70
|
+
/* keep the last good value */
|
|
71
|
+
} finally {
|
|
72
|
+
refreshing.delete(key);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return copy(hit.value as T);
|
|
77
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose-stack health answered in-process, from one `docker ps` listing
|
|
3
|
+
* shared by every check that runs at the same time.
|
|
4
|
+
*
|
|
5
|
+
* Den gives a compose repo with no declared check a `<cli> stack health`
|
|
6
|
+
* shell check (composeHealthCmd), one per manifest service. Spawned, each
|
|
7
|
+
* was a full CLI start: 17-19 tsx processes per health cycle for three
|
|
8
|
+
* stacks, which on a loaded machine (load 35-55) ran past their budget and
|
|
9
|
+
* read as down. The checks Den generates itself are now judged here, with
|
|
10
|
+
* the verdict `stack health` gives; a distribution's own shell checks still
|
|
11
|
+
* run as shell commands.
|
|
12
|
+
*/
|
|
13
|
+
import { execFile } from "child_process";
|
|
14
|
+
import type { RawManifest } from "../types/index.js";
|
|
15
|
+
import { manifestStackName } from "./config.js";
|
|
16
|
+
import { composeHealthCmd } from "./manifest-commands.js";
|
|
17
|
+
import { projectName } from "./render.js";
|
|
18
|
+
import { judgeStackRows, type ComposeRow } from "./runtime.js";
|
|
19
|
+
import { resolveStackWorkspaces } from "./status.js";
|
|
20
|
+
|
|
21
|
+
export { judgeStackRows, type ComposeRow };
|
|
22
|
+
|
|
23
|
+
/** A Den-generated compose health check: what to look for in the listing. */
|
|
24
|
+
export interface ComposeHealthTarget {
|
|
25
|
+
/** The Compose project (`<stack>-<stack workspace>`). */
|
|
26
|
+
project: string;
|
|
27
|
+
/** A compose service to check on its own; unset: the whole stack. */
|
|
28
|
+
service?: string;
|
|
29
|
+
/** The equivalent `<cli> stack health` command (shown as the check). */
|
|
30
|
+
cmd: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A health verdict; `ok: null` when docker could not be asked. */
|
|
34
|
+
export interface ComposeVerdict {
|
|
35
|
+
ok: boolean | null;
|
|
36
|
+
detail: string;
|
|
37
|
+
/** With ok null: docker gave no answer within the budget. */
|
|
38
|
+
pending?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The compose health check Den generates for this service, or null. */
|
|
42
|
+
export function composeHealthTarget(
|
|
43
|
+
manifest: RawManifest,
|
|
44
|
+
subsystem: string,
|
|
45
|
+
repoName: string,
|
|
46
|
+
workspace: string,
|
|
47
|
+
): ComposeHealthTarget | null {
|
|
48
|
+
const cmd = composeHealthCmd(manifest, subsystem, repoName, workspace);
|
|
49
|
+
if (!cmd) return null;
|
|
50
|
+
const project = projectName(
|
|
51
|
+
manifestStackName(manifest, repoName),
|
|
52
|
+
resolveStackWorkspaces(workspace).stackWorkspace,
|
|
53
|
+
);
|
|
54
|
+
const service = cmd.includes(" --service ") ? subsystem : undefined;
|
|
55
|
+
return { project, ...(service ? { service } : {}), cmd };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Budget for the shared listing; past it the checks are pending. */
|
|
59
|
+
export const COMPOSE_LISTING_TIMEOUT_MS = 5_000;
|
|
60
|
+
|
|
61
|
+
type Listing = { rows: ComposeRow[] } | { error: string; pending?: boolean };
|
|
62
|
+
|
|
63
|
+
let inFlight: Promise<Listing> | null = null;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Every compose container, from one `docker ps -a`. Concurrent callers share
|
|
67
|
+
* the call in flight, so a health cycle lists once however many checks it has.
|
|
68
|
+
*/
|
|
69
|
+
export function composeListing(): Promise<Listing> {
|
|
70
|
+
return (inFlight ??= listOnce().finally(() => {
|
|
71
|
+
inFlight = null;
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function listOnce(): Promise<Listing> {
|
|
76
|
+
return new Promise((done) => {
|
|
77
|
+
// Backstop: execFile reports only once the pipes close, which a killed
|
|
78
|
+
// process's children can hold open; the checks must not wait on that.
|
|
79
|
+
const guard = setTimeout(
|
|
80
|
+
() =>
|
|
81
|
+
resolve({
|
|
82
|
+
error: `docker gave no answer within ${COMPOSE_LISTING_TIMEOUT_MS / 1000}s`,
|
|
83
|
+
pending: true,
|
|
84
|
+
}),
|
|
85
|
+
COMPOSE_LISTING_TIMEOUT_MS + 500,
|
|
86
|
+
);
|
|
87
|
+
let settled = false;
|
|
88
|
+
const resolve = (l: Listing): void => {
|
|
89
|
+
if (settled) return;
|
|
90
|
+
settled = true;
|
|
91
|
+
clearTimeout(guard);
|
|
92
|
+
done(l);
|
|
93
|
+
};
|
|
94
|
+
execFile(
|
|
95
|
+
"docker",
|
|
96
|
+
[
|
|
97
|
+
"ps",
|
|
98
|
+
"-a",
|
|
99
|
+
"--filter",
|
|
100
|
+
"label=com.docker.compose.project",
|
|
101
|
+
"--format",
|
|
102
|
+
'{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.service"}}\t{{.State}}\t{{.Status}}',
|
|
103
|
+
],
|
|
104
|
+
{
|
|
105
|
+
encoding: "utf-8",
|
|
106
|
+
timeout: COMPOSE_LISTING_TIMEOUT_MS,
|
|
107
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
108
|
+
},
|
|
109
|
+
(err, stdout, stderr) => {
|
|
110
|
+
if (err) {
|
|
111
|
+
const killed = (err as { killed?: boolean }).killed === true;
|
|
112
|
+
return resolve(
|
|
113
|
+
killed
|
|
114
|
+
? {
|
|
115
|
+
error: `docker gave no answer within ${COMPOSE_LISTING_TIMEOUT_MS / 1000}s`,
|
|
116
|
+
pending: true,
|
|
117
|
+
}
|
|
118
|
+
: {
|
|
119
|
+
error:
|
|
120
|
+
String(stderr || err.message).trim() || "docker ps failed",
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const rows = stdout
|
|
125
|
+
.split("\n")
|
|
126
|
+
.filter(Boolean)
|
|
127
|
+
.map((line) => {
|
|
128
|
+
const [project = "", service = "", state = "", status = ""] =
|
|
129
|
+
line.split("\t");
|
|
130
|
+
return { project, service, state, status };
|
|
131
|
+
});
|
|
132
|
+
resolve({ rows });
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The health of one Den-generated compose check, from the shared listing. */
|
|
139
|
+
export async function composeHealth(
|
|
140
|
+
target: ComposeHealthTarget,
|
|
141
|
+
): Promise<ComposeVerdict> {
|
|
142
|
+
const listing = await composeListing();
|
|
143
|
+
if ("error" in listing)
|
|
144
|
+
return {
|
|
145
|
+
ok: null,
|
|
146
|
+
detail: `container state unknown: ${listing.error}`,
|
|
147
|
+
...(listing.pending ? { pending: true } : {}),
|
|
148
|
+
};
|
|
149
|
+
return judgeStackRows(listing.rows, target.project, target.service);
|
|
150
|
+
}
|
|
@@ -364,39 +364,86 @@ export function stackLogs(
|
|
|
364
364
|
|
|
365
365
|
/**
|
|
366
366
|
* Health of a running stack: ok when it has containers, all are running, and
|
|
367
|
-
* none reports a failing or pending Docker healthcheck.
|
|
367
|
+
* none reports a failing or pending Docker healthcheck. `ok: null` when
|
|
368
|
+
* docker could not be asked: container state unknown is not "not running".
|
|
368
369
|
*/
|
|
369
370
|
export function stackHealth(
|
|
370
371
|
ctx: StackContext,
|
|
371
372
|
s: StackConfig,
|
|
372
373
|
service?: string,
|
|
373
|
-
): { ok: boolean; detail: string } {
|
|
374
|
+
): { ok: boolean | null; detail: string } {
|
|
374
375
|
const project = projectName(s.name, ctx.workspace);
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
"
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
376
|
+
let r: ReturnType<typeof run>;
|
|
377
|
+
try {
|
|
378
|
+
r = run("docker", [
|
|
379
|
+
"ps",
|
|
380
|
+
"-a",
|
|
381
|
+
"--filter",
|
|
382
|
+
`label=com.docker.compose.project=${project}`,
|
|
383
|
+
...(service
|
|
384
|
+
? ["--filter", `label=com.docker.compose.service=${service}`]
|
|
385
|
+
: []),
|
|
386
|
+
"--format",
|
|
387
|
+
'{{.Label "com.docker.compose.service"}}\t{{.State}}\t{{.Status}}',
|
|
388
|
+
]);
|
|
389
|
+
} catch (e) {
|
|
390
|
+
return {
|
|
391
|
+
ok: null,
|
|
392
|
+
detail: `container state unknown: ${(e as Error).message}`,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
if (r.code !== 0)
|
|
396
|
+
return {
|
|
397
|
+
ok: null,
|
|
398
|
+
detail: `container state unknown: ${r.err.trim() || "docker ps failed"}`,
|
|
399
|
+
};
|
|
400
|
+
const rows = r.out
|
|
401
|
+
.split("\n")
|
|
402
|
+
.filter(Boolean)
|
|
403
|
+
.map((line) => {
|
|
404
|
+
const [svc = "", state = "", status = ""] = line.split("\t");
|
|
405
|
+
return { project, service: svc, state, status };
|
|
406
|
+
});
|
|
407
|
+
return judgeStackRows(rows, project, service);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** One compose container, as a `docker ps` listing reports it. */
|
|
411
|
+
export interface ComposeRow {
|
|
412
|
+
project: string;
|
|
413
|
+
service: string;
|
|
414
|
+
state: string;
|
|
415
|
+
status: string;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The verdict `stack health` gives for `project` (and `service`): ok when it
|
|
420
|
+
* has containers, all running, none with a failing or starting Docker
|
|
421
|
+
* healthcheck. Shared by the CLI and the in-process check (compose-health).
|
|
422
|
+
*/
|
|
423
|
+
export function judgeStackRows(
|
|
424
|
+
rows: ComposeRow[],
|
|
425
|
+
project: string,
|
|
426
|
+
service?: string,
|
|
427
|
+
): { ok: boolean; detail: string } {
|
|
428
|
+
const mine = rows.filter(
|
|
429
|
+
(r) => r.project === project && (!service || r.service === service),
|
|
430
|
+
);
|
|
431
|
+
if (!mine.length)
|
|
389
432
|
return {
|
|
390
433
|
ok: false,
|
|
391
434
|
detail: `${project}${service ? `/${service}` : ""}: not running`,
|
|
392
435
|
};
|
|
393
|
-
const bad =
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
});
|
|
436
|
+
const bad = mine.filter(
|
|
437
|
+
(r) =>
|
|
438
|
+
r.state !== "running" ||
|
|
439
|
+
/\((unhealthy|health: starting)\)/.test(r.status),
|
|
440
|
+
);
|
|
399
441
|
return bad.length
|
|
400
|
-
? {
|
|
401
|
-
|
|
442
|
+
? {
|
|
443
|
+
ok: false,
|
|
444
|
+
detail: bad
|
|
445
|
+
.map((r) => `${r.service}\t${r.state}\t${r.status}`)
|
|
446
|
+
.join("; "),
|
|
447
|
+
}
|
|
448
|
+
: { ok: true, detail: `${mine.length} container(s) running` };
|
|
402
449
|
}
|
|
@@ -3,13 +3,18 @@
|
|
|
3
3
|
* The CLI prints it; the web server serves it at /api/stacks (and through it
|
|
4
4
|
* the MCP `stacks` tool).
|
|
5
5
|
*/
|
|
6
|
+
import { spawnSync } from "child_process";
|
|
7
|
+
import * as fs from "fs";
|
|
8
|
+
import * as path from "path";
|
|
9
|
+
import { readCatalog } from "../environment-manager/catalog.js";
|
|
6
10
|
import { readEdgeConfig } from "../edge/config.js";
|
|
7
11
|
import { routeUrl } from "../edge/render.js";
|
|
8
12
|
import {
|
|
9
13
|
getFocusedWorkspaceName,
|
|
10
14
|
getWorkspaceByName,
|
|
15
|
+
listWorkspaceServices,
|
|
11
16
|
} from "../environment-manager/workspaces.js";
|
|
12
|
-
import { readStacks } from "./config.js";
|
|
17
|
+
import { readManifestStacks, readStacks } from "./config.js";
|
|
13
18
|
import { projectName, stackWorkspace } from "./render.js";
|
|
14
19
|
import { stackHealth, type StackContext } from "./runtime.js";
|
|
15
20
|
|
|
@@ -29,7 +34,8 @@ export interface StackStatus {
|
|
|
29
34
|
routes: StackRoute[];
|
|
30
35
|
uses: string[];
|
|
31
36
|
/** Container health (only when asked for: it runs `docker ps`). */
|
|
32
|
-
|
|
37
|
+
/** `ok: null`: docker could not be asked, so the state is unknown. */
|
|
38
|
+
health?: { ok: boolean | null; detail: string };
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
const WS_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
@@ -91,9 +97,112 @@ export function stackStatuses(
|
|
|
91
97
|
try {
|
|
92
98
|
status.health = healthOf(ctx, s);
|
|
93
99
|
} catch (e) {
|
|
94
|
-
status.health = { ok:
|
|
100
|
+
status.health = { ok: null, detail: (e as Error).message };
|
|
95
101
|
}
|
|
96
102
|
}
|
|
97
103
|
return status;
|
|
98
104
|
});
|
|
99
105
|
}
|
|
106
|
+
|
|
107
|
+
/** A running Compose project started from one of a workspace's repos. */
|
|
108
|
+
export interface WorkspaceStack {
|
|
109
|
+
/** The Den stack name, or the project name for a stack the repo runs. */
|
|
110
|
+
name: string;
|
|
111
|
+
repo: string;
|
|
112
|
+
project: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A running Compose project and the directory it was started from. */
|
|
116
|
+
export interface RunningProject {
|
|
117
|
+
project: string;
|
|
118
|
+
/** `com.docker.compose.project.working_dir`; "" when unlabelled. */
|
|
119
|
+
workingDir: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The Compose projects with running containers, from one `docker ps`; null
|
|
124
|
+
* when docker can't be asked (not installed, daemon down, too slow).
|
|
125
|
+
*/
|
|
126
|
+
export function runningComposeProjects(): RunningProject[] | null {
|
|
127
|
+
const r = spawnSync(
|
|
128
|
+
"docker",
|
|
129
|
+
[
|
|
130
|
+
"ps",
|
|
131
|
+
"--format",
|
|
132
|
+
'{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.project.working_dir"}}',
|
|
133
|
+
],
|
|
134
|
+
{ encoding: "utf-8", timeout: 5_000, stdio: ["ignore", "pipe", "ignore"] },
|
|
135
|
+
);
|
|
136
|
+
if (r.error || r.status !== 0) return null;
|
|
137
|
+
const seen = new Map<string, RunningProject>();
|
|
138
|
+
for (const line of (r.stdout ?? "").split("\n")) {
|
|
139
|
+
const [project = "", workingDir = ""] = line.trim().split("\t");
|
|
140
|
+
if (project && !seen.has(project))
|
|
141
|
+
seen.set(project, { project, workingDir: workingDir.trim() });
|
|
142
|
+
}
|
|
143
|
+
return [...seen.values()];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `p` and its realpath (compose may record either form). */
|
|
147
|
+
function pathForms(p: string): string[] {
|
|
148
|
+
try {
|
|
149
|
+
const real = fs.realpathSync(p);
|
|
150
|
+
return real === p ? [p] : [p, real];
|
|
151
|
+
} catch {
|
|
152
|
+
return [p];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* `workspace`'s running compose stacks: what `workspace deactivate` leaves
|
|
158
|
+
* up, since it releases only the daemons Den tracks. A project counts when
|
|
159
|
+
* it is a Den stack one of the workspace's repos declares (`compose:`, named
|
|
160
|
+
* by projectName), or when Compose started it from inside one of the
|
|
161
|
+
* workspace's checkouts (its working_dir label): the stacks a repo runs
|
|
162
|
+
* itself, e.g. from `make up`. Null when docker could not be asked.
|
|
163
|
+
*/
|
|
164
|
+
export function runningWorkspaceStacks(
|
|
165
|
+
workspace: string,
|
|
166
|
+
running: () => RunningProject[] | null = runningComposeProjects,
|
|
167
|
+
): WorkspaceStack[] | null {
|
|
168
|
+
const ws = getWorkspaceByName(workspace);
|
|
169
|
+
if (!ws) return [];
|
|
170
|
+
const denStacks = new Map<string, { name: string; repo: string }>();
|
|
171
|
+
const checkouts: { repo: string; forms: string[] }[] = [];
|
|
172
|
+
try {
|
|
173
|
+
const { stackWorkspace: suffix } = resolveStackWorkspaces(workspace);
|
|
174
|
+
for (const s of Object.values(readManifestStacks(workspace)))
|
|
175
|
+
denStacks.set(projectName(s.name, suffix), {
|
|
176
|
+
name: s.name,
|
|
177
|
+
repo: s.repo,
|
|
178
|
+
});
|
|
179
|
+
const seen = new Set<string>();
|
|
180
|
+
for (const svc of listWorkspaceServices(ws.config, readCatalog(), ws.name))
|
|
181
|
+
if (svc.repoPath && !seen.has(svc.repoPath)) {
|
|
182
|
+
seen.add(svc.repoPath);
|
|
183
|
+
checkouts.push({ repo: svc.repoName, forms: pathForms(svc.repoPath) });
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
return null; // an unreadable manifest or workspace name: can't tell
|
|
187
|
+
}
|
|
188
|
+
if (denStacks.size === 0 && checkouts.length === 0) return [];
|
|
189
|
+
const up = running();
|
|
190
|
+
if (!up) return null;
|
|
191
|
+
const inside = (dir: string, root: string): boolean =>
|
|
192
|
+
dir === root || dir.startsWith(`${root}${path.sep}`);
|
|
193
|
+
const out: WorkspaceStack[] = [];
|
|
194
|
+
for (const { project, workingDir } of up) {
|
|
195
|
+
const den = denStacks.get(project);
|
|
196
|
+
if (den) {
|
|
197
|
+
out.push({ ...den, project });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (!workingDir) continue;
|
|
201
|
+
const dirs = pathForms(workingDir);
|
|
202
|
+
const owner = checkouts.find((c) =>
|
|
203
|
+
c.forms.some((root) => dirs.some((d) => inside(d, root))),
|
|
204
|
+
);
|
|
205
|
+
if (owner) out.push({ name: project, repo: owner.repo, project });
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
@@ -13,6 +13,7 @@ export function healthLabelText(health: HealthState): string {
|
|
|
13
13
|
export function formatHealthLabel(health: HealthState): string {
|
|
14
14
|
if (health === "ok") return chalk.green("● ok");
|
|
15
15
|
if (health === "error") return chalk.red("● down");
|
|
16
|
+
if (health === "pending") return chalk.gray("● pending");
|
|
16
17
|
return chalk.gray("● unknown");
|
|
17
18
|
}
|
|
18
19
|
|