@olivervea/pipelines 2.0.0-beta.18 → 2.0.0-beta.19
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/package.json +4 -2
- package/src/assets.d.ts +2 -2
- package/src/build/build.ts +111 -43
- package/src/build/cli.ts +28 -21
- package/src/build/elf.ts +8 -7
- package/src/build/hashes.ts +61 -28
- package/src/build/project.ts +69 -30
- package/src/builder/pipeline.ts +274 -102
- package/src/builder/refs.ts +12 -6
- package/src/core/brands.ts +6 -6
- package/src/core/config-refs.ts +46 -20
- package/src/core/config.ts +110 -33
- package/src/core/external.ts +30 -12
- package/src/core/handlers.ts +54 -14
- package/src/core/models.ts +29 -14
- package/src/core/origin.ts +7 -4
- package/src/core/secrets.ts +2 -2
- package/src/core/steps.ts +39 -23
- package/src/core/types.ts +5 -1
- package/src/core/webhooks.ts +21 -7
- package/src/ctx/files.ts +111 -40
- package/src/ctx/index.ts +75 -34
- package/src/ctx/sh.ts +104 -44
- package/src/ctx/types.ts +4 -4
- package/src/describe/describe.ts +550 -196
- package/src/describe/manifest-schema.ts +210 -80
- package/src/describe/mapper-source.ts +249 -104
- package/src/describe/record.ts +67 -37
- package/src/dev/cli.ts +64 -34
- package/src/harness/env.ts +33 -27
- package/src/harness/execute.ts +55 -29
- package/src/harness/index.ts +21 -8
- package/src/harness/main.ts +197 -74
- package/src/harness/result.ts +38 -22
- package/src/harness/validate.ts +62 -19
- package/src/index.ts +123 -14
- package/src/library/checks.ts +11 -6
- package/src/library/compose-deploy.ts +40 -23
- package/src/library/github-source.ts +69 -39
- package/src/library/helm-deploy-ssh.ts +213 -76
- package/src/library/helm-deploy.ts +263 -85
- package/src/library/http-health.ts +7 -5
- package/src/library/image-build.ts +293 -117
- package/src/library/index.ts +47 -15
- package/src/library/models.ts +17 -9
- package/src/library/ssh.ts +31 -14
- package/src/testing/index.ts +63 -24
- package/src/util/canonical.ts +7 -4
- package/src/util/handler-hash.ts +15 -5
- package/src/util/hash.ts +5 -5
- package/src/util/tar.ts +74 -37
package/src/harness/main.ts
CHANGED
|
@@ -2,24 +2,39 @@
|
|
|
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
4
|
// map-trigger <trigger> → $OLVE_DIR/input.json (event) → pipeline input → $OLVE_DIR/work/output.json
|
|
5
|
-
import { mkdirSync, readFileSync } from
|
|
6
|
-
import { join } from
|
|
7
|
-
import { isImplementation } from
|
|
8
|
-
import { configRefPath, isConfigRef, unionAt } from
|
|
9
|
-
import type { AnyStep } from
|
|
10
|
-
import type { WebhookEvent } from
|
|
11
|
-
import { pipelineData, type PipelineData, type StageNode } from
|
|
12
|
-
import { createCtx, formatLog } from
|
|
13
|
-
import { DescribeError, describePipeline, manifestText } from
|
|
14
|
-
import type { HarnessInfo } from
|
|
15
|
-
import { callbackUrlsFromEnv, ENV, paths, runInfoFromEnv } from
|
|
16
|
-
import { EXIT } from
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
5
|
+
import { mkdirSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { isImplementation } from '../core/handlers.ts';
|
|
8
|
+
import { configRefPath, isConfigRef, unionAt } from '../core/config.ts';
|
|
9
|
+
import type { AnyStep } from '../core/steps.ts';
|
|
10
|
+
import type { WebhookEvent } from '../core/webhooks.ts';
|
|
11
|
+
import { pipelineData, type PipelineData, type StageNode } from '../builder/pipeline.ts';
|
|
12
|
+
import { createCtx, formatLog } from '../ctx/index.ts';
|
|
13
|
+
import { DescribeError, describePipeline, manifestText } from '../describe/describe.ts';
|
|
14
|
+
import type { HarnessInfo } from '../util/handler-hash.ts';
|
|
15
|
+
import { callbackUrlsFromEnv, ENV, paths, runInfoFromEnv } from './env.ts';
|
|
16
|
+
import { EXIT } from './exit.ts';
|
|
17
|
+
import {
|
|
18
|
+
executeStep,
|
|
19
|
+
type Envelope,
|
|
20
|
+
type Execution,
|
|
21
|
+
type StepFunction,
|
|
22
|
+
} from './execute.ts';
|
|
23
|
+
import {
|
|
24
|
+
MANIFEST_BEGIN,
|
|
25
|
+
MANIFEST_END,
|
|
26
|
+
packResult,
|
|
27
|
+
removeStale,
|
|
28
|
+
sentinelBlock,
|
|
29
|
+
uploadFile,
|
|
30
|
+
writeEnvelope,
|
|
31
|
+
} from './result.ts';
|
|
32
|
+
import { formatIssues, parseStrict } from './validate.ts';
|
|
20
33
|
|
|
21
34
|
/** step name → lazy import of `handlers/<name>.ts`. Literal import() paths so Bun bundles them. */
|
|
22
|
-
export type HandlerRegistry = Readonly<
|
|
35
|
+
export type HandlerRegistry = Readonly<
|
|
36
|
+
Record<string, () => Promise<{ default: unknown }>>
|
|
37
|
+
>;
|
|
23
38
|
|
|
24
39
|
export interface HarnessConfig {
|
|
25
40
|
/** Default export of pipeline.ts. */
|
|
@@ -41,11 +56,12 @@ export const defaultIO: HarnessIO = {
|
|
|
41
56
|
await Bun.write(Bun.stdout, text);
|
|
42
57
|
},
|
|
43
58
|
stderr: (line) => {
|
|
44
|
-
process.stderr.write(line.endsWith(
|
|
59
|
+
process.stderr.write(line.endsWith('\n') ? line : line + '\n');
|
|
45
60
|
},
|
|
46
61
|
};
|
|
47
62
|
|
|
48
|
-
const USAGE =
|
|
63
|
+
const USAGE =
|
|
64
|
+
'usage: step describe [--sentinels] | step run <step> | step map-trigger <trigger>';
|
|
49
65
|
|
|
50
66
|
export function allSteps(data: PipelineData): AnyStep[] {
|
|
51
67
|
const out: AnyStep[] = [];
|
|
@@ -58,12 +74,17 @@ export function stepsWithCases(data: PipelineData): { step: AnyStep; cases: stri
|
|
|
58
74
|
const out: { step: AnyStep; cases: string[] }[] = [];
|
|
59
75
|
const walk = (nodes: readonly StageNode[], cases: string[]) => {
|
|
60
76
|
for (const n of nodes) {
|
|
61
|
-
if (n.kind ===
|
|
62
|
-
else if (n.kind ===
|
|
77
|
+
if (n.kind === 'stage') out.push(...n.steps.map((s) => ({ step: s.step, cases })));
|
|
78
|
+
else if (n.kind === 'when') walk(n.nodes, cases);
|
|
63
79
|
else {
|
|
64
80
|
const union = isConfigRef(n.on) ? unionAt(n.on) : undefined;
|
|
65
81
|
for (const [value, inner] of Object.entries(n.cases))
|
|
66
|
-
walk(
|
|
82
|
+
walk(
|
|
83
|
+
inner,
|
|
84
|
+
union
|
|
85
|
+
? [...cases, `${configRefPath(n.on)}.${union.discriminator}=${value}`]
|
|
86
|
+
: cases,
|
|
87
|
+
);
|
|
67
88
|
}
|
|
68
89
|
}
|
|
69
90
|
};
|
|
@@ -73,9 +94,15 @@ export function stepsWithCases(data: PipelineData): { step: AnyStep; cases: stri
|
|
|
73
94
|
}
|
|
74
95
|
|
|
75
96
|
/** The step `run <name>` executes: the only one so named, or the one in the cases the service selected (`OLVE_CASES`). */
|
|
76
|
-
export function findStep(
|
|
97
|
+
export function findStep(
|
|
98
|
+
data: PipelineData,
|
|
99
|
+
name: string,
|
|
100
|
+
selected: readonly string[],
|
|
101
|
+
): AnyStep | string {
|
|
77
102
|
const candidates = stepsWithCases(data).filter((c) => c.step.name === name);
|
|
78
|
-
const distinct = candidates.filter(
|
|
103
|
+
const distinct = candidates.filter(
|
|
104
|
+
(c, i) => candidates.findIndex((d) => d.step === c.step) === i,
|
|
105
|
+
);
|
|
79
106
|
if (distinct.length === 0) return `unknown step '${name}'`;
|
|
80
107
|
if (distinct.length === 1) return distinct[0]!.step;
|
|
81
108
|
const chosen = distinct.filter((c) => c.cases.every((k) => selected.includes(k)));
|
|
@@ -84,26 +111,30 @@ export function findStep(data: PipelineData, name: string, selected: readonly st
|
|
|
84
111
|
}
|
|
85
112
|
|
|
86
113
|
/** The select case conditions of the step being run (JSON array), set by the service for steps inside a select. */
|
|
87
|
-
export const ENV_CASES =
|
|
114
|
+
export const ENV_CASES = 'OLVE_CASES';
|
|
88
115
|
|
|
89
116
|
/** Installs SIGTERM/SIGINT handlers that abort the returned signal (PID 1 ignores signals without them). */
|
|
90
117
|
export function cancellation(): { signal: AbortSignal; dispose(): void } {
|
|
91
118
|
const ac = new AbortController();
|
|
92
|
-
const onTerm = () => ac.abort(
|
|
93
|
-
const onInt = () =>
|
|
94
|
-
|
|
95
|
-
process.on(
|
|
119
|
+
const onTerm = () => ac.abort('SIGTERM');
|
|
120
|
+
const onInt = () =>
|
|
121
|
+
ac.signal.aborted ? process.exit(EXIT.sigint) : ac.abort('SIGINT');
|
|
122
|
+
process.on('SIGTERM', onTerm);
|
|
123
|
+
process.on('SIGINT', onInt);
|
|
96
124
|
return {
|
|
97
125
|
signal: ac.signal,
|
|
98
126
|
dispose() {
|
|
99
|
-
process.off(
|
|
100
|
-
process.off(
|
|
127
|
+
process.off('SIGTERM', onTerm);
|
|
128
|
+
process.off('SIGINT', onInt);
|
|
101
129
|
},
|
|
102
130
|
};
|
|
103
131
|
}
|
|
104
132
|
|
|
105
133
|
/** Resolves the function that implements `step` (carried by the step, or a handler module). */
|
|
106
|
-
export async function resolveStepFunction(
|
|
134
|
+
export async function resolveStepFunction(
|
|
135
|
+
step: AnyStep,
|
|
136
|
+
handlers: HandlerRegistry,
|
|
137
|
+
): Promise<StepFunction | string> {
|
|
107
138
|
if (isImplementation(step)) return step.handler as unknown as StepFunction;
|
|
108
139
|
const load = handlers[step.name];
|
|
109
140
|
if (!load) return `step '${step.name}' has no handler`;
|
|
@@ -114,7 +145,8 @@ export async function resolveStepFunction(step: AnyStep, handlers: HandlerRegist
|
|
|
114
145
|
return `the handler of step '${step.name}' failed to load: ${(e as Error)?.stack ?? String(e)}`;
|
|
115
146
|
}
|
|
116
147
|
const impl = mod.default;
|
|
117
|
-
if (!isImplementation(impl))
|
|
148
|
+
if (!isImplementation(impl))
|
|
149
|
+
return `the handler of step '${step.name}' must default-export implement(<step '${step.name}'>, …)`;
|
|
118
150
|
if (impl.step !== step)
|
|
119
151
|
return `the handler of step '${step.name}' implements a different step object (it must be implement(<the '${step.name}' step from pipeline.ts>, …))`;
|
|
120
152
|
return impl.handler as unknown as StepFunction;
|
|
@@ -129,7 +161,7 @@ export async function finish(exec: Execution, io: HarnessIO): Promise<number> {
|
|
|
129
161
|
if (!url) return exec.exitCode;
|
|
130
162
|
try {
|
|
131
163
|
mkdirSync(p.tmp, { recursive: true });
|
|
132
|
-
const archive = join(p.tmp,
|
|
164
|
+
const archive = join(p.tmp, 'result.tar.gz');
|
|
133
165
|
await packResult(p.work, archive);
|
|
134
166
|
await uploadFile(url, archive, io.stderr);
|
|
135
167
|
} catch (e) {
|
|
@@ -139,18 +171,31 @@ export async function finish(exec: Execution, io: HarnessIO): Promise<number> {
|
|
|
139
171
|
return exec.exitCode;
|
|
140
172
|
}
|
|
141
173
|
|
|
142
|
-
function readJson(
|
|
174
|
+
function readJson(
|
|
175
|
+
path: string,
|
|
176
|
+
): { ok: true; value: unknown } | { ok: false; message: string } {
|
|
143
177
|
try {
|
|
144
|
-
return { ok: true, value: JSON.parse(readFileSync(path,
|
|
178
|
+
return { ok: true, value: JSON.parse(readFileSync(path, 'utf8')) };
|
|
145
179
|
} catch (e) {
|
|
146
180
|
return { ok: false, message: `cannot read ${path}: ${(e as Error).message}` };
|
|
147
181
|
}
|
|
148
182
|
}
|
|
149
183
|
|
|
150
|
-
async function describeMode(
|
|
151
|
-
|
|
184
|
+
async function describeMode(
|
|
185
|
+
cfg: HarnessConfig,
|
|
186
|
+
args: string[],
|
|
187
|
+
io: HarnessIO,
|
|
188
|
+
): Promise<number> {
|
|
189
|
+
const sentinels = args.includes('--sentinels');
|
|
152
190
|
try {
|
|
153
|
-
const text = manifestText(
|
|
191
|
+
const text = manifestText(
|
|
192
|
+
describePipeline(cfg.pipeline, {
|
|
193
|
+
handlerNames: Object.keys(cfg.handlers),
|
|
194
|
+
handlerHashes: cfg.handlerHashes,
|
|
195
|
+
kindHashes: cfg.kindHashes,
|
|
196
|
+
harness: cfg.harness,
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
154
199
|
await io.stdout(sentinels ? `${MANIFEST_BEGIN}\n${text}${MANIFEST_END}\n` : text);
|
|
155
200
|
return EXIT.ok;
|
|
156
201
|
} catch (e) {
|
|
@@ -162,13 +207,17 @@ async function describeMode(cfg: HarnessConfig, args: string[], io: HarnessIO):
|
|
|
162
207
|
}
|
|
163
208
|
}
|
|
164
209
|
|
|
165
|
-
async function runMode(
|
|
166
|
-
|
|
210
|
+
async function runMode(
|
|
211
|
+
cfg: HarnessConfig,
|
|
212
|
+
name: string | undefined,
|
|
213
|
+
io: HarnessIO,
|
|
214
|
+
): Promise<number> {
|
|
215
|
+
if (!name) return (io.stderr(USAGE), EXIT.usage);
|
|
167
216
|
const data = pipelineData(cfg.pipeline);
|
|
168
217
|
const step = findStep(data, name, selectedCases());
|
|
169
|
-
if (typeof step ===
|
|
218
|
+
if (typeof step === 'string') return (io.stderr(step), EXIT.usage);
|
|
170
219
|
const fn = await resolveStepFunction(step, cfg.handlers);
|
|
171
|
-
if (typeof fn ===
|
|
220
|
+
if (typeof fn === 'string') return (io.stderr(fn), EXIT.configInvalid);
|
|
172
221
|
|
|
173
222
|
const p = paths();
|
|
174
223
|
removeStale(p.work);
|
|
@@ -180,11 +229,28 @@ async function runMode(cfg: HarnessConfig, name: string | undefined, io: Harness
|
|
|
180
229
|
let exec: Execution;
|
|
181
230
|
if (!raw.ok) {
|
|
182
231
|
io.stderr(raw.message);
|
|
183
|
-
exec = {
|
|
232
|
+
exec = {
|
|
233
|
+
envelope: { status: 'failed', error: { kind: 'input', message: raw.message } },
|
|
234
|
+
exitCode: EXIT.inputInvalid,
|
|
235
|
+
};
|
|
184
236
|
} else {
|
|
185
237
|
const run = runInfoFromEnv(step.name);
|
|
186
|
-
const ctx = createCtx({
|
|
187
|
-
|
|
238
|
+
const ctx = createCtx({
|
|
239
|
+
run,
|
|
240
|
+
secrets: step.secrets,
|
|
241
|
+
paths: { root: p.root, workFiles: p.files, tmp: p.tmp },
|
|
242
|
+
signal: cancel.signal,
|
|
243
|
+
logSink: io.stderr,
|
|
244
|
+
callbackUrls: callbackUrlsFromEnv(),
|
|
245
|
+
});
|
|
246
|
+
exec = await executeStep({
|
|
247
|
+
step,
|
|
248
|
+
fn,
|
|
249
|
+
rawInput: raw.value,
|
|
250
|
+
ctx,
|
|
251
|
+
workFiles: p.files,
|
|
252
|
+
log: io.stderr,
|
|
253
|
+
});
|
|
188
254
|
}
|
|
189
255
|
return await finish(exec, io);
|
|
190
256
|
} finally {
|
|
@@ -193,64 +259,121 @@ async function runMode(cfg: HarnessConfig, name: string | undefined, io: Harness
|
|
|
193
259
|
}
|
|
194
260
|
|
|
195
261
|
function isWebhookEvent(v: unknown): boolean {
|
|
196
|
-
if (typeof v !==
|
|
262
|
+
if (typeof v !== 'object' || v === null || Array.isArray(v) || !('body' in v))
|
|
263
|
+
return false;
|
|
197
264
|
const headers = (v as { headers?: unknown }).headers;
|
|
198
|
-
return typeof headers ===
|
|
265
|
+
return typeof headers === 'object' && headers !== null && !Array.isArray(headers);
|
|
199
266
|
}
|
|
200
267
|
|
|
201
|
-
async function mapTriggerMode(
|
|
202
|
-
|
|
268
|
+
async function mapTriggerMode(
|
|
269
|
+
cfg: HarnessConfig,
|
|
270
|
+
name: string | undefined,
|
|
271
|
+
io: HarnessIO,
|
|
272
|
+
): Promise<number> {
|
|
273
|
+
if (!name) return (io.stderr(USAGE), EXIT.usage);
|
|
203
274
|
const data = pipelineData(cfg.pipeline);
|
|
204
275
|
const trigger = data.triggers.find((t) => t.name === name);
|
|
205
|
-
if (!trigger)
|
|
276
|
+
if (!trigger)
|
|
277
|
+
return (
|
|
278
|
+
io.stderr(
|
|
279
|
+
`unknown trigger '${name}' (known: ${data.triggers.map((t) => t.name).join(', ') || 'none'})`,
|
|
280
|
+
),
|
|
281
|
+
EXIT.usage
|
|
282
|
+
);
|
|
206
283
|
const p = paths();
|
|
207
284
|
removeStale(p.work);
|
|
208
285
|
mkdirSync(p.work, { recursive: true });
|
|
209
286
|
const raw = readJson(p.input);
|
|
210
|
-
const fail = (
|
|
287
|
+
const fail = (
|
|
288
|
+
kind: 'input' | 'output' | 'handler',
|
|
289
|
+
message: string,
|
|
290
|
+
exitCode: number,
|
|
291
|
+
): Execution => {
|
|
211
292
|
io.stderr(message);
|
|
212
|
-
return { envelope: { status:
|
|
293
|
+
return { envelope: { status: 'failed', error: { kind, message } }, exitCode };
|
|
213
294
|
};
|
|
214
295
|
let exec: Execution;
|
|
215
|
-
if (!raw.ok) exec = fail(
|
|
216
|
-
else if (
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
296
|
+
if (!raw.ok) exec = fail('input', raw.message, EXIT.inputInvalid);
|
|
297
|
+
else if (
|
|
298
|
+
trigger.kind === 'poll' &&
|
|
299
|
+
(typeof raw.value !== 'object' ||
|
|
300
|
+
raw.value === null ||
|
|
301
|
+
typeof (raw.value as { value?: unknown }).value !== 'string')
|
|
302
|
+
)
|
|
303
|
+
exec = fail(
|
|
304
|
+
'input',
|
|
305
|
+
`poll trigger '${name}': the event must be {"value": "<string>"}`,
|
|
306
|
+
EXIT.inputInvalid,
|
|
307
|
+
);
|
|
308
|
+
else if (
|
|
309
|
+
trigger.kind === 'push' &&
|
|
310
|
+
(typeof raw.value !== 'object' || raw.value === null || Array.isArray(raw.value))
|
|
311
|
+
)
|
|
312
|
+
exec = fail(
|
|
313
|
+
'input',
|
|
314
|
+
`push trigger: the event must be the webhook payload object`,
|
|
315
|
+
EXIT.inputInvalid,
|
|
316
|
+
);
|
|
317
|
+
else if (trigger.kind === 'webhook' && !isWebhookEvent(raw.value))
|
|
318
|
+
exec = fail(
|
|
319
|
+
'input',
|
|
320
|
+
`webhook trigger '${name}': the event must be {"headers": {...}, "body": ...}`,
|
|
321
|
+
EXIT.inputInvalid,
|
|
322
|
+
);
|
|
222
323
|
else {
|
|
223
324
|
let mapped: unknown;
|
|
224
325
|
let threw: unknown = undefined;
|
|
225
326
|
try {
|
|
226
327
|
mapped =
|
|
227
|
-
trigger.kind ===
|
|
228
|
-
|
|
229
|
-
|
|
328
|
+
trigger.kind === 'poll'
|
|
329
|
+
? await trigger.map((raw.value as { value: string }).value)
|
|
330
|
+
: trigger.kind === 'webhook'
|
|
331
|
+
? await trigger.map(raw.value as WebhookEvent)
|
|
332
|
+
: await trigger.map(raw.value);
|
|
230
333
|
} catch (e) {
|
|
231
|
-
threw = e ?? new Error(
|
|
334
|
+
threw = e ?? new Error('undefined');
|
|
232
335
|
}
|
|
233
|
-
if (threw !== undefined)
|
|
234
|
-
|
|
336
|
+
if (threw !== undefined)
|
|
337
|
+
exec = fail(
|
|
338
|
+
'handler',
|
|
339
|
+
`trigger '${name}' mapper threw: ${(threw as Error)?.message ?? String(threw)}`,
|
|
340
|
+
EXIT.handlerFailed,
|
|
341
|
+
);
|
|
342
|
+
else if (mapped === null && trigger.kind === 'webhook')
|
|
343
|
+
exec = {
|
|
344
|
+
envelope: { status: 'skipped', reason: `trigger '${name}' ignored the delivery` },
|
|
345
|
+
exitCode: EXIT.ok,
|
|
346
|
+
};
|
|
235
347
|
else {
|
|
236
|
-
const r = parseStrict(data.input.schema, mapped,
|
|
348
|
+
const r = parseStrict(data.input.schema, mapped, 'input');
|
|
237
349
|
exec = r.ok
|
|
238
|
-
? {
|
|
239
|
-
|
|
350
|
+
? {
|
|
351
|
+
envelope: { status: 'succeeded', output: r.value } satisfies Envelope,
|
|
352
|
+
exitCode: EXIT.ok,
|
|
353
|
+
}
|
|
354
|
+
: fail(
|
|
355
|
+
'output',
|
|
356
|
+
`trigger '${name}': mapped input does not match ${data.input.name}:\n ${r.issues.join('\n ')}`,
|
|
357
|
+
EXIT.outputInvalid,
|
|
358
|
+
);
|
|
240
359
|
}
|
|
241
360
|
}
|
|
242
361
|
return finish(exec, io);
|
|
243
362
|
}
|
|
244
363
|
|
|
245
364
|
/** Entry point of the compiled binary (and the dev runner). Returns the exit code. */
|
|
246
|
-
export async function runHarness(
|
|
365
|
+
export async function runHarness(
|
|
366
|
+
cfg: HarnessConfig,
|
|
367
|
+
argv: readonly string[],
|
|
368
|
+
io: HarnessIO = defaultIO,
|
|
369
|
+
): Promise<number> {
|
|
247
370
|
const [mode, ...rest] = argv;
|
|
248
371
|
switch (mode) {
|
|
249
|
-
case
|
|
372
|
+
case 'describe':
|
|
250
373
|
return describeMode(cfg, rest, io);
|
|
251
|
-
case
|
|
374
|
+
case 'run':
|
|
252
375
|
return runMode(cfg, rest[0], io);
|
|
253
|
-
case
|
|
376
|
+
case 'map-trigger':
|
|
254
377
|
return mapTriggerMode(cfg, rest[0], io);
|
|
255
378
|
default:
|
|
256
379
|
io.stderr(USAGE);
|
|
@@ -262,8 +385,8 @@ export { formatIssues, formatLog };
|
|
|
262
385
|
|
|
263
386
|
function selectedCases(): string[] {
|
|
264
387
|
try {
|
|
265
|
-
const v = JSON.parse(process.env[ENV_CASES] ??
|
|
266
|
-
return Array.isArray(v) ? v.filter((x): x is string => typeof x ===
|
|
388
|
+
const v = JSON.parse(process.env[ENV_CASES] ?? '[]') as unknown;
|
|
389
|
+
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
|
267
390
|
} catch {
|
|
268
391
|
return [];
|
|
269
392
|
}
|
package/src/harness/result.ts
CHANGED
|
@@ -1,22 +1,29 @@
|
|
|
1
1
|
// Writing, printing and uploading the result of `run` / `map-trigger`.
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { canonicalJson } from '../util/canonical.ts';
|
|
12
|
+
import { dirEntries, writeTarGz, type TarEntry } from '../util/tar.ts';
|
|
13
|
+
import { redactUrl } from '../ctx/files.ts';
|
|
14
|
+
import type { Envelope } from './execute.ts';
|
|
8
15
|
|
|
9
|
-
export const RESULT_BEGIN =
|
|
10
|
-
export const RESULT_END =
|
|
11
|
-
export const MANIFEST_BEGIN =
|
|
12
|
-
export const MANIFEST_END =
|
|
16
|
+
export const RESULT_BEGIN = '---OLVE-RESULT-BEGIN---';
|
|
17
|
+
export const RESULT_END = '---OLVE-RESULT-END---';
|
|
18
|
+
export const MANIFEST_BEGIN = '---OLVE-MANIFEST-BEGIN---';
|
|
19
|
+
export const MANIFEST_END = '---OLVE-MANIFEST-END---';
|
|
13
20
|
|
|
14
21
|
/** Atomically writes the envelope to `<work>/output.json`. */
|
|
15
22
|
export function writeEnvelope(work: string, envelope: Envelope): string {
|
|
16
23
|
mkdirSync(work, { recursive: true });
|
|
17
|
-
const path = join(work,
|
|
18
|
-
writeFileSync(path +
|
|
19
|
-
renameSync(path +
|
|
24
|
+
const path = join(work, 'output.json');
|
|
25
|
+
writeFileSync(path + '.partial', canonicalJson(envelope));
|
|
26
|
+
renameSync(path + '.partial', path);
|
|
20
27
|
return path;
|
|
21
28
|
}
|
|
22
29
|
|
|
@@ -27,9 +34,15 @@ export function sentinelBlock(envelope: Envelope): string {
|
|
|
27
34
|
|
|
28
35
|
/** Packs output.json + files/ into result.tar.gz (deterministic). */
|
|
29
36
|
export async function packResult(work: string, out: string): Promise<void> {
|
|
30
|
-
const entries: TarEntry[] = [
|
|
31
|
-
|
|
32
|
-
|
|
37
|
+
const entries: TarEntry[] = [
|
|
38
|
+
{ path: 'output.json', type: 'file', mode: 0o644, source: join(work, 'output.json') },
|
|
39
|
+
];
|
|
40
|
+
const files = join(work, 'files');
|
|
41
|
+
if (existsSync(files))
|
|
42
|
+
entries.push(
|
|
43
|
+
{ path: 'files', type: 'dir', mode: 0o755 },
|
|
44
|
+
...dirEntries(files, 'files'),
|
|
45
|
+
);
|
|
33
46
|
await writeTarGz(out, entries);
|
|
34
47
|
}
|
|
35
48
|
|
|
@@ -48,7 +61,7 @@ export async function uploadFile(
|
|
|
48
61
|
let status = 0;
|
|
49
62
|
let detail: string;
|
|
50
63
|
try {
|
|
51
|
-
const res = await fetch(url, { method:
|
|
64
|
+
const res = await fetch(url, { method: 'PUT', body: Bun.file(file) });
|
|
52
65
|
status = res.status;
|
|
53
66
|
if (res.ok) return;
|
|
54
67
|
detail = `HTTP ${res.status} ${res.statusText}`;
|
|
@@ -57,7 +70,8 @@ export async function uploadFile(
|
|
|
57
70
|
detail = (e as Error).message;
|
|
58
71
|
}
|
|
59
72
|
const transient = status === 0 || TRANSIENT(status);
|
|
60
|
-
if (!transient || i >= attempts)
|
|
73
|
+
if (!transient || i >= attempts)
|
|
74
|
+
throw new Error(`upload to ${redactUrl(url)} failed: ${detail}`);
|
|
61
75
|
log(`upload to ${redactUrl(url)} failed (${detail}); retry ${i}/${attempts - 1}`);
|
|
62
76
|
await Bun.sleep(base * 2 ** (i - 1));
|
|
63
77
|
}
|
|
@@ -68,8 +82,10 @@ export async function uploadFile(
|
|
|
68
82
|
* *contents* of files/. The directories themselves are kept (olve-init creates them 1777).
|
|
69
83
|
*/
|
|
70
84
|
export function removeStale(work: string): void {
|
|
71
|
-
rmSync(join(work,
|
|
72
|
-
rmSync(join(work,
|
|
73
|
-
const files = join(work,
|
|
74
|
-
if (existsSync(files))
|
|
85
|
+
rmSync(join(work, 'output.json'), { force: true });
|
|
86
|
+
rmSync(join(work, 'output.json.partial'), { force: true });
|
|
87
|
+
const files = join(work, 'files');
|
|
88
|
+
if (existsSync(files))
|
|
89
|
+
for (const e of readdirSync(files))
|
|
90
|
+
rmSync(join(files, e), { recursive: true, force: true });
|
|
75
91
|
}
|
package/src/harness/validate.ts
CHANGED
|
@@ -1,23 +1,30 @@
|
|
|
1
|
-
import { z } from
|
|
2
|
-
import { isModel, isOptional } from
|
|
3
|
-
import { isExternal } from
|
|
4
|
-
import type { AnyStep } from
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { isModel, isOptional } from '../core/models.ts';
|
|
3
|
+
import { isExternal } from '../core/external.ts';
|
|
4
|
+
import type { AnyStep } from '../core/steps.ts';
|
|
5
5
|
|
|
6
6
|
export type Validation<T> = { ok: true; value: T } | { ok: false; issues: string[] };
|
|
7
7
|
|
|
8
8
|
export function formatIssues(err: z.ZodError, prefix: string): string[] {
|
|
9
|
-
return err.issues.map(
|
|
9
|
+
return err.issues.map(
|
|
10
|
+
(i) =>
|
|
11
|
+
`${prefix}${i.path.length ? '.' + i.path.map(String).join('.') : ''}: ${i.message}`,
|
|
12
|
+
);
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
|
13
|
-
typeof v ===
|
|
16
|
+
typeof v === 'object' &&
|
|
17
|
+
v !== null &&
|
|
18
|
+
!Array.isArray(v) &&
|
|
19
|
+
(Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
|
|
14
20
|
|
|
15
21
|
/**
|
|
16
22
|
* zod strips unknown keys silently (at every depth). Compares the raw value with the parsed one:
|
|
17
23
|
* any key present in a raw object but absent from the parsed object at the same place was stripped.
|
|
18
24
|
*/
|
|
19
|
-
export function strippedKeys(raw: unknown, parsed: unknown, path =
|
|
20
|
-
if (Array.isArray(raw) && Array.isArray(parsed))
|
|
25
|
+
export function strippedKeys(raw: unknown, parsed: unknown, path = ''): string[] {
|
|
26
|
+
if (Array.isArray(raw) && Array.isArray(parsed))
|
|
27
|
+
return raw.flatMap((x, i) => strippedKeys(x, parsed[i], `${path}[${i}]`));
|
|
21
28
|
if (isPlainObject(raw) && isPlainObject(parsed)) {
|
|
22
29
|
const out: string[] = [];
|
|
23
30
|
for (const k of Object.keys(raw)) {
|
|
@@ -31,11 +38,21 @@ export function strippedKeys(raw: unknown, parsed: unknown, path = ""): string[]
|
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
/** Strict parse: zod validation plus rejection of unknown keys at any depth. */
|
|
34
|
-
export function parseStrict(
|
|
41
|
+
export function parseStrict(
|
|
42
|
+
schema: z.ZodType,
|
|
43
|
+
value: unknown,
|
|
44
|
+
prefix: string,
|
|
45
|
+
): Validation<unknown> {
|
|
35
46
|
const r = schema.safeParse(value);
|
|
36
47
|
if (!r.success) return { ok: false, issues: formatIssues(r.error, prefix) };
|
|
37
48
|
const extra = strippedKeys(value, r.data);
|
|
38
|
-
if (extra.length)
|
|
49
|
+
if (extra.length)
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
issues: extra.map(
|
|
53
|
+
(k) => `${prefix}.${k}: unknown field (outputs are validated strictly)`,
|
|
54
|
+
),
|
|
55
|
+
};
|
|
39
56
|
return { ok: true, value: r.data };
|
|
40
57
|
}
|
|
41
58
|
|
|
@@ -46,30 +63,51 @@ function entrySchema(e: unknown): z.ZodType {
|
|
|
46
63
|
}
|
|
47
64
|
|
|
48
65
|
/** Reserved input.json key holding the step's config entries (written by the service). */
|
|
49
|
-
export const CONFIG_KEY =
|
|
66
|
+
export const CONFIG_KEY = '$config';
|
|
50
67
|
|
|
51
68
|
/** Parses `$config` with each declared entry's schema (defaults applied). Undeclared entries are ignored. */
|
|
52
|
-
export function validateConfig(
|
|
69
|
+
export function validateConfig(
|
|
70
|
+
step: AnyStep,
|
|
71
|
+
raw: unknown,
|
|
72
|
+
): Validation<Record<string, unknown>> {
|
|
53
73
|
const given = isPlainObject(raw) ? raw[CONFIG_KEY] : undefined;
|
|
54
|
-
if (given !== undefined && !isPlainObject(given))
|
|
74
|
+
if (given !== undefined && !isPlainObject(given))
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
issues: [`input.${CONFIG_KEY} must be an object keyed by config entry names`],
|
|
78
|
+
};
|
|
55
79
|
const issues: string[] = [];
|
|
56
80
|
const out: Record<string, unknown> = {};
|
|
57
81
|
for (const e of step.config ?? []) {
|
|
58
82
|
const value = given?.[e.name];
|
|
59
83
|
const r = e.schema.safeParse(value);
|
|
60
|
-
if (!r.success)
|
|
84
|
+
if (!r.success)
|
|
85
|
+
issues.push(
|
|
86
|
+
...formatIssues(r.error, `config.${e.name}`).map((i) =>
|
|
87
|
+
value === undefined ? `${i} (config entry '${e.name}' is not set)` : i,
|
|
88
|
+
),
|
|
89
|
+
);
|
|
61
90
|
else if (r.data !== undefined) out[e.name] = r.data;
|
|
62
91
|
}
|
|
63
92
|
return issues.length ? { ok: false, issues } : { ok: true, value: out };
|
|
64
93
|
}
|
|
65
94
|
|
|
66
95
|
/** Validates input.json against the step's input entries. Unknown top-level keys are rejected; nested values are parsed (unknown nested keys stripped). */
|
|
67
|
-
export function validateInput(
|
|
68
|
-
|
|
96
|
+
export function validateInput(
|
|
97
|
+
step: AnyStep,
|
|
98
|
+
raw: unknown,
|
|
99
|
+
): Validation<Record<string, unknown>> {
|
|
100
|
+
if (!isPlainObject(raw))
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
issues: ["input.json must be a JSON object keyed by the step's input names"],
|
|
104
|
+
};
|
|
69
105
|
const issues: string[] = [];
|
|
70
106
|
const out: Record<string, unknown> = {};
|
|
71
107
|
const declared = step.input as Record<string, unknown>;
|
|
72
|
-
for (const k of Object.keys(raw))
|
|
108
|
+
for (const k of Object.keys(raw))
|
|
109
|
+
if (k !== CONFIG_KEY && !Object.hasOwn(declared, k))
|
|
110
|
+
issues.push(`input.${k}: not an input of step '${step.name}'`);
|
|
73
111
|
for (const [k, e] of Object.entries(declared)) {
|
|
74
112
|
if (!Object.hasOwn(raw, k)) {
|
|
75
113
|
if (isOptional(e)) continue;
|
|
@@ -87,7 +125,12 @@ export function validateInput(step: AnyStep, raw: unknown): Validation<Record<st
|
|
|
87
125
|
export function validateOutput(step: AnyStep, value: unknown): Validation<unknown> {
|
|
88
126
|
if (!step.output) {
|
|
89
127
|
if (value === undefined || value === null) return { ok: true, value: null };
|
|
90
|
-
return {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
issues: [
|
|
131
|
+
`step '${step.name}' declares no output model but the handler returned a value`,
|
|
132
|
+
],
|
|
133
|
+
};
|
|
91
134
|
}
|
|
92
|
-
return parseStrict(step.output.schema, value,
|
|
135
|
+
return parseStrict(step.output.schema, value, 'output');
|
|
93
136
|
}
|