@monster-orange/den 0.2.5 → 0.2.7
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 +215 -0
- package/bin/den +9 -0
- package/docs/distributions.md +202 -22
- package/package.json +1 -1
- package/service/install.sh +41 -2
- package/src/pillars/security/discover.ts +1 -1
- package/src/pillars/smoke/setup-global.ts +2 -1
- package/src/scripts/configure-aws-profiles.ts +2 -1
- package/src/scripts/register-repos.ts +4 -3
- package/src/shared/den-config.ts +38 -1
- package/src/shared/environment-manager/catalog.ts +9 -2
- package/src/shared/environment-manager/default-workspace.ts +92 -14
- package/src/shared/environment-manager/health.ts +96 -17
- package/src/shared/environment-manager/service-runner.ts +51 -5
- package/src/shared/environment-manager/workspace-manager.ts +2 -1
- package/src/shared/environment-manager/workspace-org.ts +264 -0
- package/src/shared/manifest/placement.ts +1 -1
- package/src/shared/manifest/schema.ts +47 -0
- package/src/shared/manifest/service-links.ts +1 -1
- package/src/shared/manifest/validate-manifest.ts +1 -1
- package/src/shared/plural.ts +13 -0
- package/src/shared/service-url.ts +2 -5
- package/src/shared/stacks/compose-health.ts +106 -14
- package/src/shared/stacks/runtime.ts +2 -0
- package/src/shared/types/index.ts +20 -1
- package/src/ui/cli/clone-plan.ts +243 -0
- package/src/ui/cli/create.ts +2 -1
- package/src/ui/cli/flag-parser.ts +1 -1
- package/src/ui/cli/index.ts +274 -61
- package/src/ui/cli/mcp-connect.ts +36 -5
- package/src/ui/cli/org-command.ts +162 -0
- package/src/ui/cli/results-viewer.ts +5 -5
- package/src/ui/cli/server-identity.ts +233 -0
- package/src/ui/cli/service-command.ts +136 -12
- package/src/ui/cli/spinner.ts +48 -0
- package/src/ui/cli/table.ts +20 -0
- package/src/ui/cli/validate-all.ts +171 -0
- package/src/ui/cli/web-command.ts +76 -25
- package/src/ui/mcp/api.ts +94 -1
- package/src/ui/mcp/server.ts +30 -2
- package/src/ui/mcp/tools.ts +6 -2
- package/src/ui/server/daemon-reaper.ts +22 -4
- package/src/ui/server/health-row.ts +6 -3
- package/src/ui/server/index.ts +63 -18
- package/src/ui/server/release-service.ts +16 -3
- package/src/ui/server/workspace-routes.ts +4 -1
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which work org a workspace belongs to (ORG-1a, ORG-SPEC §2).
|
|
3
|
+
*
|
|
4
|
+
* An org is a key of `orgs:` or a GitHub owner. ORG-1a keeps today's model:
|
|
5
|
+
* an org with no `sources` is the GitHub owner of the same name, and
|
|
6
|
+
* `github_org` is the default org. A workspace's org resolves through this
|
|
7
|
+
* chain, first match wins:
|
|
8
|
+
*
|
|
9
|
+
* 1. `org:` on the workspace in workspaces.yml (per machine)
|
|
10
|
+
* 2. `org:` on the same-named blueprint workspace (`defaults.workspaces`)
|
|
11
|
+
* 3. the single non-empty catalog owner of its repos (case-insensitive)
|
|
12
|
+
* 4. `github_org`
|
|
13
|
+
*
|
|
14
|
+
* Computed on read and never written back to workspaces.yml, so an inferred
|
|
15
|
+
* org never looks like an explicit one. Every rule broken here is a warning,
|
|
16
|
+
* never an error (C1): an old config resolves as before, to `github_org`.
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
readDenConfig,
|
|
20
|
+
readWorkspaceBlueprints,
|
|
21
|
+
requireGithubOrg,
|
|
22
|
+
type DenConfig,
|
|
23
|
+
type ResolvedWorkspaceBlueprint,
|
|
24
|
+
} from "../den-config.js";
|
|
25
|
+
import { readCatalog } from "./catalog.js";
|
|
26
|
+
import { parseAddress, readWorkspacesFile } from "./workspaces.js";
|
|
27
|
+
import { orgOf } from "../manifest/service-links.js";
|
|
28
|
+
import { cachedRead } from "../read-cache.js";
|
|
29
|
+
import type {
|
|
30
|
+
ServiceCatalog,
|
|
31
|
+
WorkspaceConfig,
|
|
32
|
+
WorkspacesFile,
|
|
33
|
+
} from "../types/index.js";
|
|
34
|
+
|
|
35
|
+
/** Which step of the chain gave the org. */
|
|
36
|
+
export type OrgSource = "workspace" | "blueprint" | "catalog" | "default";
|
|
37
|
+
|
|
38
|
+
export interface WorkspaceOrg {
|
|
39
|
+
workspace: string;
|
|
40
|
+
/** The resolved org id (the `orgs:` key's spelling when one matches), or null. */
|
|
41
|
+
org: string | null;
|
|
42
|
+
source: OrgSource | null;
|
|
43
|
+
/** Each broken org rule, as a sentence (C1: warnings, never errors). */
|
|
44
|
+
warnings: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface OrgContext {
|
|
48
|
+
config: DenConfig;
|
|
49
|
+
catalog: ServiceCatalog;
|
|
50
|
+
file: WorkspacesFile;
|
|
51
|
+
blueprints: ResolvedWorkspaceBlueprint[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const lower = (s: string): string => s.toLowerCase();
|
|
55
|
+
|
|
56
|
+
/** The current config, catalog, workspaces.yml and blueprint. */
|
|
57
|
+
export function readOrgContext(): OrgContext {
|
|
58
|
+
const config = readDenConfig();
|
|
59
|
+
let blueprints: ResolvedWorkspaceBlueprint[] = [];
|
|
60
|
+
try {
|
|
61
|
+
blueprints = readWorkspaceBlueprints(config).workspaces;
|
|
62
|
+
} catch {
|
|
63
|
+
// A malformed blueprint is reported where seeding reads it; it must not
|
|
64
|
+
// turn an org listing into a failure (C1).
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
config,
|
|
68
|
+
catalog: readCatalog(),
|
|
69
|
+
file: readWorkspacesFile(),
|
|
70
|
+
blueprints,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `id` spelled as its `orgs:` key when one matches case-insensitively. */
|
|
75
|
+
export function canonicalOrg(id: string, config: DenConfig): string {
|
|
76
|
+
const key = Object.keys(config.orgs ?? {}).find(
|
|
77
|
+
(k) => lower(k) === lower(id),
|
|
78
|
+
);
|
|
79
|
+
return key ?? id;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The default org: `github_org`, or null when unset. */
|
|
83
|
+
export function defaultOrg(config: DenConfig): string | null {
|
|
84
|
+
const d = config.github_org?.trim();
|
|
85
|
+
return d ? canonicalOrg(d, config) : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The repos a workspace holds; a workspace not set up yet holds its blueprint's. */
|
|
89
|
+
function workspaceRepos(
|
|
90
|
+
ws: WorkspaceConfig | undefined,
|
|
91
|
+
bp: ResolvedWorkspaceBlueprint | undefined,
|
|
92
|
+
catalog: ServiceCatalog,
|
|
93
|
+
): string[] {
|
|
94
|
+
const held = [
|
|
95
|
+
...Object.keys(ws?.services ?? {}).map(
|
|
96
|
+
(k) => parseAddress(k, catalog).repoName,
|
|
97
|
+
),
|
|
98
|
+
...Object.keys(ws?.repos ?? {}),
|
|
99
|
+
];
|
|
100
|
+
return [...new Set(held.length > 0 ? held : (bp?.repos ?? []))];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Owners Den knows of: `orgs:` keys, catalog owners and `github_org`. */
|
|
104
|
+
function knownOrgs(ctx: OrgContext): Set<string> {
|
|
105
|
+
const ids = new Set(Object.keys(ctx.config.orgs ?? {}).map(lower));
|
|
106
|
+
for (const repo of Object.keys(ctx.catalog)) {
|
|
107
|
+
const o = orgOf(repo, ctx.catalog);
|
|
108
|
+
if (o) ids.add(lower(o));
|
|
109
|
+
}
|
|
110
|
+
const d = ctx.config.github_org?.trim();
|
|
111
|
+
if (d) ids.add(lower(d));
|
|
112
|
+
return ids;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Resolve one workspace's org through the chain. */
|
|
116
|
+
export function resolveWorkspaceOrg(
|
|
117
|
+
name: string,
|
|
118
|
+
ctx: OrgContext,
|
|
119
|
+
): WorkspaceOrg {
|
|
120
|
+
const warnings: string[] = [];
|
|
121
|
+
const ws = ctx.file.workspaces[name];
|
|
122
|
+
const bp = ctx.blueprints.find((b) => b.name === name);
|
|
123
|
+
const rawWsOrg = ws?.org as unknown;
|
|
124
|
+
const wsOrg =
|
|
125
|
+
typeof rawWsOrg === "string" && rawWsOrg.trim()
|
|
126
|
+
? rawWsOrg.trim()
|
|
127
|
+
: undefined;
|
|
128
|
+
if (rawWsOrg !== undefined && rawWsOrg !== null && !wsOrg)
|
|
129
|
+
warnings.push(
|
|
130
|
+
`workspace ${name}: org in workspaces.yml is not an org id; ignored`,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const repos = workspaceRepos(ws, bp, ctx.catalog);
|
|
134
|
+
const owners = new Map<string, string>(); // lowercased → first spelling
|
|
135
|
+
for (const repo of repos) {
|
|
136
|
+
const o = orgOf(repo, ctx.catalog);
|
|
137
|
+
if (o && !owners.has(lower(o))) owners.set(lower(o), o);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let org: string | null = null;
|
|
141
|
+
let source: OrgSource | null = null;
|
|
142
|
+
if (wsOrg) {
|
|
143
|
+
org = wsOrg;
|
|
144
|
+
source = "workspace";
|
|
145
|
+
if (bp?.org && lower(bp.org) !== lower(wsOrg))
|
|
146
|
+
warnings.push(
|
|
147
|
+
`workspace ${name}: org ${wsOrg} in workspaces.yml overrides the blueprint's org ${bp.org}`,
|
|
148
|
+
);
|
|
149
|
+
} else if (bp?.org) {
|
|
150
|
+
org = bp.org;
|
|
151
|
+
source = "blueprint";
|
|
152
|
+
} else if (owners.size === 1) {
|
|
153
|
+
org = [...owners.values()][0];
|
|
154
|
+
source = "catalog";
|
|
155
|
+
} else {
|
|
156
|
+
org = ctx.config.github_org?.trim() || null;
|
|
157
|
+
source = org ? "default" : null;
|
|
158
|
+
if (owners.size > 1)
|
|
159
|
+
warnings.push(
|
|
160
|
+
`workspace ${name}: its repos belong to several owners (${[...owners.values()].join(", ")}); ` +
|
|
161
|
+
`using ${org ?? "no org"}. Set org: on the workspace to choose one`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (org) org = canonicalOrg(org, ctx.config);
|
|
165
|
+
|
|
166
|
+
if (
|
|
167
|
+
org &&
|
|
168
|
+
(source === "workspace" || source === "blueprint") &&
|
|
169
|
+
!knownOrgs(ctx).has(lower(org))
|
|
170
|
+
)
|
|
171
|
+
warnings.push(
|
|
172
|
+
`workspace ${name}: org ${org} is not in orgs:, the catalog or github_org`,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// A repo in another owner than its workspace's org. A mixed workspace with
|
|
176
|
+
// no org of its own already said so above.
|
|
177
|
+
if (
|
|
178
|
+
org &&
|
|
179
|
+
(owners.size <= 1 || source === "workspace" || source === "blueprint")
|
|
180
|
+
)
|
|
181
|
+
for (const repo of repos) {
|
|
182
|
+
const o = orgOf(repo, ctx.catalog);
|
|
183
|
+
if (o && lower(o) !== lower(org))
|
|
184
|
+
warnings.push(
|
|
185
|
+
`workspace ${name}: repo ${repo} belongs to ${o}, not the workspace's org ${org}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { workspace: name, org, source, warnings };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Every workspace on this machine, then blueprint workspaces not set up yet. */
|
|
193
|
+
function workspaceNames(ctx: OrgContext): string[] {
|
|
194
|
+
return [
|
|
195
|
+
...new Set([
|
|
196
|
+
...Object.keys(ctx.file.workspaces),
|
|
197
|
+
...ctx.blueprints.map((b) => b.name),
|
|
198
|
+
]),
|
|
199
|
+
];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Every workspace's resolved org (cached for the web server's read TTL). */
|
|
203
|
+
export function resolveAllWorkspaceOrgs(ctx?: OrgContext): WorkspaceOrg[] {
|
|
204
|
+
const compute = (c: OrgContext) =>
|
|
205
|
+
workspaceNames(c).map((n) => resolveWorkspaceOrg(n, c));
|
|
206
|
+
if (ctx) return compute(ctx);
|
|
207
|
+
return cachedRead("workspace-orgs", () => compute(readOrgContext()));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The GitHub owner to clone and register a workspace's repos under: its
|
|
212
|
+
* resolved org. With none at all (no org anywhere and no `github_org`) this
|
|
213
|
+
* throws exactly as requireGithubOrg does, so an old config fails as before.
|
|
214
|
+
*/
|
|
215
|
+
export function githubOrgFor(workspace: string, ctx?: OrgContext): string {
|
|
216
|
+
const hit = ctx
|
|
217
|
+
? resolveWorkspaceOrg(workspace, ctx)
|
|
218
|
+
: (resolveAllWorkspaceOrgs().find((w) => w.workspace === workspace) ??
|
|
219
|
+
resolveWorkspaceOrg(workspace, readOrgContext()));
|
|
220
|
+
if (hit.org) return hit.org;
|
|
221
|
+
return requireGithubOrg(ctx?.config);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface OrgEntry {
|
|
225
|
+
id: string;
|
|
226
|
+
label: string;
|
|
227
|
+
color?: string;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The org listing with `default` and `workspaces` added to each entry and
|
|
232
|
+
* nothing else changed (C3): same entries, same order, same fields. Only
|
|
233
|
+
* workspaces on this machine (workspaces.yml) are listed.
|
|
234
|
+
*/
|
|
235
|
+
export function withOrgMembership<T extends OrgEntry>(
|
|
236
|
+
orgs: T[],
|
|
237
|
+
resolved: WorkspaceOrg[],
|
|
238
|
+
file: WorkspacesFile,
|
|
239
|
+
config: DenConfig,
|
|
240
|
+
): Array<T & { default: boolean; workspaces: string[] }> {
|
|
241
|
+
// The listing can hold one owner twice in different case (catalog owners
|
|
242
|
+
// are listed as spelled); an org lands on the entry spelled exactly like
|
|
243
|
+
// it, else the first that matches case-insensitively.
|
|
244
|
+
const entryFor = (id: string): number => {
|
|
245
|
+
const exact = orgs.findIndex((o) => o.id === id);
|
|
246
|
+
return exact !== -1
|
|
247
|
+
? exact
|
|
248
|
+
: orgs.findIndex((o) => lower(o.id) === lower(id));
|
|
249
|
+
};
|
|
250
|
+
const d = defaultOrg(config);
|
|
251
|
+
const defaultAt = d === null ? -1 : entryFor(d);
|
|
252
|
+
const members = orgs.map((): string[] => []);
|
|
253
|
+
const local = new Set(Object.keys(file.workspaces));
|
|
254
|
+
for (const w of resolved) {
|
|
255
|
+
if (!w.org || !local.has(w.workspace)) continue;
|
|
256
|
+
const at = entryFor(w.org);
|
|
257
|
+
if (at !== -1) members[at].push(w.workspace);
|
|
258
|
+
}
|
|
259
|
+
return orgs.map((o, i) => ({
|
|
260
|
+
...o,
|
|
261
|
+
default: i === defaultAt,
|
|
262
|
+
workspaces: members[i],
|
|
263
|
+
}));
|
|
264
|
+
}
|
|
@@ -52,7 +52,7 @@ export type IsProduction = (env: string) => boolean;
|
|
|
52
52
|
const LOCAL = "local";
|
|
53
53
|
|
|
54
54
|
function isHttp(h: ServiceHealthConfig | undefined): h is { url: string } {
|
|
55
|
-
return !!h &&
|
|
55
|
+
return !!h && "url" in h && typeof h.url === "string";
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
function hasCommands(
|
|
@@ -32,7 +32,54 @@ const commandDef = z.union([
|
|
|
32
32
|
// { url: "http://..." } — implicit HTTP
|
|
33
33
|
// { type: "http", url: "http://..." } — explicit HTTP
|
|
34
34
|
// { type: "shell", cmd: "pg_isready ..." } — shell
|
|
35
|
+
// { type: "container", project, service } — a compose container
|
|
36
|
+
// { type: "container", container_name } — a container by name
|
|
37
|
+
const CONTAINER_HEALTH_KEYS = new Set([
|
|
38
|
+
"type",
|
|
39
|
+
"project",
|
|
40
|
+
"service",
|
|
41
|
+
"container_name",
|
|
42
|
+
"label",
|
|
43
|
+
]);
|
|
44
|
+
export const CONTAINER_HEALTH_SHAPE =
|
|
45
|
+
'container checks need "project" and "service" (compose labels), or "container_name"';
|
|
46
|
+
const containerHealth = z
|
|
47
|
+
.object({
|
|
48
|
+
type: z.literal("container"),
|
|
49
|
+
project: z.string().min(1).optional(),
|
|
50
|
+
service: z.string().min(1).optional(),
|
|
51
|
+
container_name: z.string().min(1).optional(),
|
|
52
|
+
label: z.string().optional(),
|
|
53
|
+
})
|
|
54
|
+
.passthrough()
|
|
55
|
+
.superRefine((h, ctx) => {
|
|
56
|
+
const unknown = Object.keys(h).filter((k) => !CONTAINER_HEALTH_KEYS.has(k));
|
|
57
|
+
if (unknown.length)
|
|
58
|
+
ctx.addIssue({
|
|
59
|
+
code: z.ZodIssueCode.custom,
|
|
60
|
+
message: `unknown key${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")} in a container check: ${CONTAINER_HEALTH_SHAPE}`,
|
|
61
|
+
});
|
|
62
|
+
const byLabels = h.project !== undefined || h.service !== undefined;
|
|
63
|
+
const byName = h.container_name !== undefined;
|
|
64
|
+
if (byLabels && byName)
|
|
65
|
+
ctx.addIssue({
|
|
66
|
+
code: z.ZodIssueCode.custom,
|
|
67
|
+
message: `a container check matches by "project"/"service" or by "container_name", not both`,
|
|
68
|
+
});
|
|
69
|
+
else if (byLabels && (h.project === undefined || h.service === undefined))
|
|
70
|
+
ctx.addIssue({
|
|
71
|
+
code: z.ZodIssueCode.custom,
|
|
72
|
+
message: `a container check by compose labels needs both "project" and "service"`,
|
|
73
|
+
});
|
|
74
|
+
else if (!byLabels && !byName)
|
|
75
|
+
ctx.addIssue({
|
|
76
|
+
code: z.ZodIssueCode.custom,
|
|
77
|
+
message: CONTAINER_HEALTH_SHAPE,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
35
81
|
const healthConfig = z.union([
|
|
82
|
+
containerHealth,
|
|
36
83
|
// Shell health check — must have cmd
|
|
37
84
|
z.object({
|
|
38
85
|
type: z.literal("shell"),
|
|
@@ -47,7 +47,7 @@ export function openUrlFor(
|
|
|
47
47
|
const declared = httpUrl(svc.urls?.[env]);
|
|
48
48
|
if (declared) return declared;
|
|
49
49
|
const health = svc.health?.[env];
|
|
50
|
-
if (!health || ("
|
|
50
|
+
if (!health || !("url" in health)) return null;
|
|
51
51
|
return httpOrigin((health as { url?: string }).url);
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -57,7 +57,7 @@ function suggestionFor(issue: z.ZodIssue): string | undefined {
|
|
|
57
57
|
|
|
58
58
|
// health with missing url/cmd
|
|
59
59
|
if (pathStr.includes("health")) {
|
|
60
|
-
return 'HTTP checks need "url"; shell checks need "type: shell" and "cmd"';
|
|
60
|
+
return 'HTTP checks need "url"; shell checks need "type: shell" and "cmd"; container checks need "type: container" and "project" + "service", or "container_name"';
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
return undefined;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Counts in output: "1 repo", "3 repos", never "1 repos" or "repo(s)".
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** `word` in the form `n` takes: "repo" for 1, "repos" (or `many`) otherwise. */
|
|
6
|
+
export function noun(n: number, word: string, many = `${word}s`): string {
|
|
7
|
+
return n === 1 ? word : many;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** `n` and its noun: plural(1, "repo") → "1 repo", plural(2, "repo") → "2 repos". */
|
|
11
|
+
export function plural(n: number, word: string, many?: string): string {
|
|
12
|
+
return `${n} ${noun(n, word, many)}`;
|
|
13
|
+
}
|
|
@@ -24,11 +24,8 @@ function healthUrlForConfig(healthConfig?: ServiceHealthConfig): string | null {
|
|
|
24
24
|
return null;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
return healthConfig.url;
|
|
27
|
+
// Shell and container checks have no URL.
|
|
28
|
+
return "url" in healthConfig ? healthConfig.url : null;
|
|
32
29
|
}
|
|
33
30
|
|
|
34
31
|
function normalizeServiceBaseUrl(
|
|
@@ -58,13 +58,19 @@ export function composeHealthTarget(
|
|
|
58
58
|
/** Budget for the shared listing; past it the checks are pending. */
|
|
59
59
|
export const COMPOSE_LISTING_TIMEOUT_MS = 5_000;
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
/** The shared listing: every container, or why docker could not answer. */
|
|
62
|
+
export type ComposeListing =
|
|
63
|
+
| { rows: ComposeRow[] }
|
|
64
|
+
| { error: string; pending?: boolean };
|
|
65
|
+
type Listing = ComposeListing;
|
|
62
66
|
|
|
63
67
|
let inFlight: Promise<Listing> | null = null;
|
|
64
68
|
|
|
65
69
|
/**
|
|
66
|
-
* Every
|
|
67
|
-
*
|
|
70
|
+
* Every container, from one `docker ps -a`. Concurrent callers share the
|
|
71
|
+
* call in flight, so a health cycle lists once however many checks it has
|
|
72
|
+
* (compose checks and `type: container` checks alike). Not filtered to
|
|
73
|
+
* compose containers: a container check may name a plain container.
|
|
68
74
|
*/
|
|
69
75
|
export function composeListing(): Promise<Listing> {
|
|
70
76
|
return (inFlight ??= listOnce().finally(() => {
|
|
@@ -96,10 +102,8 @@ function listOnce(): Promise<Listing> {
|
|
|
96
102
|
[
|
|
97
103
|
"ps",
|
|
98
104
|
"-a",
|
|
99
|
-
"--filter",
|
|
100
|
-
"label=com.docker.compose.project",
|
|
101
105
|
"--format",
|
|
102
|
-
'{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.service"}}\t{{.State}}\t{{.Status}}',
|
|
106
|
+
'{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.service"}}\t{{.State}}\t{{.Status}}\t{{.Names}}',
|
|
103
107
|
],
|
|
104
108
|
{
|
|
105
109
|
encoding: "utf-8",
|
|
@@ -125,9 +129,14 @@ function listOnce(): Promise<Listing> {
|
|
|
125
129
|
.split("\n")
|
|
126
130
|
.filter(Boolean)
|
|
127
131
|
.map((line) => {
|
|
128
|
-
const [
|
|
129
|
-
|
|
130
|
-
|
|
132
|
+
const [
|
|
133
|
+
project = "",
|
|
134
|
+
service = "",
|
|
135
|
+
state = "",
|
|
136
|
+
status = "",
|
|
137
|
+
name = "",
|
|
138
|
+
] = line.split("\t");
|
|
139
|
+
return { project, service, state, status, name };
|
|
131
140
|
});
|
|
132
141
|
resolve({ rows });
|
|
133
142
|
},
|
|
@@ -135,16 +144,99 @@ function listOnce(): Promise<Listing> {
|
|
|
135
144
|
});
|
|
136
145
|
}
|
|
137
146
|
|
|
147
|
+
/**
|
|
148
|
+
* The verdict when docker gave no listing, shared by compose and container
|
|
149
|
+
* checks so the two cannot drift: container state unknown is not "not
|
|
150
|
+
* running" (`ok: null`, never down, however often it repeats). Only a
|
|
151
|
+
* listing past its budget is `pending` (no answer in time), which, like any
|
|
152
|
+
* timeout, is down when it repeats on the next cycle.
|
|
153
|
+
*/
|
|
154
|
+
export function unknownListingVerdict(listing: {
|
|
155
|
+
error: string;
|
|
156
|
+
pending?: boolean;
|
|
157
|
+
}): ComposeVerdict {
|
|
158
|
+
return {
|
|
159
|
+
ok: null,
|
|
160
|
+
detail: `container state unknown: ${listing.error}`,
|
|
161
|
+
...(listing.pending ? { pending: true } : {}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
138
165
|
/** The health of one Den-generated compose check, from the shared listing. */
|
|
139
166
|
export async function composeHealth(
|
|
140
167
|
target: ComposeHealthTarget,
|
|
141
168
|
): Promise<ComposeVerdict> {
|
|
142
169
|
const listing = await composeListing();
|
|
143
|
-
if ("error" in listing)
|
|
170
|
+
if ("error" in listing) return unknownListingVerdict(listing);
|
|
171
|
+
return judgeStackRows(listing.rows, target.project, target.service);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── `health: { type: container }` ───────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A declared container check: a compose container by its project and service
|
|
178
|
+
* labels, or any container by name (`container_name`).
|
|
179
|
+
*/
|
|
180
|
+
export type ContainerHealthTarget =
|
|
181
|
+
| { project: string; service: string; container_name?: undefined }
|
|
182
|
+
| { container_name: string; project?: undefined; service?: undefined };
|
|
183
|
+
|
|
184
|
+
/** What a container check shows as its check: `container <project>/<service>`. */
|
|
185
|
+
export function containerCheckLabel(t: ContainerHealthTarget): string {
|
|
186
|
+
return `container ${containerTargetName(t)}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function containerTargetName(t: ContainerHealthTarget): string {
|
|
190
|
+
return t.container_name ?? `${t.project}/${t.service}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function matchesTarget(r: ComposeRow, t: ContainerHealthTarget): boolean {
|
|
194
|
+
if (t.container_name)
|
|
195
|
+
return (r.name ?? "")
|
|
196
|
+
.split(",")
|
|
197
|
+
.map((n) => n.trim().replace(/^\//, ""))
|
|
198
|
+
.includes(t.container_name);
|
|
199
|
+
return r.project === t.project && r.service === t.service;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** One container's verdict: its healthcheck when it has one, else running. */
|
|
203
|
+
function rowVerdict(r: ComposeRow): ComposeVerdict & { rank: number } {
|
|
204
|
+
const what = `${r.name || `${r.project}/${r.service}`}\t${r.state}\t${r.status}`;
|
|
205
|
+
if (r.state !== "running") return { ok: false, detail: what, rank: 4 };
|
|
206
|
+
if (/\(unhealthy\)/.test(r.status))
|
|
207
|
+
return { ok: false, detail: what, rank: 3 };
|
|
208
|
+
// Starting is pending, but not "no answer": it never turns down on repeat.
|
|
209
|
+
if (/\(health: starting\)/.test(r.status))
|
|
210
|
+
return { ok: null, detail: what, rank: 2 };
|
|
211
|
+
if (/\(healthy\)/.test(r.status)) return { ok: true, detail: what, rank: 0 };
|
|
212
|
+
return { ok: true, detail: what, rank: 1 };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The verdict of a container check over the shared listing. More than one
|
|
217
|
+
* match: the best one (healthy, then running). No match: down. No listing:
|
|
218
|
+
* as a compose check (unknownListingVerdict), the reason in `detail`.
|
|
219
|
+
*/
|
|
220
|
+
export function judgeContainerListing(
|
|
221
|
+
listing: ComposeListing,
|
|
222
|
+
target: ContainerHealthTarget,
|
|
223
|
+
): ComposeVerdict {
|
|
224
|
+
if ("error" in listing) return unknownListingVerdict(listing);
|
|
225
|
+
const best = listing.rows
|
|
226
|
+
.filter((r) => matchesTarget(r, target))
|
|
227
|
+
.map(rowVerdict)
|
|
228
|
+
.sort((a, b) => a.rank - b.rank)[0];
|
|
229
|
+
if (!best)
|
|
144
230
|
return {
|
|
145
|
-
ok:
|
|
146
|
-
detail:
|
|
147
|
-
...(listing.pending ? { pending: true } : {}),
|
|
231
|
+
ok: false,
|
|
232
|
+
detail: `${containerTargetName(target)}: no container`,
|
|
148
233
|
};
|
|
149
|
-
return
|
|
234
|
+
return { ok: best.ok, detail: best.detail };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The health of a declared container check, from the shared listing. */
|
|
238
|
+
export async function containerHealth(
|
|
239
|
+
target: ContainerHealthTarget,
|
|
240
|
+
): Promise<ComposeVerdict> {
|
|
241
|
+
return judgeContainerListing(await composeListing(), target);
|
|
150
242
|
}
|
|
@@ -210,10 +210,22 @@ export type LogSource =
|
|
|
210
210
|
* type: shell
|
|
211
211
|
* cmd: "docker exec $(docker ps --filter ancestor=postgres:16 --format '{{.ID}}' | head -1) pg_isready -U postgres"
|
|
212
212
|
* label: "postgres" # optional — shown in UI instead of cmd
|
|
213
|
+
*
|
|
214
|
+
* Container (answered from the one `docker ps` a health cycle shares):
|
|
215
|
+
* type: container
|
|
216
|
+
* project: shop-main # com.docker.compose.project label
|
|
217
|
+
* service: db # com.docker.compose.service label
|
|
218
|
+
* -- or, for a container not run by compose --
|
|
219
|
+
* type: container
|
|
220
|
+
* container_name: shop-db
|
|
213
221
|
*/
|
|
214
222
|
export type ServiceHealthConfig =
|
|
215
223
|
| { type?: "http"; url: string; label?: string }
|
|
216
|
-
| { type: "shell"; cmd: string; label?: string }
|
|
224
|
+
| { type: "shell"; cmd: string; label?: string }
|
|
225
|
+
| ({ type: "container"; label?: string } & (
|
|
226
|
+
| { project: string; service: string; container_name?: undefined }
|
|
227
|
+
| { container_name: string; project?: undefined; service?: undefined }
|
|
228
|
+
));
|
|
217
229
|
|
|
218
230
|
/**
|
|
219
231
|
* A command can be a plain shell string (detached by default) or an object
|
|
@@ -674,6 +686,13 @@ export interface WorkspaceConfig {
|
|
|
674
686
|
* newly JOIN the blueprint are added.
|
|
675
687
|
*/
|
|
676
688
|
seeded_repos?: string[];
|
|
689
|
+
/**
|
|
690
|
+
* The org this workspace belongs to (ORG-1a), set per machine. Wins over
|
|
691
|
+
* the blueprint's `org:`; when neither is set the org is inferred from the
|
|
692
|
+
* repos' catalog owners, then `github_org`. Never written by Den: the
|
|
693
|
+
* resolved org is computed on read (see workspace-org.ts).
|
|
694
|
+
*/
|
|
695
|
+
org?: string;
|
|
677
696
|
}
|
|
678
697
|
|
|
679
698
|
export interface WorkspacesFile {
|