@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.
@@ -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 || !isPrimary(manifest, subsystem)) return {};
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: { build?: boolean } = {},
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
- { cwd: repoDir(s), env: composeEnv(ctx, s), inherit: true },
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: [],