@olivervea/pipelines 2.0.0-beta.33 → 2.0.0-beta.35
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 +8 -3
- package/manifest.schema.json +5 -0
- package/package.json +1 -1
- package/src/core/handlers.ts +43 -0
- package/src/ctx/sh.ts +14 -3
- package/src/describe/manifest-schema.ts +6 -0
- package/src/describe/step.ts +4 -0
- package/src/dev/cli.ts +5 -3
- package/src/harness/env.ts +5 -5
- package/src/harness/execute.ts +77 -1
- package/src/harness/main.ts +18 -5
- package/src/harness/supervise.ts +3 -2
- package/src/index.ts +4 -0
package/README.md
CHANGED
|
@@ -98,6 +98,7 @@ Rules:
|
|
|
98
98
|
- **`optional(M)`** means "maybe skipped": `M` must still have an earlier producer.
|
|
99
99
|
- **`cells(M)`** reads every cell of a matrix step whose output is `M` (see "Matrix steps").
|
|
100
100
|
- **`external(M, { title, description?, from? })`** is answered from outside the run; `from` lists `callback()` (another system POSTs to the URL an earlier step gets from `ctx.inputUrl(step, input)`), `poll({ url, valuePath, intervalSeconds? })` and `person()` (the default). It needs no producer and no mapper entry; the step waits until it is answered, then the value (validated against `M`) is in its input like any other. Failure handlers can't have one. `link: { input, path?, label? }` is the link shown on the step while it waits: `input` is one of the same step's regular (non-external) inputs (typed to their names), `path` a dot path inside its value (`url` → `input.review.url`; none = the value itself is the URL), `label` defaults to `title`. A value that is not an absolute http(s) URL sets no link; the step waits all the same.
|
|
101
|
+
- **`implement(step, handler, { preflight: async (input, ctx) => … })`** runs before a step with an external input waits: it hands the work and `ctx.inputUrl(step, input)` to whatever answers, and its `ctx.link` links join the waiting links. It gets the step's regular inputs only, and the handler's `ctx` without `output` and `skip`. Throwing fails the attempt without waiting. Only a step with an external input takes one (a type error, and a describe problem otherwise).
|
|
101
102
|
|
|
102
103
|
### Config
|
|
103
104
|
|
|
@@ -241,7 +242,7 @@ export const deployBeta = implement(step({ name: "deploy-beta", /* … */ }), as
|
|
|
241
242
|
| `ctx.` | |
|
|
242
243
|
|---|---|
|
|
243
244
|
| `sh(cmd \| script, { env, cwd, args })`, `sh.text(…)` | Runs a command string, or an embedded script, through the image's shell. The script is copied to `$OLVE_DIR/tmp/scripts/` first. The shell is the first of `/bin/sh`, `/busybox/sh`, `/bin/bash` that exists; a shell-less image gets a clear `ShellNotFoundError`. A script's shebang is honoured when its interpreter exists. A string counts as an embedded script when it is a `/$bunfs/…` path, or an absolute path to an existing `*.sh` or `*.bash` file. Pass `{ script }` to force script mode. `args` are the script's arguments, or `$1`, `$2`… of a command string. A non-zero exit throws a `CommandError` that carries `exitCode`. |
|
|
244
|
-
| `exec([argv…], { env, cwd, idleTimeoutSeconds })` | Runs a binary directly, with no shell (works on distroless). `idleTimeoutSeconds` (also on `sh`) is an inactivity watchdog: no stdout/stderr output for that long → SIGTERM, SIGKILL 10 s later, and a `CommandStalledError`. |
|
|
245
|
+
| `exec([argv…], { env, cwd, idleTimeoutSeconds })` | Runs a binary directly, with no shell (works on distroless). `idleTimeoutSeconds` (also on `sh`) is an inactivity watchdog: no stdout/stderr output for that long → SIGTERM, SIGKILL 10 s later, and a `CommandStalledError`. Once a command exits, its output is read for at most 5 s more, so a leftover background child holding the pipes doesn't hang the step. |
|
|
245
246
|
| `readText(path)`, `readJson(path)` | Paths are relative to the cwd. |
|
|
246
247
|
| `files.fetch(ref)` | Downloads a file reference and verifies its sha256. Directory refs are extracted. Returns a local path. |
|
|
247
248
|
| `output.write(name, content)`, `output.file(path, { name })`, `output.dir(path, { name })` | Adds a file to the step's output and returns its `FileRef`. A directory becomes a deterministic tar. |
|
|
@@ -308,9 +309,10 @@ This section is everything the C# side needs in order to run the binary.
|
|
|
308
309
|
|---|---|---|---|
|
|
309
310
|
| `describe [--sentinels]` | the binary itself | stdout: the manifest (canonical JSON) | With `--sentinels`, stdout gets the manifest between `---OLVE-MANIFEST-BEGIN---` and `---OLVE-MANIFEST-END---`. Needs no env and no files. |
|
|
310
311
|
| `run <step>` | `$OLVE_DIR/input.json` | `$OLVE_DIR/work/output.json`, `$OLVE_DIR/work/files/…` | Steps in stages and failure-handler steps. A matrix step's cell runs as `run <step>` (the step's own name) with `OLVE_CELL`. |
|
|
312
|
+
| `preflight <step>` | `$OLVE_DIR/input.json` | `$OLVE_DIR/work/output.json` | A step's preflight (manifest `preflight: true`). Validates only the step's regular inputs (external values already in the input are ignored) and writes `{"status":"succeeded","output":null}` with the preflight's `links`, or a failed envelope whose message names the preflight. Keeps no files; the service sets no `OLVE_RESULT_URL` and reads the envelope from the log. |
|
|
311
313
|
| `map-trigger <trigger>` | `$OLVE_DIR/input.json` (the event) | `$OLVE_DIR/work/output.json` | `<trigger>` is the trigger's `name` from the manifest. |
|
|
312
314
|
|
|
313
|
-
`run` and `map-trigger` also print the envelope on stdout, as one compact JSON line between `---OLVE-RESULT-BEGIN---` and `---OLVE-RESULT-END---`. The block always comes last. Use it as a fallback channel from the pod log. User output and stderr logs also go to the pod log, so never parse anything outside the sentinels.
|
|
315
|
+
`run`, `preflight` and `map-trigger` also print the envelope on stdout, as one compact JSON line between `---OLVE-RESULT-BEGIN---` and `---OLVE-RESULT-END---`. The block always comes last. Use it as a fallback channel from the pod log. User output and stderr logs also go to the pod log, so never parse anything outside the sentinels.
|
|
314
316
|
|
|
315
317
|
### Files (`$OLVE_DIR`, default `/olve`)
|
|
316
318
|
|
|
@@ -343,7 +345,7 @@ This section is everything the C# side needs in order to run the binary.
|
|
|
343
345
|
| `OLVE_FILES_BASE_URL` (+ `OLVE_FILES_TOKEN`) | optional | Fallback: `GET <base>/<run>/<step>/<attempt>/<object path>` with an optional `Authorization: Bearer <token>`. |
|
|
344
346
|
| `OLVE_FILES_DIR` | dev | Local pool laid out as `<run>/<step>/<attempt>/files/<object path>`. |
|
|
345
347
|
|
|
346
|
-
|
|
348
|
+
The variables in this table (the `OLVE_*` contract, credentials included) are removed from the environment of child processes `ctx.sh` and `ctx.exec` start; declared secrets are not. URLs are never logged past `?`.
|
|
347
349
|
|
|
348
350
|
**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
351
|
|
|
@@ -423,6 +425,7 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
|
|
|
423
425
|
|
|
424
426
|
An object whose only key is `ref` (with a string value) or `const` is a tag. Describe never emits a nested object with exactly that key set. Model-matched inputs are emitted already resolved (`{"ref": "build"}`).
|
|
425
427
|
- **Step `externalInputs`** (when present) maps each external input key to `{model, title, description, from, link?}`, `from` being `{kind: "person"}`, `{kind: "callback"}` or `{kind: "poll", url, valuePath, intervalSeconds}`, and `link` `{input, path?, label?}` (omitted when not set, as are its `path` and `label`). Those keys are not in `input` or `inputTypes`; the service merges the answers into `input.json`.
|
|
428
|
+
- **Step `preflight: true`** (omitted otherwise): the step runs `preflight <step>` before it waits for its external inputs.
|
|
426
429
|
- **`inputTypes`** declares each input key: `{model, optional}`, `{model, optional: false, cells: true}` for a `cells` input (mapped with a whole-step ref to a matrix step whose output is that model), or `{schema: {output, input?}}` for a plain schema.
|
|
427
430
|
- **Step `matrix`** (matrix steps only, never a failure handler) is `{axes: [{name, values}…], exclude?: [{axis: value…}…], failure: {kind: "keepRunning" | "cascadeFailure"}, maxParallel?}`. `axes` is a list because canonical JSON sorts keys; `exclude` is omitted when empty; `failure.kind` is always written. All of it is part of what the step declares.
|
|
428
431
|
- **`codeHash`** is the build's identity: `sha256(canonical {bundle, harness})`. `bundle` hashes the entry point bundled with everything it imports, with `@olivervea/pipelines` external. It is tree-shaken and includes embedded scripts and dependencies, so any change to code in use changes it, and with it the manifest, which makes a new pipeline version. A step version covers what the step declares: image, `inputTypes`/model versions, `output` model version, `secrets`, `configEntries` (and those entries' schemas), `input` mappings and `config`.
|
|
@@ -446,6 +449,7 @@ Describe runs these checks; reconcile should re-run the manifest-level ones:
|
|
|
446
449
|
- mapper source analysis and recording, unknown fields in refs, mapper keys that are missing or extra;
|
|
447
450
|
- `when` on an unknown or non-literal field, or with an invalid value;
|
|
448
451
|
- an external input's `link` naming anything but one of the step's regular inputs, an empty path segment, a label that is empty or over 100 characters;
|
|
452
|
+
- a preflight on a step without an external input;
|
|
449
453
|
- a poll header referencing an undeclared secret, duplicate triggers;
|
|
450
454
|
- conflicting secret descriptions;
|
|
451
455
|
- config names, one description and schema per config name, a step receiving a `secretRef` without declaring its secret, and input keys starting with `$` (reserved);
|
|
@@ -490,6 +494,7 @@ The manifest is the text between the sentinels in the pod log. The manifest text
|
|
|
490
494
|
```
|
|
491
495
|
olve-dev describe <dir> [--sentinels] # same bytes as the compiled binary's describe
|
|
492
496
|
olve-dev run <dir> <step> [--input-file f] [--config-file f] [--olve-dir d] [--secret NAME=value]...
|
|
497
|
+
olve-dev preflight <dir> <step> [same options as run]
|
|
493
498
|
olve-dev map-trigger <dir> <trigger> --event-file f [--olve-dir d]
|
|
494
499
|
```
|
|
495
500
|
|
package/manifest.schema.json
CHANGED
|
@@ -687,6 +687,11 @@
|
|
|
687
687
|
],
|
|
688
688
|
"description": "Output model, or null if the step produces no output."
|
|
689
689
|
},
|
|
690
|
+
"preflight": {
|
|
691
|
+
"const": true,
|
|
692
|
+
"description": "The step runs its preflight (the harness's 'preflight <step>' mode, on the step's non-external inputs) before it waits for its external inputs; only a step with external inputs has one. Omitted = none.",
|
|
693
|
+
"type": "boolean"
|
|
694
|
+
},
|
|
690
695
|
"retries": {
|
|
691
696
|
"maximum": 9007199254740991,
|
|
692
697
|
"minimum": 0,
|
package/package.json
CHANGED
package/src/core/handlers.ts
CHANGED
|
@@ -5,11 +5,13 @@ import {
|
|
|
5
5
|
type SkippedBrand,
|
|
6
6
|
} from './brands.ts';
|
|
7
7
|
import type { StepCtx } from '../ctx/types.ts';
|
|
8
|
+
import { isExternal, type External } from './external.ts';
|
|
8
9
|
import type {
|
|
9
10
|
AnyStep,
|
|
10
11
|
CellOf,
|
|
11
12
|
ConfigOf,
|
|
12
13
|
OutputValue,
|
|
14
|
+
RegularInputKey,
|
|
13
15
|
SecretsOf,
|
|
14
16
|
StepInput,
|
|
15
17
|
} from './steps.ts';
|
|
@@ -39,12 +41,40 @@ export type Handler<S extends AnyStep> = (
|
|
|
39
41
|
ctx: CtxOf<S>,
|
|
40
42
|
) => Awaitable<HandlerResult<S>>;
|
|
41
43
|
|
|
44
|
+
/** What a preflight receives: the step's non-external inputs (the external values don't exist yet). */
|
|
45
|
+
export type PreflightInput<S extends AnyStep> = StepInput<
|
|
46
|
+
Pick<S['input'], RegularInputKey<S['input']>>
|
|
47
|
+
>;
|
|
48
|
+
/** The ctx a preflight of `S` gets: the handler's, without output files and `skip` (the step still waits). */
|
|
49
|
+
export type PreflightCtxOf<S extends AnyStep> = Omit<CtxOf<S>, 'output' | 'skip'>;
|
|
50
|
+
export type Preflight<S extends AnyStep> = (
|
|
51
|
+
input: PreflightInput<S>,
|
|
52
|
+
ctx: PreflightCtxOf<S>,
|
|
53
|
+
) => Awaitable<void>;
|
|
54
|
+
|
|
55
|
+
/** True when some input of the step is `external(...)`. */
|
|
56
|
+
type HasExternalInput<I> = true extends {
|
|
57
|
+
[K in keyof I]-?: I[K] extends External ? true : false;
|
|
58
|
+
}[keyof I]
|
|
59
|
+
? true
|
|
60
|
+
: false;
|
|
61
|
+
|
|
62
|
+
export interface ImplementOptions<S extends AnyStep> {
|
|
63
|
+
/**
|
|
64
|
+
* Runs before the step starts waiting for its external inputs, e.g. to hand work and
|
|
65
|
+
* `ctx.inputUrl(...)` to the system that will answer. Throwing fails the attempt without waiting.
|
|
66
|
+
*/
|
|
67
|
+
preflight?: Preflight<S>;
|
|
68
|
+
}
|
|
69
|
+
|
|
42
70
|
/** A step with its handler. It is itself a step: stage it like any value. */
|
|
43
71
|
export type Implementation<S extends AnyStep = AnyStep> = S & {
|
|
44
72
|
readonly [ImplementationBrand]: true;
|
|
45
73
|
/** The step as defined. */
|
|
46
74
|
readonly step: S;
|
|
47
75
|
readonly handler: Handler<S>;
|
|
76
|
+
/** Runs before the step waits for its external inputs; absent when none. */
|
|
77
|
+
readonly preflight?: Preflight<S>;
|
|
48
78
|
};
|
|
49
79
|
|
|
50
80
|
/** Block-bodied/async returns get no excess-property check; this restores it for handler outputs. */
|
|
@@ -69,16 +99,29 @@ export function implement<S extends AnyStep, R extends HandlerResult<S>>(
|
|
|
69
99
|
input: StepInput<S['input']>,
|
|
70
100
|
ctx: CtxOf<S>,
|
|
71
101
|
) => Awaitable<R & NoExtraFields<R, OutputValue<S>>>,
|
|
102
|
+
options?: HasExternalInput<S['input']> extends true
|
|
103
|
+
? ImplementOptions<S>
|
|
104
|
+
: '❌ a preflight needs a step with an external(...) input',
|
|
72
105
|
): Implementation<S> {
|
|
73
106
|
if (isImplementation(step))
|
|
74
107
|
throw new TypeError(
|
|
75
108
|
`step '${(step as AnyStep).name}' already carries its implementation`,
|
|
76
109
|
);
|
|
110
|
+
const preflight =
|
|
111
|
+
typeof options === 'object' ? (options as ImplementOptions<S>).preflight : undefined;
|
|
112
|
+
if (
|
|
113
|
+
preflight !== undefined &&
|
|
114
|
+
!Object.values((step as AnyStep).input as Record<string, unknown>).some(isExternal)
|
|
115
|
+
)
|
|
116
|
+
throw new TypeError(
|
|
117
|
+
`step '${(step as AnyStep).name}': a preflight needs a step with an external(...) input`,
|
|
118
|
+
);
|
|
77
119
|
return Object.freeze({
|
|
78
120
|
...step,
|
|
79
121
|
[IMPLEMENTATION]: true,
|
|
80
122
|
step,
|
|
81
123
|
handler: handler as unknown as Handler<S>,
|
|
124
|
+
...(preflight !== undefined ? { preflight } : {}),
|
|
82
125
|
}) as unknown as Implementation<S>;
|
|
83
126
|
}
|
|
84
127
|
|
package/src/ctx/sh.ts
CHANGED
|
@@ -52,6 +52,9 @@ export class CommandStalledError extends CommandError {
|
|
|
52
52
|
/** Grace between the watchdog's SIGTERM and SIGKILL (a process blocked in a socket read may ignore TERM). */
|
|
53
53
|
export const STALL_KILL_GRACE_MS = 10_000;
|
|
54
54
|
|
|
55
|
+
/** How long output is still read after a command exits; a leftover child (an MSBuild node) may hold the pipes. */
|
|
56
|
+
export const PIPE_GRACE_MS = 5_000;
|
|
57
|
+
|
|
55
58
|
export function detectShell(candidates: readonly string[] = SHELL_CANDIDATES): string {
|
|
56
59
|
for (const c of candidates) if (existsSync(c)) return c;
|
|
57
60
|
throw new ShellNotFoundError();
|
|
@@ -80,6 +83,7 @@ export interface ShDeps {
|
|
|
80
83
|
tmpDir: string;
|
|
81
84
|
signal: AbortSignal;
|
|
82
85
|
shellCandidates?: readonly string[];
|
|
86
|
+
pipeGraceMs?: number;
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
export function createSh(deps: ShDeps): {
|
|
@@ -208,9 +212,16 @@ export function createSh(deps: ShDeps): {
|
|
|
208
212
|
|
|
209
213
|
try {
|
|
210
214
|
const code = await proc.exited;
|
|
211
|
-
// A
|
|
212
|
-
if (stalled)
|
|
213
|
-
|
|
215
|
+
// A command's leftover children may still hold the pipes open: don't wait for them long.
|
|
216
|
+
if (!stalled) {
|
|
217
|
+
let grace: ReturnType<typeof setTimeout> | undefined;
|
|
218
|
+
await Promise.race([
|
|
219
|
+
Promise.all(pumps),
|
|
220
|
+
new Promise((r) => (grace = setTimeout(r, deps.pipeGraceMs ?? PIPE_GRACE_MS))),
|
|
221
|
+
]);
|
|
222
|
+
clearTimeout(grace);
|
|
223
|
+
}
|
|
224
|
+
await Promise.all(readers.map((r) => r.cancel().catch(() => {})));
|
|
214
225
|
if (stalled)
|
|
215
226
|
throw new CommandStalledError(
|
|
216
227
|
what,
|
|
@@ -180,6 +180,12 @@ export const StepDescriptor = z
|
|
|
180
180
|
.describe(
|
|
181
181
|
"The step pod gets the instance's step ServiceAccount token (Kubernetes:StepServiceAccount), if the instance grants one. Omitted = no token.",
|
|
182
182
|
),
|
|
183
|
+
preflight: z
|
|
184
|
+
.literal(true)
|
|
185
|
+
.optional()
|
|
186
|
+
.describe(
|
|
187
|
+
"The step runs its preflight (the harness's 'preflight <step>' mode, on the step's non-external inputs) before it waits for its external inputs; only a step with external inputs has one. Omitted = none.",
|
|
188
|
+
),
|
|
183
189
|
externalInputs: z
|
|
184
190
|
.record(
|
|
185
191
|
z.string(),
|
package/src/describe/step.ts
CHANGED
|
@@ -68,6 +68,7 @@ export function describeStep(
|
|
|
68
68
|
),
|
|
69
69
|
].sort();
|
|
70
70
|
const carried = isImplementation(s);
|
|
71
|
+
const preflight = carried && typeof s.preflight === 'function';
|
|
71
72
|
const { settings, settingsProblem } = recordSettings(s, carried);
|
|
72
73
|
const refs = settings ? recordedConfigRefs(settings) : [];
|
|
73
74
|
checkConfigSecrets(ctx, at, configNames, refs, secretNames);
|
|
@@ -109,7 +110,10 @@ export function describeStep(
|
|
|
109
110
|
...(configNames.length ? { configEntries: configNames } : {}),
|
|
110
111
|
...(s.clusterAccess === true ? { clusterAccess: true as const } : {}),
|
|
111
112
|
...(Object.keys(externalInputs).length ? { externalInputs } : {}),
|
|
113
|
+
...(preflight ? { preflight: true as const } : {}),
|
|
112
114
|
};
|
|
115
|
+
if (preflight && Object.keys(externalInputs).length === 0)
|
|
116
|
+
problems.push(`${at}: a preflight needs at least one external input`);
|
|
113
117
|
if (s.clusterAccess !== undefined && typeof s.clusterAccess !== 'boolean')
|
|
114
118
|
problems.push(`${at}: clusterAccess must be a boolean`);
|
|
115
119
|
if (s.output && !isModel(s.output)) problems.push(`${at}: output must be a model`);
|
package/src/dev/cli.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// olve-dev — run a pipeline from source through the same harness path as the compiled binary.
|
|
3
3
|
// olve-dev describe <dir> [--sentinels]
|
|
4
4
|
// olve-dev run <dir> <step> [--input-file f] [--config-file f] [--olve-dir d] [--secret NAME=value]...
|
|
5
|
+
// olve-dev preflight <dir> <step> [same options as run]
|
|
5
6
|
// olve-dev map-trigger <dir> <trigger> --event-file f [--olve-dir d]
|
|
6
7
|
// $OLVE_DIR defaults to <dir>/.olve; outputs land in <dir>/.olve/work/.
|
|
7
8
|
import {
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
|
|
26
27
|
const USAGE = `usage: olve-dev describe <dir> [--sentinels]
|
|
27
28
|
olve-dev run <dir> <step> [--input-file f] [--config-file f] [--olve-dir d] [--secret NAME=value]...
|
|
29
|
+
olve-dev preflight <dir> <step> [--input-file f] [--config-file f] [--olve-dir d] [--secret NAME=value]...
|
|
28
30
|
olve-dev map-trigger <dir> <trigger> --event-file f [--olve-dir d]`;
|
|
29
31
|
|
|
30
32
|
export async function devConfig(
|
|
@@ -71,10 +73,10 @@ export async function devMain(argv: string[]): Promise<number> {
|
|
|
71
73
|
'describe',
|
|
72
74
|
...(values.sentinels ? ['--sentinels'] : []),
|
|
73
75
|
]);
|
|
74
|
-
if (mode !== 'run' && mode !== 'map-trigger')
|
|
76
|
+
if (mode !== 'run' && mode !== 'preflight' && mode !== 'map-trigger')
|
|
75
77
|
return (console.error(USAGE), EXIT.usage);
|
|
76
78
|
if (!name) return (console.error(USAGE), EXIT.usage);
|
|
77
|
-
const file = mode === '
|
|
79
|
+
const file = mode === 'map-trigger' ? values['event-file'] : values['input-file'];
|
|
78
80
|
if (mode === 'map-trigger' && !file)
|
|
79
81
|
return (console.error('--event-file is required'), EXIT.usage);
|
|
80
82
|
const olveDir = resolve(values['olve-dir'] ?? join(dir, '.olve'));
|
|
@@ -88,7 +90,7 @@ export async function devMain(argv: string[]): Promise<number> {
|
|
|
88
90
|
process.env[s.slice(0, i)] = s.slice(i + 1);
|
|
89
91
|
}
|
|
90
92
|
if (file) copyFileSync(resolve(file), join(olveDir, 'input.json'));
|
|
91
|
-
if (mode
|
|
93
|
+
if (mode !== 'map-trigger' && values['config-file']) {
|
|
92
94
|
const inputFile = join(olveDir, 'input.json');
|
|
93
95
|
const input = (
|
|
94
96
|
existsSync(inputFile) ? JSON.parse(readFileSync(inputFile, 'utf8')) : {}
|
package/src/harness/env.ts
CHANGED
|
@@ -25,12 +25,12 @@ export const ENV = {
|
|
|
25
25
|
filesDir: 'OLVE_FILES_DIR',
|
|
26
26
|
} as const;
|
|
27
27
|
|
|
28
|
-
/** Env vars never passed to child processes
|
|
28
|
+
/** Env vars never passed to child processes: the harness's own contract, credentials included. */
|
|
29
29
|
export const PRIVATE_ENV: readonly string[] = [
|
|
30
|
-
ENV
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
...Object.values(ENV),
|
|
31
|
+
'OLVE_CASES',
|
|
32
|
+
'OLVE_SECRETS',
|
|
33
|
+
'OLVE_SUPERVISED',
|
|
34
34
|
];
|
|
35
35
|
|
|
36
36
|
/** `OLVE_CALLBACK_URLS`, parsed; empty when unset or malformed. */
|
package/src/harness/execute.ts
CHANGED
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { isSkipped } from '../core/handlers.ts';
|
|
6
|
+
import { isExternal } from '../core/external.ts';
|
|
6
7
|
import type { AnyStep } from '../core/steps.ts';
|
|
7
8
|
import type { Ctx, RunInfo } from '../ctx/types.ts';
|
|
8
9
|
import { objectPath } from '../ctx/files.ts';
|
|
9
10
|
import { createLinks, type StepLink } from '../ctx/links.ts';
|
|
10
11
|
import { sha256File } from '../util/hash.ts';
|
|
11
12
|
import { EXIT } from './exit.ts';
|
|
12
|
-
import { validateConfig, validateInput, validateOutput } from './validate.ts';
|
|
13
|
+
import { CONFIG_KEY, validateConfig, validateInput, validateOutput } from './validate.ts';
|
|
13
14
|
|
|
14
15
|
export type FailureKind = 'handler' | 'input' | 'output';
|
|
15
16
|
export type Envelope = (
|
|
@@ -136,3 +137,78 @@ export async function executeStep(args: {
|
|
|
136
137
|
exitCode: EXIT.ok,
|
|
137
138
|
};
|
|
138
139
|
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A step's preflight: validates the step's non-external inputs (external values already given are
|
|
143
|
+
* ignored), runs the preflight and reports its links. Succeeds with `output: null`; nothing is validated
|
|
144
|
+
* as output and no files are kept.
|
|
145
|
+
*/
|
|
146
|
+
export async function executePreflight(args: {
|
|
147
|
+
step: AnyStep;
|
|
148
|
+
fn: StepFunction;
|
|
149
|
+
rawInput: unknown;
|
|
150
|
+
ctx: Ctx;
|
|
151
|
+
workFiles: string;
|
|
152
|
+
log: (line: string) => void;
|
|
153
|
+
}): Promise<Execution> {
|
|
154
|
+
const { step, log } = args;
|
|
155
|
+
const entries = Object.entries(step.input as Record<string, unknown>);
|
|
156
|
+
const external = new Set(entries.filter(([, e]) => isExternal(e)).map(([k]) => k));
|
|
157
|
+
const regular = {
|
|
158
|
+
...step,
|
|
159
|
+
input: Object.fromEntries(entries.filter(([k]) => !external.has(k))),
|
|
160
|
+
};
|
|
161
|
+
const raw =
|
|
162
|
+
typeof args.rawInput === 'object' &&
|
|
163
|
+
args.rawInput !== null &&
|
|
164
|
+
!Array.isArray(args.rawInput)
|
|
165
|
+
? Object.fromEntries(
|
|
166
|
+
Object.entries(args.rawInput).filter(
|
|
167
|
+
([k]) => k === CONFIG_KEY || !external.has(k),
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
: args.rawInput;
|
|
171
|
+
const input = validateInput(regular, raw);
|
|
172
|
+
const config = validateConfig(step, raw);
|
|
173
|
+
if (!input.ok || !config.ok) {
|
|
174
|
+
const issues = [
|
|
175
|
+
...(input.ok ? [] : input.issues),
|
|
176
|
+
...(config.ok ? [] : config.issues),
|
|
177
|
+
];
|
|
178
|
+
const message = `step '${step.name}' preflight: input does not match the step's inputs:\n ${issues.join('\n ')}`;
|
|
179
|
+
log(message);
|
|
180
|
+
return {
|
|
181
|
+
envelope: { status: 'failed', error: { kind: 'input', message } },
|
|
182
|
+
exitCode: EXIT.inputInvalid,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const collected = createLinks();
|
|
186
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
187
|
+
const { output: _output, skip: _skip, ...rest } = args.ctx;
|
|
188
|
+
const ctx = {
|
|
189
|
+
...rest,
|
|
190
|
+
config: Object.freeze(config.value) as Ctx['config'],
|
|
191
|
+
link: collected.link,
|
|
192
|
+
};
|
|
193
|
+
const withLinks = (e: Envelope): Envelope =>
|
|
194
|
+
collected.links.length ? { ...e, links: [...collected.links] } : e;
|
|
195
|
+
try {
|
|
196
|
+
await args.fn(input.value, ctx as Ctx);
|
|
197
|
+
} catch (e) {
|
|
198
|
+
const cancelled = cancelExitCode(ctx.signal);
|
|
199
|
+
const message = cancelled
|
|
200
|
+
? `step '${step.name}' preflight was cancelled (${String(ctx.signal.reason)}): ${errorMessage(e)}`
|
|
201
|
+
: `step '${step.name}' preflight failed: ${errorMessage(e)}`;
|
|
202
|
+
log(message);
|
|
203
|
+
if (e instanceof Error && e.stack && !cancelled) log(e.stack);
|
|
204
|
+
return {
|
|
205
|
+
envelope: withLinks({ status: 'failed', error: { kind: 'handler', message } }),
|
|
206
|
+
exitCode: cancelled ?? EXIT.handlerFailed,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
log(`step '${step.name}' preflight succeeded`);
|
|
210
|
+
return {
|
|
211
|
+
envelope: withLinks({ status: 'succeeded', output: null }),
|
|
212
|
+
exitCode: EXIT.ok,
|
|
213
|
+
};
|
|
214
|
+
}
|
package/src/harness/main.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The harness: one executable, three modes.
|
|
2
2
|
// describe [--sentinels] → manifest (canonical JSON) on stdout
|
|
3
3
|
// run <step> → $OLVE_DIR/input.json → handler → $OLVE_DIR/work/output.json (+ result upload)
|
|
4
|
+
// preflight <step> → the step's non-external inputs → preflight → envelope (links only, no output)
|
|
4
5
|
// map-trigger <trigger> → $OLVE_DIR/input.json (event) → pipeline input → $OLVE_DIR/work/output.json
|
|
5
6
|
import { mkdirSync, readFileSync } from 'node:fs';
|
|
6
7
|
import { join } from 'node:path';
|
|
@@ -17,6 +18,7 @@ import type { HarnessInfo } from '../util/code-hash.ts';
|
|
|
17
18
|
import { callbackUrlsFromEnv, ENV, paths, runInfoFromEnv } from './env.ts';
|
|
18
19
|
import { EXIT } from './exit.ts';
|
|
19
20
|
import {
|
|
21
|
+
executePreflight,
|
|
20
22
|
executeStep,
|
|
21
23
|
type Envelope,
|
|
22
24
|
type Execution,
|
|
@@ -56,7 +58,7 @@ export const defaultIO: HarnessIO = {
|
|
|
56
58
|
};
|
|
57
59
|
|
|
58
60
|
const USAGE =
|
|
59
|
-
'usage: step describe [--sentinels] | step run <step> | step map-trigger <trigger>';
|
|
61
|
+
'usage: step describe [--sentinels] | step run <step> | step preflight <step> | step map-trigger <trigger>';
|
|
60
62
|
|
|
61
63
|
/** Every step with the `select` case conditions (`<ref>.<discriminator>=<value>`) it sits in. */
|
|
62
64
|
export function stepsWithCases(data: PipelineData): { step: AnyStep; cases: string[] }[] {
|
|
@@ -125,6 +127,13 @@ export function resolveStepFunction(step: AnyStep): StepFunction | string {
|
|
|
125
127
|
return `step '${step.name}' has no implementation (stage implement(step({...}), fn))`;
|
|
126
128
|
}
|
|
127
129
|
|
|
130
|
+
/** The preflight `step` carries, or what is wrong. */
|
|
131
|
+
export function resolvePreflight(step: AnyStep): StepFunction | string {
|
|
132
|
+
if (isImplementation(step) && step.preflight)
|
|
133
|
+
return step.preflight as unknown as StepFunction;
|
|
134
|
+
return `step '${step.name}' has no preflight (implement(step, fn, { preflight }))`;
|
|
135
|
+
}
|
|
136
|
+
|
|
128
137
|
/** Writes output.json, prints the sentinel block and uploads result.tar.gz when OLVE_RESULT_URL is set. */
|
|
129
138
|
export async function finish(exec: Execution, io: HarnessIO): Promise<number> {
|
|
130
139
|
const p = paths();
|
|
@@ -182,12 +191,13 @@ async function runMode(
|
|
|
182
191
|
cfg: HarnessConfig,
|
|
183
192
|
name: string | undefined,
|
|
184
193
|
io: HarnessIO,
|
|
194
|
+
mode: 'run' | 'preflight' = 'run',
|
|
185
195
|
): Promise<number> {
|
|
186
196
|
if (!name) return (io.stderr(USAGE), EXIT.usage);
|
|
187
197
|
const data = pipelineData(cfg.pipeline);
|
|
188
198
|
const step = findStep(data, name, selectedCases());
|
|
189
199
|
if (typeof step === 'string') return (io.stderr(step), EXIT.usage);
|
|
190
|
-
const fn = resolveStepFunction(step);
|
|
200
|
+
const fn = mode === 'run' ? resolveStepFunction(step) : resolvePreflight(step);
|
|
191
201
|
if (typeof fn === 'string') return (io.stderr(fn), EXIT.configInvalid);
|
|
192
202
|
|
|
193
203
|
const p = paths();
|
|
@@ -225,17 +235,18 @@ async function runMode(
|
|
|
225
235
|
callbackUrls: callbackUrlsFromEnv(),
|
|
226
236
|
...(cell?.ok ? { cell: cell.cell } : {}),
|
|
227
237
|
});
|
|
228
|
-
|
|
238
|
+
const args = {
|
|
229
239
|
step,
|
|
230
240
|
fn,
|
|
231
241
|
rawInput: raw.value,
|
|
232
242
|
ctx,
|
|
233
243
|
workFiles: p.files,
|
|
234
244
|
log: io.stderr,
|
|
235
|
-
}
|
|
245
|
+
};
|
|
246
|
+
exec = await (mode === 'run' ? executeStep(args) : executePreflight(args));
|
|
236
247
|
}
|
|
237
248
|
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)}`;
|
|
249
|
+
const message = `step '${step.name}'${mode === 'preflight' ? ' preflight' : ''} failed: an error nothing caught: ${uncaught instanceof Error ? uncaught.message : String(uncaught)}`;
|
|
239
250
|
io.stderr(message);
|
|
240
251
|
exec = {
|
|
241
252
|
envelope: { status: 'failed', error: { kind: 'handler', message } },
|
|
@@ -365,6 +376,8 @@ export async function runHarness(
|
|
|
365
376
|
return describeMode(cfg, rest, io);
|
|
366
377
|
case 'run':
|
|
367
378
|
return runMode(cfg, rest[0], io);
|
|
379
|
+
case 'preflight':
|
|
380
|
+
return runMode(cfg, rest[0], io, 'preflight');
|
|
368
381
|
case 'map-trigger':
|
|
369
382
|
return mapTriggerMode(cfg, rest[0], io);
|
|
370
383
|
default:
|
package/src/harness/supervise.ts
CHANGED
|
@@ -30,10 +30,11 @@ function ownArgv(): string[] {
|
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
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.
|
|
33
|
+
* run the step itself: it is the child, the mode isn't `run` or `preflight`, or there is nothing to mask.
|
|
34
34
|
*/
|
|
35
35
|
export async function supervise(argv: readonly string[]): Promise<number | undefined> {
|
|
36
|
-
if (argv[0] !== 'run' || process.env[SUPERVISED])
|
|
36
|
+
if ((argv[0] !== 'run' && argv[0] !== 'preflight') || process.env[SUPERVISED])
|
|
37
|
+
return undefined;
|
|
37
38
|
const patterns = secretPatterns();
|
|
38
39
|
if (patterns.length === 0) return undefined;
|
|
39
40
|
|