@monster-orange/den 0.2.1 → 0.2.3
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 +129 -0
- package/README.md +1 -1
- package/docs/distributions.md +116 -1
- package/package.json +1 -1
- package/scripts/setup-secrets.ts +11 -0
- package/service/install.sh +14 -2
- package/src/shared/den-config.ts +69 -0
- package/src/shared/environment-manager/service-runner.ts +370 -6
- package/src/shared/manifest/schema.ts +15 -0
- package/src/shared/manifest/validate-layers.ts +40 -1
- 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/manifest-commands.ts +11 -1
- package/src/shared/stacks/runtime.ts +70 -2
- package/src/shared/stacks/types.ts +8 -0
- package/src/shared/types/index.ts +15 -0
- package/src/ui/cli/flag-parser.ts +2 -1
- package/src/ui/cli/index.ts +216 -34
- package/src/ui/cli/service-command.ts +131 -0
- package/src/ui/cli/stack-command.ts +55 -4
- package/src/ui/mcp/connect.ts +3 -1
- package/src/ui/mcp/server.ts +82 -1
- package/src/ui/server/index.ts +3 -0
- package/src/ui/server/lifecycle-exec.ts +23 -0
- package/src/ui/server/workspace-routes.ts +32 -0
|
@@ -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
|
|
|
@@ -31,13 +31,23 @@ export function composeCommands(
|
|
|
31
31
|
repoName: string,
|
|
32
32
|
workspace: string,
|
|
33
33
|
): Record<string, CommandDef> {
|
|
34
|
-
if (!manifest.compose
|
|
34
|
+
if (!manifest.compose) return {};
|
|
35
35
|
// `docker compose up -d` returns once the containers are started, so these
|
|
36
36
|
// finish on their own: never daemonize them.
|
|
37
37
|
const stack = (sub: string) => ({
|
|
38
38
|
cmd: `${denBin()} stack ${sub} ${manifestStackName(manifest, repoName)} -w ${workspace}`,
|
|
39
39
|
detachable: false,
|
|
40
40
|
});
|
|
41
|
+
// Any other manifest service names a compose service: it restarts on its
|
|
42
|
+
// own (`docker compose restart`, same container and env). Up and down stay
|
|
43
|
+
// the stack's, through the primary service.
|
|
44
|
+
if (!isPrimary(manifest, subsystem))
|
|
45
|
+
return {
|
|
46
|
+
restart: {
|
|
47
|
+
...stack("restart"),
|
|
48
|
+
cmd: `${stack("restart").cmd} --service ${JSON.stringify(subsystem)}`,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
41
51
|
return {
|
|
42
52
|
up: stack("up"),
|
|
43
53
|
down: stack("down"),
|
|
@@ -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.
|
|
@@ -266,6 +312,28 @@ export function stackDown(ctx: StackContext, s: StackConfig): void {
|
|
|
266
312
|
}
|
|
267
313
|
}
|
|
268
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Restart one compose service of a running stack in place (`docker compose
|
|
317
|
+
* restart`): the same container, so the same env; nothing is recreated.
|
|
318
|
+
*/
|
|
319
|
+
export function stackRestartService(
|
|
320
|
+
ctx: StackContext,
|
|
321
|
+
s: StackConfig,
|
|
322
|
+
service: string,
|
|
323
|
+
opts: { secretEnv?: Record<string, string> } = {},
|
|
324
|
+
): void {
|
|
325
|
+
const secrets = secretComposeEnv(s, opts.secretEnv);
|
|
326
|
+
const override = path.join(stackDir(ctx, s), "override.yml");
|
|
327
|
+
const args = fs.existsSync(override)
|
|
328
|
+
? overrideArgs(ctx, s)
|
|
329
|
+
: baseArgs(ctx, s);
|
|
330
|
+
must("docker", [...args, "restart", service], {
|
|
331
|
+
cwd: repoDir(s),
|
|
332
|
+
env: { ...composeEnv(ctx, s), ...secrets },
|
|
333
|
+
inherit: true,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
269
337
|
export function stackPs(ctx: StackContext, s: StackConfig): string {
|
|
270
338
|
return run(
|
|
271
339
|
"docker",
|
|
@@ -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 {
|
|
@@ -176,8 +176,9 @@ const COMMAND_FLAGS: Record<string, readonly string[]> = {
|
|
|
176
176
|
"--force",
|
|
177
177
|
"-A",
|
|
178
178
|
"--app",
|
|
179
|
+
"--with-deps",
|
|
179
180
|
],
|
|
180
|
-
restart: ["-A", "--app"],
|
|
181
|
+
restart: ["-A", "--app", "-n", "--dry-run"],
|
|
181
182
|
logs: [],
|
|
182
183
|
setup: ["-f", "--force"],
|
|
183
184
|
sync: [],
|