@olivervea/pipelines 2.0.0-beta.31 → 2.0.0-beta.32

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/README.md CHANGED
@@ -333,6 +333,7 @@ This section is everything the C# side needs in order to run the binary.
333
333
  | `OLVE_RUN` | run | Run number (integer ≥ 0). Written into produced file refs. Default 0. |
334
334
  | `OLVE_ATTEMPT` | run | Attempt number (integer ≥ 1). Written into produced file refs. Default 1. |
335
335
  | `OLVE_VERSION_STAMP` | run | Derived version, e.g. `2026.10.21.412` → `ctx.run.version`. |
336
+ | `OLVE_SECRETS` | run | The step's declared secret names, comma-separated: the values the binary masks in its output (above). Unset or empty: no masking. |
336
337
  | `OLVE_CELL` | run | Only for a matrix step: the cell, as compact JSON in axis order (`{"os":"linux","arch":"x64"}`) → `ctx.cell`. Missing, or not one of the step's cells → exit 65. `ctx.run.step` and produced file refs carry the cell's name, `build-cli[os=linux,arch=x64]`. |
337
338
  | `OLVE_CASES` | run | Only for a step inside a `select`: a JSON array of the case conditions it sits in (`["target.prod.kind=ssh"]`); `run <step>` uses it to pick the variant of a name several cases use. |
338
339
  | `<SECRET_NAME>` | per step | Each declared secret as an env var of the **same name**: `secretKeyRef` key = name. Mount only the step's `secrets` (for poll triggers, the poll's `secrets`). Child processes inherit them. The mount is optional: an unset secret leaves the variable unset and `ctx.secrets.<NAME>` throws on access, so a handler can check `process.env.<NAME>` first and skip. |
@@ -344,7 +345,9 @@ This section is everything the C# side needs in order to run the binary.
344
345
 
345
346
  `OLVE_RESULT_URL`, `OLVE_FILE_URLS`, `OLVE_FILES_TOKEN` and `OLVE_CALLBACK_URLS` are removed from the environment of child processes. URLs are never logged past `?`.
346
347
 
347
- **Secret masking** (`run`): every value of the step's declared secrets is replaced with `***` in what the step writes: `ctx.log`, `console`, anything written to stdout or stderr, the output of `ctx.exec` and `ctx.sh` commands (piped through the harness when the step has a secret; inherited otherwise), and the envelope's failure message or skip reason. Output is masked a line at a time; a multi-line value is masked line by line, and values (or lines) shorter than 4 characters are left alone. Outputs and files are data and are not masked. A process that writes to the pod's stdout around the harness (a file descriptor it opened itself) is not masked.
348
+ **Secret masking** (`run`): with `OLVE_SECRETS` set, the binary runs the step as its own child and replaces every value of those secrets with `***` in the child's stdout and stderr, a line at a time, from the pipeline's first import to its exit. That covers every way of printing: logs, `console`, direct writes, commands and the processes they start. The envelope's failure message, skip reason and links are masked too; outputs and files are data and aren't. A multi-line value is masked line by line, and values (or lines) shorter than 4 characters are left alone. Not masked: a value the step transforms before printing (escaped, encoded, cut short) and a process writing to the pod's stdout directly (`/proc/1/fd/1`).
349
+
350
+ An error nothing catches (a rejected promise, a throw in a timer) fails the step.
348
351
 
349
352
  `ctx.files.fetch` looks for a file in this order:
350
353
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olivervea/pipelines",
3
- "version": "2.0.0-beta.31",
3
+ "version": "2.0.0-beta.32",
4
4
  "description": "Olve.Pipelines v2 SDK: typed pipeline authoring, the step harness, and the build tool.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -61,8 +61,11 @@ export function generateEntry(
61
61
  writeFileSync(
62
62
  entry,
63
63
  `// GENERATED by olve-build — do not edit. Entry point of the step binary.\n` +
64
- `import pipeline from ${JSON.stringify(relPosix(gen, project.pipelineFile))};\n` +
65
- `import { runHarness } from "@olivervea/pipelines/harness";\n\n` +
64
+ `import { runHarness, supervise } from "@olivervea/pipelines/harness";\n\n` +
65
+ `// Secret masking starts before the pipeline's code runs: the masked child imports it.\n` +
66
+ `const supervised = await supervise(process.argv.slice(2));\n` +
67
+ `if (supervised !== undefined) process.exit(supervised);\n` +
68
+ `const { default: pipeline } = await import(${JSON.stringify(relPosix(gen, project.pipelineFile))});\n` +
66
69
  `process.exit(await runHarness(${config}, process.argv.slice(2)));\n`,
67
70
  );
68
71
  return entry;
