@monster-orange/den 0.2.2 → 0.2.4
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 +151 -0
- package/docs/distributions.md +51 -0
- package/package.json +1 -1
- package/scripts/setup-secrets.ts +11 -0
- package/service/install.sh +4 -2
- package/src/shared/edge/config.ts +16 -0
- package/src/shared/environment-manager/health.ts +36 -1
- package/src/shared/environment-manager/opctl.ts +83 -1
- package/src/shared/environment-manager/service-runner.ts +205 -8
- package/src/shared/git/origin-url.ts +77 -0
- package/src/shared/manifest/read-manifest.ts +39 -15
- package/src/shared/manifest/schema.ts +15 -0
- 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/secrets/doppler.ts +10 -5
- package/src/shared/secrets/index.ts +1 -0
- package/src/shared/secrets/keys.ts +2 -0
- package/src/shared/secrets/secret-env.ts +149 -0
- package/src/shared/secrets/types.ts +10 -0
- package/src/shared/stacks/config.ts +6 -0
- package/src/shared/stacks/runtime.ts +51 -3
- package/src/shared/stacks/status.ts +109 -1
- package/src/shared/stacks/types.ts +8 -0
- package/src/shared/types/index.ts +15 -0
- package/src/ui/cli/index.ts +151 -10
- package/src/ui/cli/mcp-connect.ts +77 -15
- package/src/ui/cli/stack-command.ts +47 -5
- package/src/ui/cli/workspace-notes.ts +43 -0
- package/src/ui/mcp/connect.ts +3 -1
- package/src/ui/server/graph-order.ts +22 -0
- package/src/ui/server/index.ts +101 -183
- package/src/ui/server/lifecycle-exec.ts +23 -0
- package/src/ui/server/services-list.ts +210 -0
- package/src/ui/server/single-flight.ts +53 -0
- package/src/ui/server/workspace-routes.ts +272 -181
|
@@ -27,6 +27,15 @@ import {
|
|
|
27
27
|
shellQuote,
|
|
28
28
|
} from "../terminal/tabs.js";
|
|
29
29
|
import { readCatalog } from "./catalog.js";
|
|
30
|
+
import { getSecretsProvider } from "../secrets/index.js";
|
|
31
|
+
import {
|
|
32
|
+
assertSecretEnvSpec,
|
|
33
|
+
resolveSecretEnv,
|
|
34
|
+
secretEnvKeys,
|
|
35
|
+
type SecretEnvSpec,
|
|
36
|
+
} from "../secrets/secret-env.js";
|
|
37
|
+
import type { SecretsProvider } from "../secrets/types.js";
|
|
38
|
+
import { composeCommands } from "../stacks/manifest-commands.js";
|
|
30
39
|
import { buildServiceEnv } from "./service-env.js";
|
|
31
40
|
import {
|
|
32
41
|
devOpContainerNames,
|
|
@@ -35,6 +44,8 @@ import {
|
|
|
35
44
|
inspectOpctlContainer,
|
|
36
45
|
isOpctlAvailable,
|
|
37
46
|
listRunningOpctlContainers,
|
|
47
|
+
listRunningOpctlContainersAsync,
|
|
48
|
+
isOpctlAvailableAsync,
|
|
38
49
|
} from "./opctl.js";
|
|
39
50
|
import {
|
|
40
51
|
getActiveWorkspace,
|
|
@@ -348,6 +359,24 @@ export function activeDaemonOwners(
|
|
|
348
359
|
return (record?.workspaces ?? []).filter((w) => active.has(w));
|
|
349
360
|
}
|
|
350
361
|
|
|
362
|
+
/** Whether workspace `name`'s current config lists `daemon`'s service. */
|
|
363
|
+
function workspaceContainsDaemon(
|
|
364
|
+
name: string,
|
|
365
|
+
daemon: Pick<DaemonRecord, "address" | "repoName">,
|
|
366
|
+
): boolean {
|
|
367
|
+
const services = getWorkspaceByName(name)?.config.services ?? {};
|
|
368
|
+
return daemon.address in services || daemon.repoName in services;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Replace a running daemon's owners (re-derived from membership). */
|
|
372
|
+
function setDaemonOwners(address: string, workspaces: string[]): void {
|
|
373
|
+
loadDaemonsFromFile();
|
|
374
|
+
const existing = _daemonRegistry.get(address);
|
|
375
|
+
if (!existing) return;
|
|
376
|
+
_daemonRegistry.set(address, { ...existing, workspaces });
|
|
377
|
+
saveDaemonsFile(getDaemonsFile());
|
|
378
|
+
}
|
|
379
|
+
|
|
351
380
|
/** Outcome of releasing one daemon during a workspace deactivation. */
|
|
352
381
|
export interface DaemonRelease {
|
|
353
382
|
address: string;
|
|
@@ -373,15 +402,19 @@ export async function releaseWorkspaceDaemons(
|
|
|
373
402
|
const owned = listDaemons().filter((d) =>
|
|
374
403
|
(d.workspaces ?? []).includes(workspace),
|
|
375
404
|
);
|
|
376
|
-
const active =
|
|
405
|
+
const active = getActiveWorkspaces();
|
|
377
406
|
const releases: DaemonRelease[] = [];
|
|
378
407
|
|
|
379
408
|
for (const daemon of owned) {
|
|
380
|
-
|
|
381
|
-
|
|
409
|
+
// Owners re-derived from the CURRENT membership, not the ones recorded at
|
|
410
|
+
// start: a repo moved into another workspace while its daemon ran belongs
|
|
411
|
+
// to that workspace now, and one moved out no longer belongs to this one.
|
|
412
|
+
// Stopped only when no active workspace still contains it.
|
|
413
|
+
const remaining = active.filter(
|
|
414
|
+
(w) => w !== workspace && workspaceContainsDaemon(w, daemon),
|
|
382
415
|
);
|
|
383
416
|
if (remaining.length > 0) {
|
|
384
|
-
|
|
417
|
+
setDaemonOwners(daemon.address, remaining);
|
|
385
418
|
onLine(
|
|
386
419
|
`detached "${workspace}" from ${daemon.address} — still running for [${remaining.join(", ")}]`,
|
|
387
420
|
);
|
|
@@ -630,6 +663,27 @@ export function pruneDeadDaemons(): string[] {
|
|
|
630
663
|
const running = isOpctlAvailable()
|
|
631
664
|
? new Set(listRunningOpctlContainers().map((c) => c.base))
|
|
632
665
|
: null;
|
|
666
|
+
return pruneAgainst(daemons, running);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* pruneDeadDaemons without blocking: the container listing runs as async
|
|
671
|
+
* child processes (the web server's startup, which used to hold the event
|
|
672
|
+
* loop for the `docker ps`).
|
|
673
|
+
*/
|
|
674
|
+
export async function pruneDeadDaemonsAsync(): Promise<string[]> {
|
|
675
|
+
const daemons = listDaemons();
|
|
676
|
+
if (daemons.length === 0) return [];
|
|
677
|
+
const running = (await isOpctlAvailableAsync())
|
|
678
|
+
? new Set((await listRunningOpctlContainersAsync()).map((c) => c.base))
|
|
679
|
+
: null;
|
|
680
|
+
return pruneAgainst(daemons, running);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function pruneAgainst(
|
|
684
|
+
daemons: DaemonRecord[],
|
|
685
|
+
running: Set<string> | null,
|
|
686
|
+
): string[] {
|
|
633
687
|
const pruned: string[] = [];
|
|
634
688
|
for (const d of daemons) {
|
|
635
689
|
try {
|
|
@@ -1069,6 +1123,12 @@ export interface RunCommandOptions {
|
|
|
1069
1123
|
* the detach window is invisible to fail-fast callers.
|
|
1070
1124
|
*/
|
|
1071
1125
|
onServiceExit?: (exitCode: number) => void;
|
|
1126
|
+
/**
|
|
1127
|
+
* R16: the service's resolved `secret_env` (see prepareServiceSecretEnv),
|
|
1128
|
+
* set only in the spawned process's env. An up/restart of a service that
|
|
1129
|
+
* declares secret_env is refused unless every key is here.
|
|
1130
|
+
*/
|
|
1131
|
+
secretEnv?: Record<string, string>;
|
|
1072
1132
|
}
|
|
1073
1133
|
|
|
1074
1134
|
export interface RunCommandResult {
|
|
@@ -1135,6 +1195,108 @@ function repoStartedFallback(
|
|
|
1135
1195
|
};
|
|
1136
1196
|
}
|
|
1137
1197
|
|
|
1198
|
+
// ── secret_env (R16) ─────────────────────────────────────────────────────────
|
|
1199
|
+
|
|
1200
|
+
/** Commands that start a service process, and so get its secret_env. */
|
|
1201
|
+
function startsService(command: string): boolean {
|
|
1202
|
+
return command === "up" || command === "restart";
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/** A service's declared `services.<svc>.secret_env` (validated), or undefined. */
|
|
1206
|
+
function serviceSecretEnvSpec(
|
|
1207
|
+
resolved: ResolvedService,
|
|
1208
|
+
): SecretEnvSpec | undefined {
|
|
1209
|
+
const raw = readManifest(resolved.repoPath)?.services?.[resolved.subsystem]
|
|
1210
|
+
?.secret_env;
|
|
1211
|
+
return assertSecretEnvSpec(
|
|
1212
|
+
raw,
|
|
1213
|
+
`${resolved.address}: services.${resolved.subsystem}.secret_env`,
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* The address whose command a run of `command` on `address` actually spawns
|
|
1219
|
+
* (a service with no up of its own runs its repo's up), or null when none.
|
|
1220
|
+
*/
|
|
1221
|
+
function spawningAddress(
|
|
1222
|
+
address: string,
|
|
1223
|
+
command: string,
|
|
1224
|
+
workspace?: string,
|
|
1225
|
+
): string | null {
|
|
1226
|
+
const p = previewServiceCommand(address, command, workspace);
|
|
1227
|
+
return p.kind === "run" || p.kind === "via-repo" ? p.address : null;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* The secret_env key NAMES a run of `command` on `address` would inject,
|
|
1232
|
+
* for a dry run or status: never a value, and the provider is never asked.
|
|
1233
|
+
* `service` is the service's own secret_env (set in its command's env);
|
|
1234
|
+
* `compose` is the repo's compose.secret_env, which Den's synthesized stack
|
|
1235
|
+
* command resolves for the docker compose process.
|
|
1236
|
+
*/
|
|
1237
|
+
export function previewServiceSecretEnv(
|
|
1238
|
+
address: string,
|
|
1239
|
+
command: string,
|
|
1240
|
+
workspace?: string,
|
|
1241
|
+
): { address: string; service: string[]; compose: string[] } | null {
|
|
1242
|
+
if (!startsService(command)) return null;
|
|
1243
|
+
const target = spawningAddress(address, command, workspace);
|
|
1244
|
+
const resolved = target ? resolveService(target, workspace) : null;
|
|
1245
|
+
if (!resolved) return null;
|
|
1246
|
+
const manifest = readManifest(resolved.repoPath);
|
|
1247
|
+
const service = Object.keys(
|
|
1248
|
+
manifest?.services?.[resolved.subsystem]?.secret_env ?? {},
|
|
1249
|
+
).sort();
|
|
1250
|
+
const declared = manifest?.services?.[resolved.subsystem]?.commands?.local;
|
|
1251
|
+
const synthesized =
|
|
1252
|
+
manifest?.compose && !declared?.[command]
|
|
1253
|
+
? composeCommands(
|
|
1254
|
+
manifest,
|
|
1255
|
+
resolved.subsystem,
|
|
1256
|
+
resolved.repoName,
|
|
1257
|
+
workspace ?? "default",
|
|
1258
|
+
)[command]
|
|
1259
|
+
: undefined;
|
|
1260
|
+
const compose = synthesized
|
|
1261
|
+
? Object.keys(manifest?.compose?.secret_env ?? {}).sort()
|
|
1262
|
+
: [];
|
|
1263
|
+
if (!service.length && !compose.length) return null;
|
|
1264
|
+
return { address: resolved.address, service, compose };
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
export type ServiceSecretEnvResult =
|
|
1268
|
+
| { ok: true; secretEnv: Record<string, string> }
|
|
1269
|
+
| { ok: false; message: string };
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* Resolve the `secret_env` of the service a run of `command` on `address`
|
|
1273
|
+
* spawns, for runServiceCommand's `secretEnv`. All or nothing; the provider
|
|
1274
|
+
* is only asked when the service declares secret_env, and only for up and
|
|
1275
|
+
* restart. Messages name keys, refs and the provider, never values.
|
|
1276
|
+
*/
|
|
1277
|
+
export async function prepareServiceSecretEnv(
|
|
1278
|
+
address: string,
|
|
1279
|
+
command: string,
|
|
1280
|
+
workspace?: string,
|
|
1281
|
+
provider: () => SecretsProvider = getSecretsProvider,
|
|
1282
|
+
): Promise<ServiceSecretEnvResult> {
|
|
1283
|
+
if (!startsService(command)) return { ok: true, secretEnv: {} };
|
|
1284
|
+
const target = spawningAddress(address, command, workspace);
|
|
1285
|
+
const resolved = target ? resolveService(target, workspace) : null;
|
|
1286
|
+
if (!resolved) return { ok: true, secretEnv: {} };
|
|
1287
|
+
let spec: SecretEnvSpec | undefined;
|
|
1288
|
+
try {
|
|
1289
|
+
spec = serviceSecretEnvSpec(resolved);
|
|
1290
|
+
} catch (e) {
|
|
1291
|
+
return { ok: false, message: (e as Error).message };
|
|
1292
|
+
}
|
|
1293
|
+
if (!secretEnvKeys(spec).length) return { ok: true, secretEnv: {} };
|
|
1294
|
+
const r = await resolveSecretEnv(spec, provider(), resolved.address);
|
|
1295
|
+
return r.ok
|
|
1296
|
+
? { ok: true, secretEnv: r.env }
|
|
1297
|
+
: { ok: false, message: r.message };
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1138
1300
|
/** What a run of `command` on `address` would do, without running it. */
|
|
1139
1301
|
export type ServiceCommandPreview =
|
|
1140
1302
|
| { kind: "run"; address: string }
|
|
@@ -1516,6 +1678,41 @@ export function runServiceCommand(
|
|
|
1516
1678
|
onLine,
|
|
1517
1679
|
);
|
|
1518
1680
|
|
|
1681
|
+
// R16: a service that declares secret_env never starts without every key
|
|
1682
|
+
// (a caller that did not resolve them is refused, not run without them).
|
|
1683
|
+
let secretEnv: Record<string, string> = {};
|
|
1684
|
+
if (startsService(command)) {
|
|
1685
|
+
let declared: string[];
|
|
1686
|
+
try {
|
|
1687
|
+
declared = secretEnvKeys(serviceSecretEnvSpec(resolved));
|
|
1688
|
+
} catch (e) {
|
|
1689
|
+
onLine(`Error: ${(e as Error).message}`);
|
|
1690
|
+
closeAsync(1);
|
|
1691
|
+
return dummy();
|
|
1692
|
+
}
|
|
1693
|
+
const missing = declared.filter((k) => !options.secretEnv?.[k]);
|
|
1694
|
+
if (missing.length) {
|
|
1695
|
+
onLine(
|
|
1696
|
+
`Error: ${resolved.address} declares secret_env ${missing.join(", ")}, which were not resolved; not starting it`,
|
|
1697
|
+
);
|
|
1698
|
+
closeAsync(1);
|
|
1699
|
+
return dummy();
|
|
1700
|
+
}
|
|
1701
|
+
secretEnv = Object.fromEntries(
|
|
1702
|
+
declared.map((k) => [k, options.secretEnv?.[k] as string]),
|
|
1703
|
+
);
|
|
1704
|
+
}
|
|
1705
|
+
const hasSecrets = Object.keys(secretEnv).length > 0;
|
|
1706
|
+
// Secrets go only in the spawned process's env. A tab would need them in
|
|
1707
|
+
// its command line (and so its shell history), so such a start runs as a
|
|
1708
|
+
// daemon instead.
|
|
1709
|
+
const runEnv: NodeJS.ProcessEnv = { ...env, ...secretEnv };
|
|
1710
|
+
if (hasSecrets && runtime === "tab" && startsService(command))
|
|
1711
|
+
onLine(
|
|
1712
|
+
`${resolved.address} has secret_env: running as a daemon, not in a tab, to keep the values out of the tab's shell`,
|
|
1713
|
+
);
|
|
1714
|
+
const useTab = runtime === "tab" && !hasSecrets;
|
|
1715
|
+
|
|
1519
1716
|
// Manifest downs that are just `opctl container down <name>` must not be
|
|
1520
1717
|
// spawned raw: opctl re-elevates with `sudo -E` and fails on sudoers without
|
|
1521
1718
|
// SETENV even when there is nothing to stop — which is why files-api/api
|
|
@@ -1571,7 +1768,7 @@ export function runServiceCommand(
|
|
|
1571
1768
|
// HITL command — inherit the full TTY; onLine is not called.
|
|
1572
1769
|
const proc = spawnInteractive(
|
|
1573
1770
|
cmdString,
|
|
1574
|
-
{ ...process.env, ...
|
|
1771
|
+
{ ...process.env, ...runEnv },
|
|
1575
1772
|
onClose,
|
|
1576
1773
|
repoPath,
|
|
1577
1774
|
);
|
|
@@ -1621,11 +1818,11 @@ export function runServiceCommand(
|
|
|
1621
1818
|
}
|
|
1622
1819
|
};
|
|
1623
1820
|
|
|
1624
|
-
if (
|
|
1821
|
+
if (useTab && command === "up") {
|
|
1625
1822
|
return openCommandInTab(cmdString);
|
|
1626
1823
|
}
|
|
1627
1824
|
|
|
1628
|
-
if (
|
|
1825
|
+
if (useTab && command === "restart") {
|
|
1629
1826
|
closeExistingTab();
|
|
1630
1827
|
if (requestedCmdDef) return openCommandInTab(cmdString);
|
|
1631
1828
|
|
|
@@ -1743,7 +1940,7 @@ export function runServiceCommand(
|
|
|
1743
1940
|
const proc = spawnStreaming(
|
|
1744
1941
|
cmdString,
|
|
1745
1942
|
[],
|
|
1746
|
-
{ ...process.env, ...
|
|
1943
|
+
{ ...process.env, ...runEnv },
|
|
1747
1944
|
onLine,
|
|
1748
1945
|
wrappedOnClose,
|
|
1749
1946
|
repoPath,
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A checkout's `origin` URL, read from its git config file instead of by
|
|
3
|
+
* running `git`. Callers on the web server's request path (manifest
|
|
4
|
+
* resolution for every repo without its own manifest) used to spawn
|
|
5
|
+
* `git config --get remote.origin.url` synchronously, one repo after another:
|
|
6
|
+
* ~40-100ms each, which held the event loop for a second on a cold server.
|
|
7
|
+
*
|
|
8
|
+
* Same answer as `git -C <dir> config --get remote.origin.url` for ordinary
|
|
9
|
+
* checkouts and linked worktrees: the repo is found by walking up from `dir`
|
|
10
|
+
* to the first `.git` (a directory, or a `gitdir:` file), and a worktree's
|
|
11
|
+
* config is its common dir's.
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from "fs";
|
|
14
|
+
import * as path from "path";
|
|
15
|
+
|
|
16
|
+
/** The git directory for `start` (walking up), or null outside a repo. */
|
|
17
|
+
export function findGitDir(start: string): string | null {
|
|
18
|
+
let dir = path.resolve(start);
|
|
19
|
+
for (;;) {
|
|
20
|
+
const dotGit = path.join(dir, ".git");
|
|
21
|
+
try {
|
|
22
|
+
const st = fs.statSync(dotGit);
|
|
23
|
+
if (st.isDirectory()) return dotGit;
|
|
24
|
+
if (st.isFile()) {
|
|
25
|
+
const m = /^gitdir:\s*(.+?)\s*$/m.exec(
|
|
26
|
+
fs.readFileSync(dotGit, "utf-8"),
|
|
27
|
+
);
|
|
28
|
+
if (m) return path.resolve(dir, m[1]);
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
/* no .git here: keep walking */
|
|
32
|
+
}
|
|
33
|
+
const parent = path.dirname(dir);
|
|
34
|
+
if (parent === dir) return null;
|
|
35
|
+
dir = parent;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The `url` of `[remote "origin"]` in a git config file's text, or null. */
|
|
40
|
+
export function originUrlFromConfig(text: string): string | null {
|
|
41
|
+
let inOrigin = false;
|
|
42
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
43
|
+
const line = raw.trim();
|
|
44
|
+
if (!line || line.startsWith("#") || line.startsWith(";")) continue;
|
|
45
|
+
if (line.startsWith("[")) {
|
|
46
|
+
inOrigin = /^\[\s*remote\s+"origin"\s*\]$/i.test(line);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!inOrigin) continue;
|
|
50
|
+
const m = /^url\s*=\s*(.*)$/i.exec(line);
|
|
51
|
+
if (m) {
|
|
52
|
+
const value = m[1].replace(/\s+[#;].*$/, "").trim();
|
|
53
|
+
return value.replace(/^"(.*)"$/, "$1") || null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `dir`'s origin URL, or null (not a checkout, or no origin). Never spawns. */
|
|
60
|
+
export function readOriginUrl(dir: string): string | null {
|
|
61
|
+
const gitDir = findGitDir(dir);
|
|
62
|
+
if (!gitDir) return null;
|
|
63
|
+
let common = gitDir;
|
|
64
|
+
try {
|
|
65
|
+
const rel = fs.readFileSync(path.join(gitDir, "commondir"), "utf-8").trim();
|
|
66
|
+
if (rel) common = path.resolve(gitDir, rel);
|
|
67
|
+
} catch {
|
|
68
|
+
/* not a linked worktree */
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return originUrlFromConfig(
|
|
72
|
+
fs.readFileSync(path.join(common, "config"), "utf-8"),
|
|
73
|
+
);
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -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(
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
import { journeysAppSchema } from "../../pillars/journeys/app-config.js";
|
|
12
|
+
import { SECRET_ENV_KEY_RE, secretEnvRefError } from "../secrets/secret-env.js";
|
|
12
13
|
|
|
13
14
|
export const CURRENT_SCHEMA_VERSION = 1;
|
|
14
15
|
export const KNOWN_VERSIONS = [1] as const;
|
|
@@ -69,6 +70,16 @@ const logSource = z.union([
|
|
|
69
70
|
}),
|
|
70
71
|
]);
|
|
71
72
|
|
|
73
|
+
// secret_env (R16): ENV_NAME → "<vault>/<item>/<field>", resolved through the
|
|
74
|
+
// secrets provider at up/restart and passed only in the process env.
|
|
75
|
+
const secretEnvConfig = z.record(
|
|
76
|
+
z.string().regex(SECRET_ENV_KEY_RE, "must be a valid env var name"),
|
|
77
|
+
z.string().superRefine((ref, ctx) => {
|
|
78
|
+
const err = secretEnvRefError(ref);
|
|
79
|
+
if (err) ctx.addIssue({ code: z.ZodIssueCode.custom, message: err });
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
|
|
72
83
|
// ── Service management config ─────────────────────────────────────────────────
|
|
73
84
|
//
|
|
74
85
|
// Keys for commands/logs/health are environment names (local, dev, …).
|
|
@@ -105,6 +116,7 @@ const serviceTarget = z
|
|
|
105
116
|
const serviceManagementConfig = z.object({
|
|
106
117
|
runs: z.record(z.string(), z.enum(["local", "remote"])).optional(),
|
|
107
118
|
target: serviceTarget.optional(),
|
|
119
|
+
secret_env: secretEnvConfig.optional(),
|
|
108
120
|
depends_on: z.array(dependsOnEntry).optional(),
|
|
109
121
|
commands: z.record(z.string(), z.record(z.string(), commandDef)).optional(),
|
|
110
122
|
logs: z.record(z.string(), logSource).optional(),
|
|
@@ -280,6 +292,9 @@ const composeConfig = z.object({
|
|
|
280
292
|
z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),
|
|
281
293
|
)
|
|
282
294
|
.optional(),
|
|
295
|
+
// Env vars resolved from the secrets provider into the docker compose
|
|
296
|
+
// process's env at up/restart. Never written to the override.
|
|
297
|
+
secret_env: secretEnvConfig.optional(),
|
|
283
298
|
});
|
|
284
299
|
|
|
285
300
|
// ── v1 manifest schema ────────────────────────────────────────────────────────
|
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
|
+
}
|