@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
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from "../shared/environment-manager/default-workspace.js";
|
|
30
30
|
|
|
31
31
|
import { product } from "../shared/product.js";
|
|
32
|
+
import { plural } from "../shared/plural.js";
|
|
32
33
|
// Anchored to the den root, NOT process.cwd() — this script WRITES
|
|
33
34
|
// config/local/* and a cwd-derived root once put den files in another repo.
|
|
34
35
|
const ROOT = getDistRoot();
|
|
@@ -179,7 +180,7 @@ async function legacyScanAndSelect(): Promise<void> {
|
|
|
179
180
|
fs.readFileSync(REGISTERED_FILE, "utf-8"),
|
|
180
181
|
) as string[];
|
|
181
182
|
console.log(
|
|
182
|
-
` Current: ${existing.length
|
|
183
|
+
` Current: ${plural(existing.length, "repo")} registered for testing\n`,
|
|
183
184
|
);
|
|
184
185
|
} catch {
|
|
185
186
|
/* corrupt file — start fresh */
|
|
@@ -223,7 +224,7 @@ async function legacyScanAndSelect(): Promise<void> {
|
|
|
223
224
|
fs.writeFileSync(REGISTERED_FILE, JSON.stringify(selected, null, 2) + "\n");
|
|
224
225
|
|
|
225
226
|
console.log(
|
|
226
|
-
`\n Registered ${selected.length
|
|
227
|
+
`\n Registered ${plural(selected.length, "repo")} → config/local/registered-repos.json`,
|
|
227
228
|
);
|
|
228
229
|
console.log(" Run `make register-repos` again at any time to update.\n");
|
|
229
230
|
}
|
|
@@ -282,7 +283,7 @@ async function main(): Promise<void> {
|
|
|
282
283
|
console.log(
|
|
283
284
|
" Local Dev is enabled but no workspace exists yet. Your first" +
|
|
284
285
|
` \`${product().cli}\` command\n seeds the default workspace with every standard` +
|
|
285
|
-
` system repo, all managed\n out of the box (${plan.repos.length
|
|
286
|
+
` system repo, all managed\n out of the box (${plural(plan.repos.length, "repo")}):\n`,
|
|
286
287
|
);
|
|
287
288
|
console.log(` ${plan.repos.join(", ")}\n`);
|
|
288
289
|
console.log(
|
package/src/shared/den-config.ts
CHANGED
|
@@ -636,6 +636,8 @@ export interface ResolvedWorkspaceBlueprint {
|
|
|
636
636
|
branch: string;
|
|
637
637
|
autostartOff: string[];
|
|
638
638
|
focus: boolean;
|
|
639
|
+
/** `org:` (ORG-1a): the org this workspace belongs to, when the blueprint names one. */
|
|
640
|
+
org?: string;
|
|
639
641
|
}
|
|
640
642
|
|
|
641
643
|
/** Which blueprint shape the distribution uses, and its workspaces. */
|
|
@@ -644,7 +646,7 @@ export interface WorkspaceBlueprints {
|
|
|
644
646
|
workspaces: ResolvedWorkspaceBlueprint[];
|
|
645
647
|
}
|
|
646
648
|
|
|
647
|
-
const BLUEPRINT_KEYS = ["branch", "repos", "autostart_off"];
|
|
649
|
+
const BLUEPRINT_KEYS = ["branch", "repos", "autostart_off", "org"];
|
|
648
650
|
|
|
649
651
|
function blueprintStrings(where: string, key: string, v: unknown): string[] {
|
|
650
652
|
if (v === undefined) return [];
|
|
@@ -674,12 +676,15 @@ function resolveBlueprint(
|
|
|
674
676
|
throw new Error(`${where}.branch must be a branch name`);
|
|
675
677
|
if (bp.focus !== undefined && typeof bp.focus !== "boolean")
|
|
676
678
|
throw new Error(`${where}.focus must be true or false`);
|
|
679
|
+
if (bp.org !== undefined && (typeof bp.org !== "string" || !bp.org.trim()))
|
|
680
|
+
throw new Error(`${where}.org must be an org id (a key of orgs:)`);
|
|
677
681
|
return {
|
|
678
682
|
name,
|
|
679
683
|
repos: blueprintStrings(where, "repos", bp.repos),
|
|
680
684
|
branch,
|
|
681
685
|
autostartOff: blueprintStrings(where, "autostart_off", bp.autostart_off),
|
|
682
686
|
focus: bp.focus === true,
|
|
687
|
+
...(typeof bp.org === "string" ? { org: bp.org.trim() } : {}),
|
|
683
688
|
};
|
|
684
689
|
}
|
|
685
690
|
|
|
@@ -864,6 +869,38 @@ export function readReleaseConfig(): ReleaseConfig {
|
|
|
864
869
|
return cfg;
|
|
865
870
|
}
|
|
866
871
|
|
|
872
|
+
/**
|
|
873
|
+
* `release.org` as configured (den.yml, then user.yml), without the
|
|
874
|
+
* `github_org` fallback readReleaseConfig fills in. An explicit value is the
|
|
875
|
+
* release owner of every repo (REL-1); unset, each repo's own catalog owner is.
|
|
876
|
+
*/
|
|
877
|
+
export function readExplicitReleaseOrg(): string | undefined {
|
|
878
|
+
const den = mergeReleaseLayer(
|
|
879
|
+
DEFAULT_RELEASE_CONFIG,
|
|
880
|
+
readDenConfig().release,
|
|
881
|
+
);
|
|
882
|
+
const org = mergeReleaseLayer(den, readUserConfig().release).org;
|
|
883
|
+
return typeof org === "string" && org.trim() ? org.trim() : undefined;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* The GitHub owner a repo is released from (REL-1): an explicit
|
|
888
|
+
* `release.org` wins, then the repo's non-empty catalog owner, then
|
|
889
|
+
* `github_org`. "" when none is known.
|
|
890
|
+
*/
|
|
891
|
+
export function releaseOwnerFor(opts: {
|
|
892
|
+
explicitOrg?: string;
|
|
893
|
+
catalogOwner?: string;
|
|
894
|
+
githubOrg?: string;
|
|
895
|
+
}): string {
|
|
896
|
+
return (
|
|
897
|
+
opts.explicitOrg?.trim() ||
|
|
898
|
+
opts.catalogOwner?.trim() ||
|
|
899
|
+
opts.githubOrg?.trim() ||
|
|
900
|
+
""
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
|
|
867
904
|
/**
|
|
868
905
|
* Persist a personal release override to config/local/user.yml (gitignored).
|
|
869
906
|
* Used by the Release tab's wave editor — team defaults stay in den.yml.
|
|
@@ -55,7 +55,8 @@ export function upsertCatalogEntry(
|
|
|
55
55
|
/**
|
|
56
56
|
* GitHub slug to clone `repoName` from.
|
|
57
57
|
*
|
|
58
|
-
* The catalog's `gh` when set, else the conventional `<
|
|
58
|
+
* The catalog's `gh` when set, else the conventional `<org>/<repo>`, where
|
|
59
|
+
* `<org>` is the workspace's resolved org (`github_org` when none is given) —
|
|
59
60
|
* the same rule `seedDefaultWorkspace` uses. Entries written by the
|
|
60
61
|
* dev-managed add flow carry `gh: ""`, so a repo can be IN the catalog and
|
|
61
62
|
* still have no slug; deriving it (rather than refusing to clone) is what lets
|
|
@@ -64,10 +65,16 @@ export function upsertCatalogEntry(
|
|
|
64
65
|
export function resolveRepoGhSlug(
|
|
65
66
|
repoName: string,
|
|
66
67
|
catalog: ServiceCatalog = readCatalog(),
|
|
68
|
+
/**
|
|
69
|
+
* The owner for a repo with no `gh`: the workspace's resolved org
|
|
70
|
+
* (`() => githubOrgFor(ws)`, ORG-1a). Called only when needed, so a repo
|
|
71
|
+
* with a slug never needs an org. Defaults to `github_org`.
|
|
72
|
+
*/
|
|
73
|
+
owner: () => string = () => requireGithubOrg(),
|
|
67
74
|
): string {
|
|
68
75
|
const gh = catalog[repoName]?.gh?.trim();
|
|
69
76
|
if (gh) return gh;
|
|
70
|
-
return `${
|
|
77
|
+
return `${owner()}/${repoName}`;
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
export function removeCatalogEntry(repoName: string): void {
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* all managed. On a fresh clone (no local workspace) den offers — once —
|
|
6
6
|
* to seed the `default` workspace from it so `den graph` / `den up` show the
|
|
7
7
|
* full system graph immediately; `den init` (run in the den root) re-seeds.
|
|
8
|
-
* Catalog clone URLs derive as `<
|
|
8
|
+
* Catalog clone URLs derive as `<org>/<repo>`, with the workspace's resolved org
|
|
9
|
+
* (workspace-org.ts; `github_org` unless the blueprint or catalog says otherwise). Switch any repo to a local
|
|
9
10
|
* checkout with `den use-local <repo>`.
|
|
10
11
|
*/
|
|
11
12
|
import {
|
|
@@ -16,9 +17,13 @@ import {
|
|
|
16
17
|
import { readCatalog, writeCatalog } from "./catalog.js";
|
|
17
18
|
import {
|
|
18
19
|
readWorkspaceBlueprints,
|
|
19
|
-
requireGithubOrg,
|
|
20
20
|
type ResolvedWorkspaceBlueprint,
|
|
21
21
|
} from "../den-config.js";
|
|
22
|
+
import {
|
|
23
|
+
githubOrgFor,
|
|
24
|
+
readOrgContext,
|
|
25
|
+
type OrgContext,
|
|
26
|
+
} from "./workspace-org.js";
|
|
22
27
|
import type {
|
|
23
28
|
ServiceCatalog,
|
|
24
29
|
WorkspaceConfig,
|
|
@@ -47,10 +52,6 @@ export function defaultWorkspaceRepos(): string[] {
|
|
|
47
52
|
return singleBlueprint()?.repos ?? [];
|
|
48
53
|
}
|
|
49
54
|
|
|
50
|
-
function org(): string {
|
|
51
|
-
return requireGithubOrg();
|
|
52
|
-
}
|
|
53
|
-
|
|
54
55
|
function blueprintServices(
|
|
55
56
|
bp: ResolvedWorkspaceBlueprint,
|
|
56
57
|
repos: string[] = bp.repos,
|
|
@@ -70,14 +71,41 @@ function blueprintServices(
|
|
|
70
71
|
return services;
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/**
|
|
75
|
+
* The clone slug seeding would give each repo the catalog has no `gh` for:
|
|
76
|
+
* `<org>/<repo>` with its workspace's resolved org. A repo in several
|
|
77
|
+
* workspaces takes the first one's. Reads only; throws when a repo needs an
|
|
78
|
+
* org and none resolves (no org anywhere and no `github_org`).
|
|
79
|
+
*/
|
|
80
|
+
export function catalogFill(
|
|
81
|
+
workspaces: { name: string; repos: string[] }[],
|
|
82
|
+
catalog: ServiceCatalog = readCatalog(),
|
|
83
|
+
ctx: OrgContext = readOrgContext(),
|
|
84
|
+
): Map<string, string> {
|
|
85
|
+
const fill = new Map<string, string>();
|
|
86
|
+
for (const ws of workspaces)
|
|
87
|
+
for (const repo of ws.repos) {
|
|
88
|
+
if (catalog[repo]?.gh || fill.has(repo)) continue;
|
|
89
|
+
fill.set(repo, `${githubOrgFor(ws.name, ctx)}/${repo}`);
|
|
90
|
+
}
|
|
91
|
+
return fill;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Give each blueprint repo with no clone URL `<org>/<repo>`, where `<org>` is
|
|
96
|
+
* its workspace's resolved org (ORG-1a: the blueprint's `org:`, else the
|
|
97
|
+
* catalog owners, else `github_org`), so a client workspace never clones
|
|
98
|
+
* from the default org on a fresh machine. The org is resolved only for a
|
|
99
|
+
* repo that needs one, and before anything is written: an unresolvable org
|
|
100
|
+
* throws with the catalog untouched.
|
|
101
|
+
*/
|
|
73
102
|
function fillCatalog(
|
|
74
|
-
repos: string[],
|
|
103
|
+
workspaces: { name: string; repos: string[] }[],
|
|
75
104
|
catalog: ServiceCatalog = readCatalog(),
|
|
76
105
|
): void {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
106
|
+
const fill = catalogFill(workspaces, catalog);
|
|
107
|
+
if (fill.size === 0) return;
|
|
108
|
+
for (const [repo, gh] of fill) catalog[repo] = { gh };
|
|
81
109
|
writeCatalog(catalog);
|
|
82
110
|
}
|
|
83
111
|
|
|
@@ -117,6 +145,53 @@ export function defaultWorkspaceHasServices(): boolean {
|
|
|
117
145
|
);
|
|
118
146
|
}
|
|
119
147
|
|
|
148
|
+
function sameEntry(a: unknown, b: unknown): boolean {
|
|
149
|
+
if (a === b) return true;
|
|
150
|
+
if (typeof a !== "object" || typeof b !== "object" || !a || !b) return false;
|
|
151
|
+
const ka = Object.keys(a).sort();
|
|
152
|
+
const kb = Object.keys(b).sort();
|
|
153
|
+
return (
|
|
154
|
+
ka.length === kb.length &&
|
|
155
|
+
ka.every(
|
|
156
|
+
(k, i) =>
|
|
157
|
+
k === kb[i] &&
|
|
158
|
+
sameEntry(
|
|
159
|
+
(a as Record<string, unknown>)[k],
|
|
160
|
+
(b as Record<string, unknown>)[k],
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Whether seeding now would change nothing: every workspace the blueprint
|
|
168
|
+
* seeds holds exactly what seeding writes (same repos, all managed on the
|
|
169
|
+
* blueprint's branch, same autostart defaults), records the blueprint as
|
|
170
|
+
* applied, and (multi blueprint) is active. A workspace the user changed
|
|
171
|
+
* since (a repo switched to a local checkout, one added or removed) is not
|
|
172
|
+
* "already seeded": seeding would reset it.
|
|
173
|
+
*/
|
|
174
|
+
export function blueprintAlreadySeeded(
|
|
175
|
+
file: WorkspacesFile = readWorkspacesFile(),
|
|
176
|
+
): boolean {
|
|
177
|
+
const bps = blueprintWorkspaces();
|
|
178
|
+
if (bps.length === 0) return false;
|
|
179
|
+
const multi = isMultiBlueprint();
|
|
180
|
+
return bps.every((bp) => {
|
|
181
|
+
const name = multi ? bp.name : "default";
|
|
182
|
+
const ws = file.workspaces[name];
|
|
183
|
+
if (!ws) return false;
|
|
184
|
+
if (multi && !(file.active ?? []).includes(name)) return false;
|
|
185
|
+
const applied = ws.seeded_repos ?? [];
|
|
186
|
+
if (
|
|
187
|
+
applied.length !== bp.repos.length ||
|
|
188
|
+
bp.repos.some((r, i) => applied[i] !== r)
|
|
189
|
+
)
|
|
190
|
+
return false;
|
|
191
|
+
return sameEntry(ws.services ?? {}, blueprintServices(bp));
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
120
195
|
/**
|
|
121
196
|
* Seed (or reset) the `default` workspace from the blueprint — every repo as a
|
|
122
197
|
* bare, first-class managed entry on `branch` — and ensure the catalog has
|
|
@@ -131,6 +206,9 @@ export function seedDefaultWorkspace(): string[] {
|
|
|
131
206
|
if (!bp) return [];
|
|
132
207
|
const repos = bp.repos;
|
|
133
208
|
|
|
209
|
+
// The catalog first: it is what can fail (no github_org), and a failure
|
|
210
|
+
// must leave the workspaces as they were, not reset and uncloneable.
|
|
211
|
+
fillCatalog([{ name: "default", repos }]);
|
|
134
212
|
const file = readWorkspacesFile();
|
|
135
213
|
file.workspaces.default = {
|
|
136
214
|
...(file.workspaces.default ?? {}),
|
|
@@ -142,7 +220,6 @@ export function seedDefaultWorkspace(): string[] {
|
|
|
142
220
|
file.focus = "default";
|
|
143
221
|
}
|
|
144
222
|
writeWorkspacesFile(file);
|
|
145
|
-
fillCatalog(repos);
|
|
146
223
|
return repos;
|
|
147
224
|
}
|
|
148
225
|
|
|
@@ -155,6 +232,7 @@ export function seedDefaultWorkspace(): string[] {
|
|
|
155
232
|
export function seedBlueprintWorkspaces(): { name: string; repos: string[] }[] {
|
|
156
233
|
const bps = blueprintWorkspaces();
|
|
157
234
|
if (bps.length === 0) return [];
|
|
235
|
+
fillCatalog(bps);
|
|
158
236
|
const file = readWorkspacesFile();
|
|
159
237
|
for (const bp of bps) {
|
|
160
238
|
const prev: WorkspaceConfig | undefined = file.workspaces[bp.name];
|
|
@@ -176,7 +254,6 @@ export function seedBlueprintWorkspaces(): { name: string; repos: string[] }[] {
|
|
|
176
254
|
file.active = [...names, ...kept.filter((n) => !names.includes(n))];
|
|
177
255
|
file.focus = (bps.find((b) => b.focus) ?? bps[0]).name;
|
|
178
256
|
writeWorkspacesFile(file);
|
|
179
|
-
fillCatalog([...new Set(bps.flatMap((b) => b.repos))]);
|
|
180
257
|
return bps.map((b) => ({ name: b.name, repos: [...b.repos] }));
|
|
181
258
|
}
|
|
182
259
|
|
|
@@ -230,7 +307,8 @@ export function topUpDefaultWorkspace(): string[] {
|
|
|
230
307
|
|
|
231
308
|
def.seeded_repos = [...repos];
|
|
232
309
|
writeWorkspacesFile(file);
|
|
233
|
-
if (missing.length > 0)
|
|
310
|
+
if (missing.length > 0)
|
|
311
|
+
fillCatalog([{ name: "default", repos: missing }], catalog);
|
|
234
312
|
return missing;
|
|
235
313
|
}
|
|
236
314
|
|
|
@@ -12,7 +12,10 @@ import { readEdgeConfig } from "../edge/config.js";
|
|
|
12
12
|
import {
|
|
13
13
|
composeHealth,
|
|
14
14
|
composeHealthTarget,
|
|
15
|
+
containerHealth,
|
|
15
16
|
type ComposeHealthTarget,
|
|
17
|
+
type ComposeVerdict,
|
|
18
|
+
type ContainerHealthTarget,
|
|
16
19
|
} from "../stacks/compose-health.js";
|
|
17
20
|
import * as fs from "fs";
|
|
18
21
|
import * as os from "os";
|
|
@@ -38,7 +41,11 @@ import {
|
|
|
38
41
|
listWorkspaceServices,
|
|
39
42
|
readManifestAnyBranch,
|
|
40
43
|
} from "./workspaces.js";
|
|
41
|
-
import type {
|
|
44
|
+
import type {
|
|
45
|
+
ResolvedService,
|
|
46
|
+
ServiceCatalog,
|
|
47
|
+
ServiceHealthConfig,
|
|
48
|
+
} from "../types/index.js";
|
|
42
49
|
import type { Environment } from "../types/index.js";
|
|
43
50
|
|
|
44
51
|
import { product } from "../product.js";
|
|
@@ -223,6 +230,28 @@ function trustContext(): { context: tls.SecureContext; agent: https.Agent } {
|
|
|
223
230
|
return _trust;
|
|
224
231
|
}
|
|
225
232
|
|
|
233
|
+
/**
|
|
234
|
+
* The body a reverse proxy sends for a host it has no route for: Traefik's
|
|
235
|
+
* default 404 (Go's http.NotFound). Behind a shared edge a stopped service
|
|
236
|
+
* answers with this instead of refusing the connection, so a 4xx-means-up
|
|
237
|
+
* rule would show it healthy. A service's own 404 carries its own body.
|
|
238
|
+
*/
|
|
239
|
+
const EDGE_NO_ROUTE_BODY = "404 page not found";
|
|
240
|
+
|
|
241
|
+
/** Up: 2xx, or a 4xx from the service itself (auth/route proves it answers). */
|
|
242
|
+
function statusOk(status: number, body404?: string): boolean {
|
|
243
|
+
if (status === 404 && body404?.trim() === EDGE_NO_ROUTE_BODY) return false;
|
|
244
|
+
return status < 300 || (status >= 400 && status < 500);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The PingResult for `status`, naming an edge 404 as "no route". */
|
|
248
|
+
function statusResult(status: number, body404?: string): PingResult {
|
|
249
|
+
const ok = statusOk(status, body404);
|
|
250
|
+
return ok || status !== 404
|
|
251
|
+
? { ok, status }
|
|
252
|
+
: { ok, status, error: "no route (edge 404)" };
|
|
253
|
+
}
|
|
254
|
+
|
|
226
255
|
/**
|
|
227
256
|
* HTTPS check. Connects to the c-ares-resolved IP (see above) but keeps the
|
|
228
257
|
* hostname for SNI and certificate verification; rewriting the URL to the IP,
|
|
@@ -269,11 +298,23 @@ async function pingHttps(parsed: URL, timeoutMs: number): Promise<PingResult> {
|
|
|
269
298
|
},
|
|
270
299
|
(res) => {
|
|
271
300
|
const status = res.statusCode ?? 0;
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
ok:
|
|
275
|
-
|
|
301
|
+
if (status !== 404) {
|
|
302
|
+
res.resume(); // discard the body
|
|
303
|
+
done({ ok: statusOk(status), status });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
// A 404: read a little of the body to tell the edge's own "no
|
|
307
|
+
// route" from the service's 404.
|
|
308
|
+
let body = "";
|
|
309
|
+
res.setEncoding("utf8");
|
|
310
|
+
res.on("data", (c: string) => {
|
|
311
|
+
if (body.length < 64) body += c;
|
|
312
|
+
if (body.length >= 64) res.destroy();
|
|
276
313
|
});
|
|
314
|
+
const finish = () => done(statusResult(status, body));
|
|
315
|
+
res.on("end", finish);
|
|
316
|
+
res.on("close", finish);
|
|
317
|
+
res.on("error", finish);
|
|
277
318
|
},
|
|
278
319
|
);
|
|
279
320
|
const timer = setTimeout(() => {
|
|
@@ -327,11 +368,20 @@ async function pingOnce(
|
|
|
327
368
|
// (e.g. VPN / /etc/hosts) returning 301 on port 80 for services that actually run
|
|
328
369
|
// on a different port — the previous `< 500` check showed those as green.
|
|
329
370
|
// 5xx = unhealthy (service errored).
|
|
330
|
-
|
|
331
|
-
//
|
|
332
|
-
// sockets in the long-lived web server
|
|
371
|
+
// A 404 may be the edge's own "no route" (see EDGE_NO_ROUTE_BODY): read
|
|
372
|
+
// a little of it. Otherwise discard the body: only the status matters,
|
|
373
|
+
// and unconsumed bodies pin sockets in the long-lived web server.
|
|
374
|
+
if (res.status === 404) {
|
|
375
|
+
let body = "";
|
|
376
|
+
try {
|
|
377
|
+
body = (await res.text()).slice(0, 64);
|
|
378
|
+
} catch {
|
|
379
|
+
/* unreadable body: judge by the status alone */
|
|
380
|
+
}
|
|
381
|
+
return statusResult(res.status, body);
|
|
382
|
+
}
|
|
333
383
|
void res.body?.cancel().catch(() => {});
|
|
334
|
-
return { ok, status: res.status };
|
|
384
|
+
return { ok: statusOk(res.status), status: res.status };
|
|
335
385
|
} catch (e: unknown) {
|
|
336
386
|
clearTimeout(timer);
|
|
337
387
|
const err = e as Error;
|
|
@@ -595,12 +645,39 @@ export async function pingShellCheck(
|
|
|
595
645
|
compose: ComposeHealthTarget | null,
|
|
596
646
|
): Promise<PingResult> {
|
|
597
647
|
if (!compose || cmd !== compose.cmd) return pingShell(cmd, extraEnv, cwd);
|
|
598
|
-
|
|
648
|
+
return verdictPing(await composeHealth(compose));
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* A compose or container verdict as a ping. One conversion for both, so a
|
|
653
|
+
* docker that could not be asked reads the same: `ok: null`, pending (and so
|
|
654
|
+
* down on a repeat) only when the listing ran past its budget.
|
|
655
|
+
*/
|
|
656
|
+
function verdictPing(v: ComposeVerdict): PingResult {
|
|
599
657
|
if (v.ok === true) return { ok: true };
|
|
600
658
|
if (v.ok === false) return { ok: false, error: v.detail };
|
|
601
659
|
return { ok: null, error: v.detail, ...(v.pending ? { pending: true } : {}) };
|
|
602
660
|
}
|
|
603
661
|
|
|
662
|
+
/**
|
|
663
|
+
* Run a `type: container` check: answered in-process from the shared docker
|
|
664
|
+
* listing (the one Den's compose checks use), never a docker call of its own.
|
|
665
|
+
*/
|
|
666
|
+
export async function pingContainerCheck(
|
|
667
|
+
target: ContainerHealthTarget,
|
|
668
|
+
): Promise<PingResult> {
|
|
669
|
+
return verdictPing(await containerHealth(target));
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** The container a `type: container` health config names. */
|
|
673
|
+
export function containerTarget(
|
|
674
|
+
h: Extract<ServiceHealthConfig, { type: "container" }>,
|
|
675
|
+
): ContainerHealthTarget {
|
|
676
|
+
return h.container_name !== undefined
|
|
677
|
+
? { container_name: h.container_name }
|
|
678
|
+
: { project: h.project, service: h.service };
|
|
679
|
+
}
|
|
680
|
+
|
|
604
681
|
/** A ping result as a ServiceHealthResult health state. */
|
|
605
682
|
export function healthState(r: PingResult): ServiceHealthResult["health"] {
|
|
606
683
|
return r.ok === true ? "ok" : r.ok === false ? "error" : "pending";
|
|
@@ -710,13 +787,15 @@ export async function checkServiceHealth(
|
|
|
710
787
|
const result = await pingTwiceOnTimeout(() =>
|
|
711
788
|
healthCfg.type === "shell"
|
|
712
789
|
? pingShellCheck(healthCfg.cmd, {}, resolved.repoPath, compose)
|
|
713
|
-
:
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
790
|
+
: healthCfg.type === "container"
|
|
791
|
+
? pingContainerCheck(containerTarget(healthCfg))
|
|
792
|
+
: pingHttp(
|
|
793
|
+
healthCfg.url,
|
|
794
|
+
env === "local" && plan.placement.runs !== "remote"
|
|
795
|
+
? HEALTH_CHECK_TIMEOUT_MS
|
|
796
|
+
: HOSTED_HEALTH_CHECK_TIMEOUT_MS,
|
|
797
|
+
plan.expect,
|
|
798
|
+
),
|
|
720
799
|
);
|
|
721
800
|
|
|
722
801
|
return {
|
|
@@ -69,7 +69,7 @@ import type {
|
|
|
69
69
|
WorkspaceConfig,
|
|
70
70
|
} from "../types/index.js";
|
|
71
71
|
import type { ChildProcess } from "child_process";
|
|
72
|
-
import { getStateDir } from "../paths.js";
|
|
72
|
+
import { getDistRoot, getStateDir } from "../paths.js";
|
|
73
73
|
|
|
74
74
|
import { product } from "../product.js";
|
|
75
75
|
// Re-export so existing callers don't need to change their import path.
|
|
@@ -101,6 +101,33 @@ export interface DaemonRecord {
|
|
|
101
101
|
* command. Optional for backward-compat with records written before this.
|
|
102
102
|
*/
|
|
103
103
|
containers?: string[];
|
|
104
|
+
/**
|
|
105
|
+
* The distribution root (realpath) whose server or CLI registered this
|
|
106
|
+
* daemon. Every root of a product shares one daemons file, so startup
|
|
107
|
+
* pruning skips records stamped by another root. Missing on records
|
|
108
|
+
* written before 0.2.7.
|
|
109
|
+
*/
|
|
110
|
+
root?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** This process's distribution root, as stamped on its daemon records. */
|
|
114
|
+
function daemonRoot(): string | null {
|
|
115
|
+
try {
|
|
116
|
+
return fs.realpathSync(getDistRoot());
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Whose a daemon record is: this root's, another root's (all roots of a
|
|
124
|
+
* product share one daemons file), or unstamped (written before 0.2.7).
|
|
125
|
+
*/
|
|
126
|
+
export function daemonRootOf(
|
|
127
|
+
d: Pick<DaemonRecord, "root">,
|
|
128
|
+
): "here" | "other" | "unstamped" {
|
|
129
|
+
if (!d.root) return "unstamped";
|
|
130
|
+
return d.root === daemonRoot() ? "here" : "other";
|
|
104
131
|
}
|
|
105
132
|
|
|
106
133
|
export type ServiceConflictReason = "identity_mismatch" | "orphan_containers";
|
|
@@ -263,8 +290,10 @@ export function registerDaemon(record: DaemonRecord): void {
|
|
|
263
290
|
? (existing?.workspaces ?? [])
|
|
264
291
|
: [];
|
|
265
292
|
const workspaces = [...new Set([...inherited, ...(record.workspaces ?? [])])];
|
|
293
|
+
const root = daemonRoot() ?? record.root;
|
|
266
294
|
_daemonRegistry.set(record.address, {
|
|
267
295
|
...record,
|
|
296
|
+
...(root ? { root } : {}),
|
|
268
297
|
workspaces,
|
|
269
298
|
branch: normalizeBranch(record.branch),
|
|
270
299
|
});
|
|
@@ -296,9 +325,17 @@ export function updateDaemonContainers(
|
|
|
296
325
|
saveDaemonsFile(getDaemonsFile());
|
|
297
326
|
}
|
|
298
327
|
|
|
328
|
+
/**
|
|
329
|
+
* This root's daemons (and unstamped legacy ones). Every root of a product
|
|
330
|
+
* shares one daemons file; another root's records stay in it untouched but
|
|
331
|
+
* are not listed, so this server never shows, stops, releases or sweeps
|
|
332
|
+
* them. Lookups by address (findDaemonByAddress) still see every record.
|
|
333
|
+
*/
|
|
299
334
|
export function listDaemons(): DaemonRecord[] {
|
|
300
335
|
loadDaemonsFromFile();
|
|
301
|
-
return Array.from(_daemonRegistry.values())
|
|
336
|
+
return Array.from(_daemonRegistry.values()).filter(
|
|
337
|
+
(d) => daemonRootOf(d) !== "other",
|
|
338
|
+
);
|
|
302
339
|
}
|
|
303
340
|
|
|
304
341
|
export function findDaemonByAddress(address: string): DaemonRecord | undefined {
|
|
@@ -646,8 +683,11 @@ export function isDaemonVerifiablyDead(
|
|
|
646
683
|
* clear stale entries from previous sessions. Otherwise they reopen as tabs in
|
|
647
684
|
* the web UI and clutter the shutdown prompt.
|
|
648
685
|
*
|
|
649
|
-
* A daemon
|
|
650
|
-
*
|
|
686
|
+
* A daemon stamped by another root (all roots of a product share the file)
|
|
687
|
+
* is never touched. Otherwise it is dropped when:
|
|
688
|
+
* - it was stamped by this root and its address no longer resolves to a
|
|
689
|
+
* workspace/catalog service (an unstamped legacy record might belong to
|
|
690
|
+
* another root, so this alone never drops one), OR
|
|
651
691
|
* - it is opctl-based (has captured containers or a discoverable dev-op) and
|
|
652
692
|
* NONE of its candidate containers are currently running.
|
|
653
693
|
*
|
|
@@ -687,7 +727,13 @@ function pruneAgainst(
|
|
|
687
727
|
const pruned: string[] = [];
|
|
688
728
|
for (const d of daemons) {
|
|
689
729
|
try {
|
|
690
|
-
|
|
730
|
+
const root = daemonRootOf(d);
|
|
731
|
+
// Another root's daemon: its services resolve in its workspaces, not
|
|
732
|
+
// ours, and its liveness is its own server's call.
|
|
733
|
+
if (root === "other") continue;
|
|
734
|
+
// An unresolvable address is only proof of staleness for a record
|
|
735
|
+
// this root wrote; an unstamped one may belong to another root.
|
|
736
|
+
if (root === "here" && !canResolveDaemonAddress(d)) {
|
|
691
737
|
unregisterDaemon(d.address);
|
|
692
738
|
pruned.push(d.address);
|
|
693
739
|
continue;
|
|
@@ -91,7 +91,8 @@ function gitCapture(cmd: string, cwd?: string): string {
|
|
|
91
91
|
}).trim();
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
/** The HTTPS URL a managed clone of `ghSlug` is made from. */
|
|
95
|
+
export function ghCloneUrl(ghSlug: string): string {
|
|
95
96
|
return `https://github.com/${ghSlug}.git`;
|
|
96
97
|
}
|
|
97
98
|
|