package/src/ctx/index.ts CHANGED
@@ -24,8 +24,6 @@ export interface CtxOptions {
24
24
  callbackUrls?: Readonly<Record<string, string>>;
25
25
  /** A matrix step's cell (`ctx.cell`). */
26
26
  cell?: Cell;
27
- /** Commands' output goes through this process (the harness masks secrets there). */
28
- pipeOutput?: boolean;
29
27
  }
30
28
 
31
29
  export function formatLog(message: string, fields?: Record<string, unknown>): string {
@@ -146,11 +144,7 @@ export function createCtx(o: CtxOptions): Ctx {
146
144
  const sink = o.logSink ?? ((line: string) => process.stderr.write(line + '\n'));
147
145
  const log = (message: string, fields?: Record<string, unknown>) =>
148
146
  sink(formatLog(message, fields));
149
- const { sh, exec } = createSh({
150
- tmpDir: o.paths.tmp,
151
- signal: o.signal,
152
- pipeOutput: o.pipeOutput,
153
- });
147
+ const { sh, exec } = createSh({ tmpDir: o.paths.tmp, signal: o.signal });
154
148
  const filesDeps = {
155
149
  run: o.run,
156
150
  workFiles: o.paths.workFiles,
package/src/ctx/sh.ts CHANGED
@@ -79,8 +79,6 @@ export function childEnv(extra?: Record<string, string>): Record<string, string>
79
79
  export interface ShDeps {
80
80
  tmpDir: string;
81
81
  signal: AbortSignal;
82
- /** Pipe commands' output through this process's stdout/stderr (where secrets are masked). */
83
- pipeOutput?: boolean;
84
82
  shellCandidates?: readonly string[];
85
83
  }
86
84
 
@@ -152,8 +150,8 @@ export function createSh(deps: ShDeps): {
152
150
  cwd: opts.cwd,
153
151
  env: childEnv(opts.env),
154
152
  stdin: 'ignore',
155
- stdout: capture || idleSeconds || deps.pipeOutput ? 'pipe' : 'inherit',
156
- stderr: idleSeconds || deps.pipeOutput ? 'pipe' : 'inherit',
153
+ stdout: capture || idleSeconds ? 'pipe' : 'inherit',
154
+ stderr: idleSeconds ? 'pipe' : 'inherit',
157
155
  });
158
156
  } catch (e) {
159
157
  throw new Error(`cannot start ${what} (${argv[0]}): ${(e as Error).message}`);
@@ -198,11 +196,11 @@ export function createSh(deps: ShDeps): {
198
196
  pumps.push(
199
197
  pump(proc.stdout as ReadableStream<Uint8Array>, (c) => captured.push(c)),
200
198
  );
201
- else if (idleSeconds || deps.pipeOutput)
199
+ else if (idleSeconds)
202
200
  pumps.push(
203
201
  pump(proc.stdout as ReadableStream<Uint8Array>, (c) => process.stdout.write(c)),
204
202
  );
205
- if (idleSeconds || deps.pipeOutput)
203
+ if (idleSeconds)
206
204
  pumps.push(
207
205
  pump(proc.stderr as ReadableStream<Uint8Array>, (c) => process.stderr.write(c)),
208
206
  );
@@ -7,6 +7,7 @@ export {
7
7
  type HarnessIO,
8
8
  } from './main.ts';
9
9
  export { EXIT } from './exit.ts';
10
+ export { supervise } from './supervise.ts';
10
11
  export { ENV, paths, runInfoFromEnv } from './env.ts';
11
12
  export {
12
13
  executeStep,
@@ -8,7 +8,7 @@ import { isImplementation } from '../core/handlers.ts';
8
8
  import { configRefPath, isConfigRef, unionAt } from '../core/config.ts';
9
9
  import type { AnyStep } from '../core/steps.ts';
10
10
  import { cellName, parseCell } from '../core/matrix.ts';
11
- import { installMasking, maskEnvelope, maskPatterns, maskText } from './mask.ts';
11
+ import { maskEnvelope, maskPatterns } from './mask.ts';
12
12
  import type { WebhookEvent } from '../core/webhooks.ts';
13
13
  import { pipelineData, type PipelineData, type StageNode } from '../builder/pipeline.ts';
14
14
  import { createCtx, formatLog } from '../ctx/index.ts';
@@ -200,11 +200,11 @@ async function runMode(
200
200
  : undefined;
201
201
  const cancel = cancellation();
202
202
  const masks = maskPatterns(step.secrets.map((s) => process.env[s.name]));
203
- const unmask = masks.length > 0 ? installMasking(masks) : () => {};
204
- if (masks.length > 0) {
205
- const stderr = io.stderr;
206
- io = { ...io, stderr: (line) => stderr(maskText(line, masks)) };
207
- }
203
+ // An error nothing caught (a rejected promise, a throw in a timer) fails the step.
204
+ let uncaught: unknown;
205
+ const onUncaught = (e: unknown) => void (uncaught ??= e);
206
+ process.on('unhandledRejection', onUncaught);
207
+ process.on('uncaughtException', onUncaught);
208
208
  try {
209
209
  let exec: Execution;
210
210
  if (!raw.ok || (cell && !cell.ok)) {
@@ -224,7 +224,6 @@ async function runMode(
224
224
  logSink: io.stderr,
225
225
  callbackUrls: callbackUrlsFromEnv(),
226
226
  ...(cell?.ok ? { cell: cell.cell } : {}),
227
- pipeOutput: masks.length > 0,
228
227
  });
229
228
  exec = await executeStep({
230
229
  step,
@@ -235,10 +234,18 @@ async function runMode(
235
234
  log: io.stderr,
236
235
  });
237
236
  }
238
- unmask();
237
+ if (uncaught !== undefined && exec.envelope.status !== 'failed') {
238
+ const message = `step '${step.name}' failed: an error nothing caught: ${uncaught instanceof Error ? uncaught.message : String(uncaught)}`;
239
+ io.stderr(message);
240
+ exec = {
241
+ envelope: { status: 'failed', error: { kind: 'handler', message } },
242
+ exitCode: EXIT.handlerFailed,
243
+ };
244
+ }
239
245
  return await finish({ ...exec, envelope: maskEnvelope(exec.envelope, masks) }, io);
240
246
  } finally {
241
- unmask();
247
+ process.off('unhandledRejection', onUncaught);
248
+ process.off('uncaughtException', onUncaught);
242
249
  cancel.dispose();
243
250
  }
244
251
  }
@@ -1,10 +1,8 @@
1
- import { format } from 'node:util';
2
1
  import type { Envelope } from './execute.ts';
3
2
 
4
3
  /**
5
- * Masks the step's declared secret values in what it writes to stdout and stderr. Output is masked a
6
- * line at a time, so a value split across two writes is still found; a multi-line value (a key file)
7
- * is masked line by line.
4
+ * Masks secret values in text: a stream a line at a time, so a value split across two writes is
5
+ * still found; a multi-line value (a key file) line by line.
8
6
  */
9
7
 
10
8
  export const MASK = '***';
@@ -17,7 +15,7 @@ const MAX_PENDING = 64 * 1024;
17
15
  export function maskPatterns(values: readonly (string | undefined)[]): string[] {
18
16
  const patterns = new Set<string>();
19
17
  for (const value of values)
20
- for (const line of (value ?? '').split(/\r?\n/))
18
+ for (const line of (value ?? '').split(/\r\n|\r|\n/))
21
19
  if (line.trim().length >= MIN_MASKED_LENGTH) patterns.add(line.trim());
22
20
  return [...patterns].sort((a, b) => b.length - a.length);
23
21
  }
@@ -41,7 +39,12 @@ export function lineMasker(patterns: readonly string[], write: (text: string) =>
41
39
  write(maskText(pending.slice(0, end + 1), patterns));
42
40
  pending = pending.slice(end + 1);
43
41
  }
44
- if (pending.length > MAX_PENDING) this.flush();
42
+ if (pending.length > MAX_PENDING) {
43
+ // Keep enough back that a value cut here is whole in what follows.
44
+ const keep = Math.max(0, (patterns[0]?.length ?? 1) - 1);
45
+ write(maskText(pending.slice(0, pending.length - keep), patterns));
46
+ pending = pending.slice(pending.length - keep);
47
+ }
45
48
  },
46
49
  flush() {
47
50
  pending += decoder.decode();
@@ -53,6 +56,15 @@ export function lineMasker(patterns: readonly string[], write: (text: string) =>
53
56
 
54
57
  /** The envelope with its failure message or skip reason masked; outputs are data and stay as they are. */
55
58
  export function maskEnvelope(envelope: Envelope, patterns: readonly string[]): Envelope {
59
+ if (envelope.links)
60
+ envelope = {
61
+ ...envelope,
62
+ links: envelope.links.map((l) => ({
63
+ ...l,
64
+ url: maskText(l.url, patterns),
65
+ ...(l.label !== undefined ? { label: maskText(l.label, patterns) } : {}),
66
+ })),
67
+ };
56
68
  if (envelope.status === 'failed')
57
69
  return {
58
70
  ...envelope,
@@ -62,37 +74,3 @@ export function maskEnvelope(envelope: Envelope, patterns: readonly string[]): E
62
74
  return { ...envelope, reason: maskText(envelope.reason, patterns) };
63
75
  return envelope;
64
76
  }
65
-
66
- type Write = typeof process.stdout.write;
67
-
68
- /**
69
- * Routes process.stdout and process.stderr through maskers until the returned function is called,
70
- * which flushes them and restores the streams.
71
- */
72
- export function installMasking(patterns: readonly string[]): () => void {
73
- const streams = [process.stdout, process.stderr];
74
- const originals = streams.map((s) => s.write.bind(s) as Write);
75
- const maskers = originals.map((original) =>
76
- lineMasker(patterns, (text) => void original(text)),
77
- );
78
- streams.forEach((s, i) => {
79
- s.write = ((chunk: string | Uint8Array, a?: unknown, b?: unknown) => {
80
- maskers[i]!.write(chunk);
81
- const callback = typeof a === 'function' ? a : typeof b === 'function' ? b : null;
82
- if (callback) queueMicrotask(() => (callback as () => void)());
83
- return true;
84
- }) as Write;
85
- });
86
- // Bun's console writes natively, past process.stdout.write.
87
- const methods = ['log', 'info', 'debug', 'warn', 'error'] as const;
88
- const consoleOriginals = methods.map((m) => console[m]);
89
- methods.forEach((m) => {
90
- const stream = m === 'warn' || m === 'error' ? process.stderr : process.stdout;
91
- console[m] = (...args: unknown[]) => void stream.write(format(...args) + '\n');
92
- });
93
- return () => {
94
- maskers.forEach((m) => m.flush());
95
- streams.forEach((s, i) => (s.write = originals[i]!));
96
- methods.forEach((m, i) => (console[m] = consoleOriginals[i]!));
97
- };
98
- }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Secret masking for `run`: when the service names the step's secrets (`OLVE_SECRETS`), the binary
3
+ * runs itself as a child and masks the child's stdout and stderr bytes, so every way of printing is
4
+ * covered, from the pipeline's first import to its last timer. It runs before the pipeline module
5
+ * is imported.
6
+ */
7
+ import { readFileSync } from 'node:fs';
8
+ import { constants } from 'node:os';
9
+ import { lineMasker, maskPatterns } from './mask.ts';
10
+
11
+ const SUPERVISED = 'OLVE_SUPERVISED';
12
+ const FORWARDED = ['SIGTERM', 'SIGINT', 'SIGHUP'] as const;
13
+
14
+ /** The secret values this run must not print, from `OLVE_SECRETS` (names, comma-separated). */
15
+ export function secretPatterns(env: NodeJS.ProcessEnv = process.env): string[] {
16
+ const names = (env.OLVE_SECRETS ?? '').split(',').filter(Boolean);
17
+ return maskPatterns(names.map((n) => env[n]));
18
+ }
19
+
20
+ /** This process's own command line, loader included (`ld-linux … /olve/step run x` in a pod). */
21
+ function ownArgv(): string[] {
22
+ try {
23
+ const argv = readFileSync('/proc/self/cmdline', 'utf8').split('\0').filter(Boolean);
24
+ if (argv.length > 0) return argv;
25
+ } catch {
26
+ // not Linux
27
+ }
28
+ return [process.execPath, ...process.argv.slice(1)];
29
+ }
30
+
31
+ /**
32
+ * Runs the step as a masked child and answers its exit code, or undefined when this process should
33
+ * run the step itself: it is the child, the mode isn't `run`, or there is nothing to mask.
34
+ */
35
+ export async function supervise(argv: readonly string[]): Promise<number | undefined> {
36
+ if (argv[0] !== 'run' || process.env[SUPERVISED]) return undefined;
37
+ const patterns = secretPatterns();
38
+ if (patterns.length === 0) return undefined;
39
+
40
+ const child = Bun.spawn(ownArgv(), {
41
+ env: { ...process.env, [SUPERVISED]: '1' },
42
+ stdin: 'inherit',
43
+ stdout: 'pipe',
44
+ stderr: 'pipe',
45
+ });
46
+ const forward = (signal: NodeJS.Signals) => () => child.kill(signal);
47
+ for (const s of FORWARDED) process.on(s, forward(s));
48
+
49
+ const pump = async (stream: ReadableStream<Uint8Array>, fd: 1 | 2) => {
50
+ const out = fd === 1 ? process.stdout : process.stderr;
51
+ const masker = lineMasker(patterns, (text) => void out.write(text));
52
+ for await (const chunk of stream) masker.write(chunk);
53
+ masker.flush();
54
+ };
55
+ const pumps = Promise.all([pump(child.stdout, 1), pump(child.stderr, 2)]);
56
+ await child.exited;
57
+ // A detached grandchild may hold the pipes open: don't wait for it long.
58
+ await Promise.race([pumps, Bun.sleep(5000)]);
59
+ const signal = child.signalCode
60
+ ? constants.signals[child.signalCode as NodeJS.Signals]
61
+ : undefined;
62
+ return child.exitCode ?? (signal ? 128 + signal : 1);
63
+ }