@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
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Project/config come from `secrets.doppler.{project,config}` in
|
|
6
6
|
* config/den.yml, else from the directory's `doppler setup` scope,
|
|
7
|
-
* else from DOPPLER_PROJECT / DOPPLER_CONFIG / DOPPLER_TOKEN.
|
|
7
|
+
* else from DOPPLER_PROJECT / DOPPLER_CONFIG / DOPPLER_TOKEN. A scoped ref
|
|
8
|
+
* (a `secret_env` value, `<project>/<config>/<key>`) names its own.
|
|
8
9
|
*/
|
|
9
10
|
import { execFile, spawn } from "child_process";
|
|
10
11
|
import { promisify } from "util";
|
|
@@ -29,8 +30,12 @@ export class DopplerProvider implements SecretsProvider {
|
|
|
29
30
|
this.timeout = opts.timeoutMs ?? 30_000;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
33
|
+
/**
|
|
34
|
+
* `--project/--config` flags: a scoped ref's own vault/item, else the
|
|
35
|
+
* configured ones (when set explicitly).
|
|
36
|
+
*/
|
|
37
|
+
scopeArgs(ref?: SecretRef): string[] {
|
|
38
|
+
if (ref?.scoped) return ["--project", ref.vault, "--config", ref.item];
|
|
34
39
|
const a: string[] = [];
|
|
35
40
|
if (this.opts.project) a.push("--project", this.opts.project);
|
|
36
41
|
if (this.opts.config) a.push("--config", this.opts.config);
|
|
@@ -56,7 +61,7 @@ export class DopplerProvider implements SecretsProvider {
|
|
|
56
61
|
const key = this.keyFor(ref);
|
|
57
62
|
const { stdout } = await execFileAsync(
|
|
58
63
|
"doppler",
|
|
59
|
-
["secrets", "get", key, "--plain", ...this.scopeArgs()],
|
|
64
|
+
["secrets", "get", key, "--plain", ...this.scopeArgs(ref)],
|
|
60
65
|
{ timeout: this.timeout },
|
|
61
66
|
);
|
|
62
67
|
return String(stdout).trim();
|
|
@@ -77,7 +82,7 @@ export class DopplerProvider implements SecretsProvider {
|
|
|
77
82
|
await new Promise<void>((resolve, reject) => {
|
|
78
83
|
const child = spawn(
|
|
79
84
|
"doppler",
|
|
80
|
-
["secrets", "set", key, "--silent", ...this.scopeArgs()],
|
|
85
|
+
["secrets", "set", key, "--silent", ...this.scopeArgs(ref)],
|
|
81
86
|
{ stdio: ["pipe", "ignore", "pipe"] },
|
|
82
87
|
);
|
|
83
88
|
let stderr = "";
|
|
@@ -26,6 +26,7 @@ export { flatKeyFor, toEnvName, refPath } from "./keys.js";
|
|
|
26
26
|
export { DopplerProvider } from "./doppler.js";
|
|
27
27
|
export { EnvProvider } from "./env.js";
|
|
28
28
|
export { OnePasswordProvider } from "./onepassword.js";
|
|
29
|
+
export * from "./secret-env.js";
|
|
29
30
|
|
|
30
31
|
export interface SecretsConfig {
|
|
31
32
|
provider?: SecretsProviderName;
|
|
@@ -16,6 +16,7 @@ export function refPath(ref: SecretRef): string {
|
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Resolve the flat key a key/value backend should use for `ref`:
|
|
19
|
+
* 0. a scoped ref's field, as is (it is fully qualified: no overrides);
|
|
19
20
|
* 1. `ref.key` when the caller pinned one;
|
|
20
21
|
* 2. the `secrets.keys` override map (`"vault/item/field": KEY`);
|
|
21
22
|
* 3. the field itself when it already looks like an env var (API keys);
|
|
@@ -25,6 +26,7 @@ export function flatKeyFor(
|
|
|
25
26
|
ref: SecretRef,
|
|
26
27
|
overrides: Record<string, string> = {},
|
|
27
28
|
): string {
|
|
29
|
+
if (ref.scoped) return ref.field;
|
|
28
30
|
if (ref.key) return ref.key;
|
|
29
31
|
const mapped = overrides[refPath(ref)];
|
|
30
32
|
if (mapped) return mapped;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `secret_env` (R16): environment variables whose values Den resolves from
|
|
3
|
+
* the configured secrets provider when it starts or restarts a target.
|
|
4
|
+
*
|
|
5
|
+
* compose:
|
|
6
|
+
* secret_env:
|
|
7
|
+
* OPENAI_API_KEY: proj/dev_personal/OPENAI_API_KEY
|
|
8
|
+
* services:
|
|
9
|
+
* api:
|
|
10
|
+
* secret_env:
|
|
11
|
+
* SOME_KEY: vault/item/field
|
|
12
|
+
*
|
|
13
|
+
* Each value is a fully qualified `a/b/c` path, parsed into a SCOPED
|
|
14
|
+
* {@link SecretRef} (see its `scoped` field for what each provider does with
|
|
15
|
+
* it). Values only ever travel in the spawned process's env: never into the
|
|
16
|
+
* compose override, logs, activity records, MCP output, dry runs or errors.
|
|
17
|
+
* Everything here that produces text names keys and refs, never values.
|
|
18
|
+
*
|
|
19
|
+
* This module only imports types, so the manifest schema can use the parser.
|
|
20
|
+
*/
|
|
21
|
+
import type { SecretRef, SecretsProvider } from "./types.js";
|
|
22
|
+
|
|
23
|
+
/** A `secret_env` block: env var name → `a/b/c` ref. */
|
|
24
|
+
export type SecretEnvSpec = Record<string, string>;
|
|
25
|
+
|
|
26
|
+
/** Env var names Den will set (POSIX-portable). */
|
|
27
|
+
export const SECRET_ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
28
|
+
|
|
29
|
+
/** Why `raw` is not a valid secret_env ref, or null when it is. */
|
|
30
|
+
export function secretEnvRefError(raw: unknown): string | null {
|
|
31
|
+
if (typeof raw !== "string")
|
|
32
|
+
return 'must be a string "<vault>/<item>/<field>"';
|
|
33
|
+
const parts = raw.split("/");
|
|
34
|
+
if (
|
|
35
|
+
parts.length !== 3 ||
|
|
36
|
+
parts.some((p) => p.trim() === "" || p !== p.trim())
|
|
37
|
+
)
|
|
38
|
+
return `"${raw}" must be exactly three non-empty parts, "<vault>/<item>/<field>" (Doppler: <project>/<config>/<key>)`;
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Parse an `a/b/c` ref into a scoped SecretRef. Throws when malformed. */
|
|
43
|
+
export function parseSecretEnvRef(raw: string): SecretRef {
|
|
44
|
+
const err = secretEnvRefError(raw);
|
|
45
|
+
if (err) throw new Error(`secret_env ref ${err}`);
|
|
46
|
+
const [vault, item, field] = raw.split("/");
|
|
47
|
+
return { vault, item, field, scoped: true };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Problems with a whole `secret_env` block, as `KEY: message` strings ([]
|
|
52
|
+
* when it is valid). `where` prefixes each message.
|
|
53
|
+
*/
|
|
54
|
+
export function secretEnvSpecErrors(spec: unknown, where: string): string[] {
|
|
55
|
+
if (spec === undefined) return [];
|
|
56
|
+
if (spec === null || typeof spec !== "object" || Array.isArray(spec))
|
|
57
|
+
return [`${where} must be a map of ENV_NAME: "<vault>/<item>/<field>"`];
|
|
58
|
+
const out: string[] = [];
|
|
59
|
+
for (const [k, v] of Object.entries(spec)) {
|
|
60
|
+
if (!SECRET_ENV_KEY_RE.test(k))
|
|
61
|
+
out.push(`${where}.${k}: "${k}" is not a valid env var name`);
|
|
62
|
+
const err = secretEnvRefError(v);
|
|
63
|
+
if (err) out.push(`${where}.${k}: ref ${err}`);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The validated block, or throws listing every problem. */
|
|
69
|
+
export function assertSecretEnvSpec(
|
|
70
|
+
spec: unknown,
|
|
71
|
+
where: string,
|
|
72
|
+
): SecretEnvSpec | undefined {
|
|
73
|
+
if (spec === undefined) return undefined;
|
|
74
|
+
const errors = secretEnvSpecErrors(spec, where);
|
|
75
|
+
if (errors.length) throw new Error(errors.join("\n"));
|
|
76
|
+
return { ...(spec as SecretEnvSpec) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One key that could not be resolved. Never carries a value. */
|
|
80
|
+
export interface SecretEnvMissing {
|
|
81
|
+
key: string;
|
|
82
|
+
ref: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type SecretEnvResolution =
|
|
86
|
+
| { ok: true; env: Record<string, string> }
|
|
87
|
+
| { ok: false; missing: SecretEnvMissing[]; message: string };
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The failure text for unresolved keys: key, ref and provider, never a value
|
|
91
|
+
* and never the provider's own error output (which could echo one).
|
|
92
|
+
*/
|
|
93
|
+
export function describeSecretEnvMissing(
|
|
94
|
+
target: string,
|
|
95
|
+
missing: SecretEnvMissing[],
|
|
96
|
+
provider: string,
|
|
97
|
+
): string {
|
|
98
|
+
const lines = missing.map(
|
|
99
|
+
(m) => ` ${m.key} (ref ${m.ref}, provider ${provider}): not resolved`,
|
|
100
|
+
);
|
|
101
|
+
return (
|
|
102
|
+
`${target}: ${missing.length} secret_env key(s) could not be resolved; not starting it:\n` +
|
|
103
|
+
lines.join("\n")
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve every key of `spec` through `provider`. All or nothing: when any
|
|
109
|
+
* key is missing (unset, empty, unreadable) the result is a failure naming
|
|
110
|
+
* each missing key, and no partial env is returned.
|
|
111
|
+
*/
|
|
112
|
+
export async function resolveSecretEnv(
|
|
113
|
+
spec: SecretEnvSpec | undefined,
|
|
114
|
+
provider: SecretsProvider,
|
|
115
|
+
target: string,
|
|
116
|
+
): Promise<SecretEnvResolution> {
|
|
117
|
+
const entries = Object.entries(spec ?? {});
|
|
118
|
+
if (!entries.length) return { ok: true, env: {} };
|
|
119
|
+
const results = await Promise.all(
|
|
120
|
+
entries.map(async ([key, raw]) => {
|
|
121
|
+
if (secretEnvRefError(raw)) return { key, raw, value: null };
|
|
122
|
+
let value: string | null = null;
|
|
123
|
+
try {
|
|
124
|
+
value = await provider.tryGet(parseSecretEnvRef(raw));
|
|
125
|
+
} catch {
|
|
126
|
+
value = null;
|
|
127
|
+
}
|
|
128
|
+
return { key, raw, value };
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
const missing = results
|
|
132
|
+
.filter((r) => !r.value)
|
|
133
|
+
.map((r) => ({ key: r.key, ref: String(r.raw) }));
|
|
134
|
+
if (missing.length)
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
missing,
|
|
138
|
+
message: describeSecretEnvMissing(target, missing, provider.name),
|
|
139
|
+
};
|
|
140
|
+
return {
|
|
141
|
+
ok: true,
|
|
142
|
+
env: Object.fromEntries(results.map((r) => [r.key, r.value as string])),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The keys of a block, sorted: what a dry run or status may show. */
|
|
147
|
+
export function secretEnvKeys(spec: SecretEnvSpec | undefined): string[] {
|
|
148
|
+
return Object.keys(spec ?? {}).sort();
|
|
149
|
+
}
|
|
@@ -22,6 +22,16 @@ export interface SecretRef {
|
|
|
22
22
|
* `flatKeyFor`.
|
|
23
23
|
*/
|
|
24
24
|
key?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Set on a fully qualified ref (a `secret_env` value, `a/b/c`): every part
|
|
27
|
+
* names a real location and no provider config fills it in.
|
|
28
|
+
* - Doppler: vault = `--project`, item = `--config`, field = the key
|
|
29
|
+
* (`secrets.doppler` and `secrets.keys` are not consulted).
|
|
30
|
+
* - 1Password: vault / item / field as for any ref.
|
|
31
|
+
* - env: field is the env var name (`secrets.keys` is not consulted).
|
|
32
|
+
* Unscoped refs (the default) keep the provider's configured scope.
|
|
33
|
+
*/
|
|
34
|
+
scoped?: true;
|
|
25
35
|
}
|
|
26
36
|
|
|
27
37
|
export type SecretsProviderName = "doppler" | "1password" | "env";
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "../environment-manager/workspaces.js";
|
|
18
18
|
import { readManifest } from "../manifest/read-manifest.js";
|
|
19
19
|
import type { ManifestComposeConfig } from "../types/index.js";
|
|
20
|
+
import { assertSecretEnvSpec } from "../secrets/secret-env.js";
|
|
20
21
|
import { assertStackName, parseTarget } from "./render.js";
|
|
21
22
|
import type { StackConfig } from "./types.js";
|
|
22
23
|
|
|
@@ -41,6 +42,10 @@ function toStack(
|
|
|
41
42
|
const routes = raw.routes ?? {};
|
|
42
43
|
Object.values(routes).forEach(parseTarget);
|
|
43
44
|
const files = list(raw.files, `${where}.files`);
|
|
45
|
+
const secretEnv =
|
|
46
|
+
"secret_env" in raw
|
|
47
|
+
? assertSecretEnvSpec(raw.secret_env, `${where}.secret_env`)
|
|
48
|
+
: undefined;
|
|
44
49
|
return {
|
|
45
50
|
name,
|
|
46
51
|
repo,
|
|
@@ -69,6 +74,7 @@ function toStack(
|
|
|
69
74
|
),
|
|
70
75
|
}
|
|
71
76
|
: {}),
|
|
77
|
+
...(secretEnv && Object.keys(secretEnv).length ? { secretEnv } : {}),
|
|
72
78
|
};
|
|
73
79
|
}
|
|
74
80
|
|
|
@@ -16,6 +16,12 @@ import {
|
|
|
16
16
|
renderStackRoutes,
|
|
17
17
|
routedNetworks,
|
|
18
18
|
} from "./render.js";
|
|
19
|
+
import {
|
|
20
|
+
resolveSecretEnv,
|
|
21
|
+
secretEnvKeys,
|
|
22
|
+
type SecretEnvResolution,
|
|
23
|
+
} from "../secrets/secret-env.js";
|
|
24
|
+
import type { SecretsProvider } from "../secrets/types.js";
|
|
19
25
|
import type { ResolvedProject, StackConfig } from "./types.js";
|
|
20
26
|
|
|
21
27
|
function run(
|
|
@@ -117,6 +123,35 @@ function composeEnv(ctx: StackContext, s: StackConfig): NodeJS.ProcessEnv {
|
|
|
117
123
|
};
|
|
118
124
|
}
|
|
119
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Resolve a stack's `secret_env` through `provider` (all or nothing). Call
|
|
128
|
+
* it before anything starts or stops, and hand the env to stackUp /
|
|
129
|
+
* stackRestartService. Nothing in the result's message carries a value.
|
|
130
|
+
*/
|
|
131
|
+
export function resolveStackSecretEnv(
|
|
132
|
+
s: StackConfig,
|
|
133
|
+
provider: SecretsProvider,
|
|
134
|
+
): Promise<SecretEnvResolution> {
|
|
135
|
+
return resolveSecretEnv(s.secretEnv, provider, `stack ${s.name}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The resolved secrets for a compose process that starts containers. Fails
|
|
140
|
+
* closed: a stack that declares `secret_env` is never started without every
|
|
141
|
+
* key (that is how a plain restart used to drop one).
|
|
142
|
+
*/
|
|
143
|
+
function secretComposeEnv(
|
|
144
|
+
s: StackConfig,
|
|
145
|
+
secretEnv: Record<string, string> | undefined,
|
|
146
|
+
): Record<string, string> {
|
|
147
|
+
const missing = secretEnvKeys(s.secretEnv).filter((k) => !secretEnv?.[k]);
|
|
148
|
+
if (missing.length)
|
|
149
|
+
throw new Error(
|
|
150
|
+
`stack ${s.name}: secret_env ${missing.join(", ")} not resolved; not starting it`,
|
|
151
|
+
);
|
|
152
|
+
return secretEnv ?? {};
|
|
153
|
+
}
|
|
154
|
+
|
|
120
155
|
function baseArgs(ctx: StackContext, s: StackConfig): string[] {
|
|
121
156
|
return [
|
|
122
157
|
"compose",
|
|
@@ -168,8 +203,15 @@ function running(container: string): boolean {
|
|
|
168
203
|
export function stackUp(
|
|
169
204
|
ctx: StackContext,
|
|
170
205
|
s: StackConfig,
|
|
171
|
-
opts: {
|
|
206
|
+
opts: {
|
|
207
|
+
build?: boolean;
|
|
208
|
+
/** The stack's resolved `secret_env` (see resolveStackSecretEnv). */
|
|
209
|
+
secretEnv?: Record<string, string>;
|
|
210
|
+
} = {},
|
|
172
211
|
) {
|
|
212
|
+
const secrets = secretComposeEnv(s, opts.secretEnv);
|
|
213
|
+
// `compose config` runs without them: its JSON (which feeds the override)
|
|
214
|
+
// must not carry a secret.
|
|
173
215
|
const resolved = resolve(ctx, s);
|
|
174
216
|
// Provider side of each link: its referenced services join a private network.
|
|
175
217
|
const needs = providerServices(s);
|
|
@@ -200,7 +242,11 @@ export function stackUp(
|
|
|
200
242
|
...(opts.build ? ["--build"] : []),
|
|
201
243
|
"--remove-orphans",
|
|
202
244
|
],
|
|
203
|
-
{
|
|
245
|
+
{
|
|
246
|
+
cwd: repoDir(s),
|
|
247
|
+
env: { ...composeEnv(ctx, s), ...secrets },
|
|
248
|
+
inherit: true,
|
|
249
|
+
},
|
|
204
250
|
);
|
|
205
251
|
// This stack as a provider: consumers already running keep their link
|
|
206
252
|
// network, but a restarted provider's new containers must rejoin it.
|
|
@@ -274,14 +320,16 @@ export function stackRestartService(
|
|
|
274
320
|
ctx: StackContext,
|
|
275
321
|
s: StackConfig,
|
|
276
322
|
service: string,
|
|
323
|
+
opts: { secretEnv?: Record<string, string> } = {},
|
|
277
324
|
): void {
|
|
325
|
+
const secrets = secretComposeEnv(s, opts.secretEnv);
|
|
278
326
|
const override = path.join(stackDir(ctx, s), "override.yml");
|
|
279
327
|
const args = fs.existsSync(override)
|
|
280
328
|
? overrideArgs(ctx, s)
|
|
281
329
|
: baseArgs(ctx, s);
|
|
282
330
|
must("docker", [...args, "restart", service], {
|
|
283
331
|
cwd: repoDir(s),
|
|
284
|
-
env: composeEnv(ctx, s),
|
|
332
|
+
env: { ...composeEnv(ctx, s), ...secrets },
|
|
285
333
|
inherit: true,
|
|
286
334
|
});
|
|
287
335
|
}
|
|
@@ -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
|
|
|
@@ -97,3 +102,106 @@ export function stackStatuses(
|
|
|
97
102
|
return status;
|
|
98
103
|
});
|
|
99
104
|
}
|
|
105
|
+
|
|
106
|
+
/** A running Compose project started from one of a workspace's repos. */
|
|
107
|
+
export interface WorkspaceStack {
|
|
108
|
+
/** The Den stack name, or the project name for a stack the repo runs. */
|
|
109
|
+
name: string;
|
|
110
|
+
repo: string;
|
|
111
|
+
project: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A running Compose project and the directory it was started from. */
|
|
115
|
+
export interface RunningProject {
|
|
116
|
+
project: string;
|
|
117
|
+
/** `com.docker.compose.project.working_dir`; "" when unlabelled. */
|
|
118
|
+
workingDir: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The Compose projects with running containers, from one `docker ps`; null
|
|
123
|
+
* when docker can't be asked (not installed, daemon down, too slow).
|
|
124
|
+
*/
|
|
125
|
+
export function runningComposeProjects(): RunningProject[] | null {
|
|
126
|
+
const r = spawnSync(
|
|
127
|
+
"docker",
|
|
128
|
+
[
|
|
129
|
+
"ps",
|
|
130
|
+
"--format",
|
|
131
|
+
'{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.project.working_dir"}}',
|
|
132
|
+
],
|
|
133
|
+
{ encoding: "utf-8", timeout: 5_000, stdio: ["ignore", "pipe", "ignore"] },
|
|
134
|
+
);
|
|
135
|
+
if (r.error || r.status !== 0) return null;
|
|
136
|
+
const seen = new Map<string, RunningProject>();
|
|
137
|
+
for (const line of (r.stdout ?? "").split("\n")) {
|
|
138
|
+
const [project = "", workingDir = ""] = line.trim().split("\t");
|
|
139
|
+
if (project && !seen.has(project))
|
|
140
|
+
seen.set(project, { project, workingDir: workingDir.trim() });
|
|
141
|
+
}
|
|
142
|
+
return [...seen.values()];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `p` and its realpath (compose may record either form). */
|
|
146
|
+
function pathForms(p: string): string[] {
|
|
147
|
+
try {
|
|
148
|
+
const real = fs.realpathSync(p);
|
|
149
|
+
return real === p ? [p] : [p, real];
|
|
150
|
+
} catch {
|
|
151
|
+
return [p];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* `workspace`'s running compose stacks: what `workspace deactivate` leaves
|
|
157
|
+
* up, since it releases only the daemons Den tracks. A project counts when
|
|
158
|
+
* it is a Den stack one of the workspace's repos declares (`compose:`, named
|
|
159
|
+
* by projectName), or when Compose started it from inside one of the
|
|
160
|
+
* workspace's checkouts (its working_dir label): the stacks a repo runs
|
|
161
|
+
* itself, e.g. from `make up`. Null when docker could not be asked.
|
|
162
|
+
*/
|
|
163
|
+
export function runningWorkspaceStacks(
|
|
164
|
+
workspace: string,
|
|
165
|
+
running: () => RunningProject[] | null = runningComposeProjects,
|
|
166
|
+
): WorkspaceStack[] | null {
|
|
167
|
+
const ws = getWorkspaceByName(workspace);
|
|
168
|
+
if (!ws) return [];
|
|
169
|
+
const denStacks = new Map<string, { name: string; repo: string }>();
|
|
170
|
+
const checkouts: { repo: string; forms: string[] }[] = [];
|
|
171
|
+
try {
|
|
172
|
+
const { stackWorkspace: suffix } = resolveStackWorkspaces(workspace);
|
|
173
|
+
for (const s of Object.values(readManifestStacks(workspace)))
|
|
174
|
+
denStacks.set(projectName(s.name, suffix), {
|
|
175
|
+
name: s.name,
|
|
176
|
+
repo: s.repo,
|
|
177
|
+
});
|
|
178
|
+
const seen = new Set<string>();
|
|
179
|
+
for (const svc of listWorkspaceServices(ws.config, readCatalog(), ws.name))
|
|
180
|
+
if (svc.repoPath && !seen.has(svc.repoPath)) {
|
|
181
|
+
seen.add(svc.repoPath);
|
|
182
|
+
checkouts.push({ repo: svc.repoName, forms: pathForms(svc.repoPath) });
|
|
183
|
+
}
|
|
184
|
+
} catch {
|
|
185
|
+
return null; // an unreadable manifest or workspace name: can't tell
|
|
186
|
+
}
|
|
187
|
+
if (denStacks.size === 0 && checkouts.length === 0) return [];
|
|
188
|
+
const up = running();
|
|
189
|
+
if (!up) return null;
|
|
190
|
+
const inside = (dir: string, root: string): boolean =>
|
|
191
|
+
dir === root || dir.startsWith(`${root}${path.sep}`);
|
|
192
|
+
const out: WorkspaceStack[] = [];
|
|
193
|
+
for (const { project, workingDir } of up) {
|
|
194
|
+
const den = denStacks.get(project);
|
|
195
|
+
if (den) {
|
|
196
|
+
out.push({ ...den, project });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (!workingDir) continue;
|
|
200
|
+
const dirs = pathForms(workingDir);
|
|
201
|
+
const owner = checkouts.find((c) =>
|
|
202
|
+
c.forms.some((root) => dirs.some((d) => inside(d, root))),
|
|
203
|
+
);
|
|
204
|
+
if (owner) out.push({ name: project, repo: owner.repo, project });
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* uses: [ragstack] # start first + private link network
|
|
11
11
|
* env: # compose ${VAR} substitution
|
|
12
12
|
* RAG_PROXY_TARGET: "http://{ragstack.ragstack-server}:80"
|
|
13
|
+
* secret_env: # from the secrets provider, at up/restart
|
|
14
|
+
* OPENAI_API_KEY: proj/dev/OPENAI_API_KEY
|
|
13
15
|
*
|
|
14
16
|
* `{stack.service}` expands to that service's container name.
|
|
15
17
|
*/
|
|
@@ -36,6 +38,12 @@ export interface StackConfig {
|
|
|
36
38
|
* `${VAR}` substitution) cannot change.
|
|
37
39
|
*/
|
|
38
40
|
serviceEnv?: Record<string, Record<string, string>>;
|
|
41
|
+
/**
|
|
42
|
+
* `secret_env` (R16): env var → `<vault>/<item>/<field>` ref. Resolved
|
|
43
|
+
* through the secrets provider by `stack up` / `stack restart` and passed
|
|
44
|
+
* only in the docker compose process's env, never in the override file.
|
|
45
|
+
*/
|
|
46
|
+
secretEnv?: Record<string, string>;
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
/** What `docker compose config --format json` tells us about a service. */
|
|
@@ -276,6 +276,12 @@ export interface ServiceManagementConfig {
|
|
|
276
276
|
runs?: Partial<Record<Environment, ServiceRunsValue>>;
|
|
277
277
|
/** Where a remote service runs: the env, an account label, a URL to probe. */
|
|
278
278
|
target?: ServiceTargetConfig;
|
|
279
|
+
/**
|
|
280
|
+
* R16: env var → `<vault>/<item>/<field>` ref, resolved through the
|
|
281
|
+
* secrets provider when this service's up/restart command runs, and set
|
|
282
|
+
* only in that process's env (see src/shared/secrets/secret-env.ts).
|
|
283
|
+
*/
|
|
284
|
+
secret_env?: Record<string, string>;
|
|
279
285
|
}
|
|
280
286
|
|
|
281
287
|
export type ServiceRunsValue = "local" | "remote";
|
|
@@ -572,6 +578,15 @@ export interface ManifestComposeConfig {
|
|
|
572
578
|
profiles?: string[];
|
|
573
579
|
/** service → replacement healthcheck `test` (written to den's override, not the repo). */
|
|
574
580
|
healthchecks?: Record<string, string[]>;
|
|
581
|
+
/** service → env vars set on that service in the override (wins by key). */
|
|
582
|
+
service_env?: Record<string, Record<string, string | number | boolean>>;
|
|
583
|
+
/**
|
|
584
|
+
* R16: env var → `<vault>/<item>/<field>` ref, resolved through the
|
|
585
|
+
* secrets provider at stack up/restart and set only in the docker compose
|
|
586
|
+
* process's env (for the compose file's `${VAR}` / pass-through
|
|
587
|
+
* `environment:` entries). Never written to the override.
|
|
588
|
+
*/
|
|
589
|
+
secret_env?: Record<string, string>;
|
|
575
590
|
}
|
|
576
591
|
|
|
577
592
|
export interface MigrationResult {
|