@olivervea/pipelines 2.0.0-beta.3 → 2.0.0-beta.30
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 +163 -78
- package/manifest.schema.json +300 -16
- package/package.json +6 -4
- package/src/assets.d.ts +2 -2
- package/src/build/build.ts +98 -64
- package/src/build/cli.ts +33 -23
- package/src/build/elf.ts +16 -7
- package/src/build/hashes.ts +40 -51
- package/src/build/project.ts +55 -52
- package/src/builder/pipeline.ts +348 -89
- package/src/builder/refs.ts +15 -5
- package/src/core/brands.ts +6 -5
- package/src/core/config-refs.ts +103 -0
- package/src/core/config.ts +224 -0
- package/src/core/external.ts +86 -0
- package/src/core/handlers.ts +62 -15
- package/src/core/matrix.ts +150 -0
- package/src/core/models.ts +37 -15
- package/src/core/secrets.ts +2 -2
- package/src/core/steps.ts +113 -38
- package/src/core/types.ts +5 -1
- package/src/core/webhooks.ts +21 -7
- package/src/ctx/files.ts +119 -42
- package/src/ctx/index.ts +103 -38
- package/src/ctx/links.ts +30 -0
- package/src/ctx/sh.ts +104 -44
- package/src/ctx/types.ts +24 -4
- package/src/describe/config-schema.ts +98 -0
- package/src/describe/context.ts +187 -0
- package/src/describe/describe.ts +63 -355
- package/src/describe/failure-handlers.ts +34 -0
- package/src/describe/manifest-schema.ts +304 -61
- package/src/describe/mapper-source.ts +252 -105
- package/src/describe/record.ts +67 -37
- package/src/describe/schemas.ts +71 -0
- package/src/describe/stages.ts +166 -0
- package/src/describe/step.ts +544 -0
- package/src/describe/triggers.ts +72 -0
- package/src/dev/cli.ts +62 -35
- package/src/harness/env.ts +47 -25
- package/src/harness/execute.ts +74 -30
- package/src/harness/exit.ts +1 -1
- package/src/harness/index.ts +19 -8
- package/src/harness/main.ts +234 -98
- package/src/harness/result.ts +45 -24
- package/src/harness/validate.ts +82 -15
- package/src/index.ts +139 -10
- package/src/library/checks.ts +11 -6
- package/src/library/compose-deploy.ts +115 -0
- package/src/library/github-source.ts +100 -47
- package/src/library/helm-deploy-ssh.ts +426 -112
- package/src/library/helm-deploy.ts +529 -0
- package/src/library/http-health.ts +7 -5
- package/src/library/image-build.ts +510 -162
- package/src/library/index.ts +48 -14
- package/src/library/models.ts +35 -14
- package/src/library/ssh.ts +44 -18
- package/src/testing/index.ts +100 -35
- package/src/util/canonical.ts +7 -4
- package/src/util/code-hash.ts +17 -0
- package/src/util/hash.ts +5 -5
- package/src/util/tar.ts +83 -38
- package/src/library/script.ts +0 -85
- package/src/util/handler-hash.ts +0 -22
package/src/harness/main.ts
CHANGED
|
@@ -2,29 +2,41 @@
|
|
|
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
|
|
9
|
-
import type {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 { cellName, parseCell } from '../core/matrix.ts';
|
|
11
|
+
import type { WebhookEvent } from '../core/webhooks.ts';
|
|
12
|
+
import { pipelineData, type PipelineData, type StageNode } from '../builder/pipeline.ts';
|
|
13
|
+
import { createCtx, formatLog } from '../ctx/index.ts';
|
|
14
|
+
import { DescribeError, describePipeline, manifestText } from '../describe/describe.ts';
|
|
15
|
+
import type { HarnessInfo } from '../util/code-hash.ts';
|
|
16
|
+
import { callbackUrlsFromEnv, ENV, paths, runInfoFromEnv } from './env.ts';
|
|
17
|
+
import { EXIT } from './exit.ts';
|
|
18
|
+
import {
|
|
19
|
+
executeStep,
|
|
20
|
+
type Envelope,
|
|
21
|
+
type Execution,
|
|
22
|
+
type StepFunction,
|
|
23
|
+
} from './execute.ts';
|
|
24
|
+
import {
|
|
25
|
+
MANIFEST_BEGIN,
|
|
26
|
+
MANIFEST_END,
|
|
27
|
+
packResult,
|
|
28
|
+
removeStale,
|
|
29
|
+
sentinelBlock,
|
|
30
|
+
uploadFile,
|
|
31
|
+
writeEnvelope,
|
|
32
|
+
} from './result.ts';
|
|
33
|
+
import { formatIssues, parseStrict } from './validate.ts';
|
|
22
34
|
|
|
23
35
|
export interface HarnessConfig {
|
|
24
|
-
/** Default export of
|
|
36
|
+
/** Default export of the entry point. */
|
|
25
37
|
pipeline: unknown;
|
|
26
|
-
|
|
27
|
-
|
|
38
|
+
/** Hash of the build (from the build; includes the harness identity). */
|
|
39
|
+
codeHash?: string;
|
|
28
40
|
harness: HarnessInfo;
|
|
29
41
|
}
|
|
30
42
|
|
|
@@ -38,57 +50,78 @@ export const defaultIO: HarnessIO = {
|
|
|
38
50
|
await Bun.write(Bun.stdout, text);
|
|
39
51
|
},
|
|
40
52
|
stderr: (line) => {
|
|
41
|
-
process.stderr.write(line.endsWith(
|
|
53
|
+
process.stderr.write(line.endsWith('\n') ? line : line + '\n');
|
|
42
54
|
},
|
|
43
55
|
};
|
|
44
56
|
|
|
45
|
-
const USAGE =
|
|
57
|
+
const USAGE =
|
|
58
|
+
'usage: step describe [--sentinels] | step run <step> | step map-trigger <trigger>';
|
|
46
59
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
60
|
+
/** Every step with the `select` case conditions (`<ref>.<discriminator>=<value>`) it sits in. */
|
|
61
|
+
export function stepsWithCases(data: PipelineData): { step: AnyStep; cases: string[] }[] {
|
|
62
|
+
const out: { step: AnyStep; cases: string[] }[] = [];
|
|
63
|
+
const walk = (nodes: readonly StageNode[], cases: string[]) => {
|
|
64
|
+
for (const n of nodes) {
|
|
65
|
+
if (n.kind === 'stage') out.push(...n.steps.map((s) => ({ step: s.step, cases })));
|
|
66
|
+
else if (n.kind === 'when') walk(n.nodes, cases);
|
|
67
|
+
else {
|
|
68
|
+
const union = isConfigRef(n.on) ? unionAt(n.on) : undefined;
|
|
69
|
+
for (const [value, inner] of Object.entries(n.cases))
|
|
70
|
+
walk(
|
|
71
|
+
inner,
|
|
72
|
+
union
|
|
73
|
+
? [...cases, `${configRefPath(n.on)}.${union.discriminator}=${value}`]
|
|
74
|
+
: cases,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
51
78
|
};
|
|
52
|
-
walk(data.nodes);
|
|
53
|
-
for (const fh of data.failureHandlers)
|
|
79
|
+
walk(data.nodes, []);
|
|
80
|
+
for (const fh of data.failureHandlers) out.push({ step: fh.step, cases: [] });
|
|
54
81
|
return out;
|
|
55
82
|
}
|
|
56
83
|
|
|
84
|
+
/** The step `run <name>` executes: the only one so named, or the one in the cases the service selected (`OLVE_CASES`). */
|
|
85
|
+
export function findStep(
|
|
86
|
+
data: PipelineData,
|
|
87
|
+
name: string,
|
|
88
|
+
selected: readonly string[],
|
|
89
|
+
): AnyStep | string {
|
|
90
|
+
const candidates = stepsWithCases(data).filter((c) => c.step.name === name);
|
|
91
|
+
const distinct = candidates.filter(
|
|
92
|
+
(c, i) => candidates.findIndex((d) => d.step === c.step) === i,
|
|
93
|
+
);
|
|
94
|
+
if (distinct.length === 0) return `unknown step '${name}'`;
|
|
95
|
+
if (distinct.length === 1) return distinct[0]!.step;
|
|
96
|
+
const chosen = distinct.filter((c) => c.cases.every((k) => selected.includes(k)));
|
|
97
|
+
if (chosen.length === 1) return chosen[0]!.step;
|
|
98
|
+
return `step '${name}' is in several select cases; ${ENV_CASES} must name the selected one (got ${JSON.stringify(selected)})`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The select case conditions of the step being run (JSON array), set by the service for steps inside a select. */
|
|
102
|
+
export const ENV_CASES = 'OLVE_CASES';
|
|
103
|
+
|
|
57
104
|
/** Installs SIGTERM/SIGINT handlers that abort the returned signal (PID 1 ignores signals without them). */
|
|
58
105
|
export function cancellation(): { signal: AbortSignal; dispose(): void } {
|
|
59
106
|
const ac = new AbortController();
|
|
60
|
-
const onTerm = () => ac.abort(
|
|
61
|
-
const onInt = () =>
|
|
62
|
-
|
|
63
|
-
process.on(
|
|
107
|
+
const onTerm = () => ac.abort('SIGTERM');
|
|
108
|
+
const onInt = () =>
|
|
109
|
+
ac.signal.aborted ? process.exit(EXIT.sigint) : ac.abort('SIGINT');
|
|
110
|
+
process.on('SIGTERM', onTerm);
|
|
111
|
+
process.on('SIGINT', onInt);
|
|
64
112
|
return {
|
|
65
113
|
signal: ac.signal,
|
|
66
114
|
dispose() {
|
|
67
|
-
process.off(
|
|
68
|
-
process.off(
|
|
115
|
+
process.off('SIGTERM', onTerm);
|
|
116
|
+
process.off('SIGINT', onInt);
|
|
69
117
|
},
|
|
70
118
|
};
|
|
71
119
|
}
|
|
72
120
|
|
|
73
|
-
/**
|
|
74
|
-
export
|
|
75
|
-
if (step.
|
|
76
|
-
|
|
77
|
-
return step.builtin.run as StepFunction;
|
|
78
|
-
}
|
|
79
|
-
const load = handlers[step.name];
|
|
80
|
-
if (!load) return `step '${step.name}' has no handler`;
|
|
81
|
-
let mod: { default: unknown };
|
|
82
|
-
try {
|
|
83
|
-
mod = await load();
|
|
84
|
-
} catch (e) {
|
|
85
|
-
return `the handler of step '${step.name}' failed to load: ${(e as Error)?.stack ?? String(e)}`;
|
|
86
|
-
}
|
|
87
|
-
const impl = mod.default;
|
|
88
|
-
if (!isImplementation(impl)) return `the handler of step '${step.name}' must default-export implement(<step '${step.name}'>, …)`;
|
|
89
|
-
if (impl.step !== step)
|
|
90
|
-
return `the handler of step '${step.name}' implements a different step object (it must be implement(<the '${step.name}' step from pipeline.ts>, …))`;
|
|
91
|
-
return impl.handler as unknown as StepFunction;
|
|
121
|
+
/** The function `step` carries, or what is wrong. */
|
|
122
|
+
export function resolveStepFunction(step: AnyStep): StepFunction | string {
|
|
123
|
+
if (isImplementation(step)) return step.handler as unknown as StepFunction;
|
|
124
|
+
return `step '${step.name}' has no implementation (stage implement(step({...}), fn))`;
|
|
92
125
|
}
|
|
93
126
|
|
|
94
127
|
/** Writes output.json, prints the sentinel block and uploads result.tar.gz when OLVE_RESULT_URL is set. */
|
|
@@ -100,7 +133,7 @@ export async function finish(exec: Execution, io: HarnessIO): Promise<number> {
|
|
|
100
133
|
if (!url) return exec.exitCode;
|
|
101
134
|
try {
|
|
102
135
|
mkdirSync(p.tmp, { recursive: true });
|
|
103
|
-
const archive = join(p.tmp,
|
|
136
|
+
const archive = join(p.tmp, 'result.tar.gz');
|
|
104
137
|
await packResult(p.work, archive);
|
|
105
138
|
await uploadFile(url, archive, io.stderr);
|
|
106
139
|
} catch (e) {
|
|
@@ -110,18 +143,29 @@ export async function finish(exec: Execution, io: HarnessIO): Promise<number> {
|
|
|
110
143
|
return exec.exitCode;
|
|
111
144
|
}
|
|
112
145
|
|
|
113
|
-
function readJson(
|
|
146
|
+
function readJson(
|
|
147
|
+
path: string,
|
|
148
|
+
): { ok: true; value: unknown } | { ok: false; message: string } {
|
|
114
149
|
try {
|
|
115
|
-
return { ok: true, value: JSON.parse(readFileSync(path,
|
|
150
|
+
return { ok: true, value: JSON.parse(readFileSync(path, 'utf8')) };
|
|
116
151
|
} catch (e) {
|
|
117
152
|
return { ok: false, message: `cannot read ${path}: ${(e as Error).message}` };
|
|
118
153
|
}
|
|
119
154
|
}
|
|
120
155
|
|
|
121
|
-
async function describeMode(
|
|
122
|
-
|
|
156
|
+
async function describeMode(
|
|
157
|
+
cfg: HarnessConfig,
|
|
158
|
+
args: string[],
|
|
159
|
+
io: HarnessIO,
|
|
160
|
+
): Promise<number> {
|
|
161
|
+
const sentinels = args.includes('--sentinels');
|
|
123
162
|
try {
|
|
124
|
-
const text = manifestText(
|
|
163
|
+
const text = manifestText(
|
|
164
|
+
describePipeline(cfg.pipeline, {
|
|
165
|
+
codeHash: cfg.codeHash,
|
|
166
|
+
harness: cfg.harness,
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
125
169
|
await io.stdout(sentinels ? `${MANIFEST_BEGIN}\n${text}${MANIFEST_END}\n` : text);
|
|
126
170
|
return EXIT.ok;
|
|
127
171
|
} catch (e) {
|
|
@@ -133,29 +177,55 @@ async function describeMode(cfg: HarnessConfig, args: string[], io: HarnessIO):
|
|
|
133
177
|
}
|
|
134
178
|
}
|
|
135
179
|
|
|
136
|
-
async function runMode(
|
|
137
|
-
|
|
180
|
+
async function runMode(
|
|
181
|
+
cfg: HarnessConfig,
|
|
182
|
+
name: string | undefined,
|
|
183
|
+
io: HarnessIO,
|
|
184
|
+
): Promise<number> {
|
|
185
|
+
if (!name) return (io.stderr(USAGE), EXIT.usage);
|
|
138
186
|
const data = pipelineData(cfg.pipeline);
|
|
139
|
-
const step =
|
|
140
|
-
if (
|
|
141
|
-
const fn =
|
|
142
|
-
if (typeof fn ===
|
|
187
|
+
const step = findStep(data, name, selectedCases());
|
|
188
|
+
if (typeof step === 'string') return (io.stderr(step), EXIT.usage);
|
|
189
|
+
const fn = resolveStepFunction(step);
|
|
190
|
+
if (typeof fn === 'string') return (io.stderr(fn), EXIT.configInvalid);
|
|
143
191
|
|
|
144
192
|
const p = paths();
|
|
145
193
|
removeStale(p.work);
|
|
146
194
|
mkdirSync(p.files, { recursive: true });
|
|
147
195
|
mkdirSync(p.tmp, { recursive: true });
|
|
148
196
|
const raw = readJson(p.input);
|
|
197
|
+
const cell = step.matrix
|
|
198
|
+
? parseCell(step.name, step.matrix, process.env[ENV.cell])
|
|
199
|
+
: undefined;
|
|
149
200
|
const cancel = cancellation();
|
|
150
201
|
try {
|
|
151
202
|
let exec: Execution;
|
|
152
|
-
if (!raw.ok) {
|
|
153
|
-
|
|
154
|
-
|
|
203
|
+
if (!raw.ok || (cell && !cell.ok)) {
|
|
204
|
+
const message = !raw.ok ? raw.message : cell && !cell.ok ? cell.message : '';
|
|
205
|
+
io.stderr(message);
|
|
206
|
+
exec = {
|
|
207
|
+
envelope: { status: 'failed', error: { kind: 'input', message } },
|
|
208
|
+
exitCode: EXIT.inputInvalid,
|
|
209
|
+
};
|
|
155
210
|
} else {
|
|
156
|
-
const run = runInfoFromEnv(step.name);
|
|
157
|
-
const ctx = createCtx({
|
|
158
|
-
|
|
211
|
+
const run = runInfoFromEnv(cell?.ok ? cellName(step.name, cell.cell) : step.name);
|
|
212
|
+
const ctx = createCtx({
|
|
213
|
+
run,
|
|
214
|
+
secrets: step.secrets,
|
|
215
|
+
paths: { root: p.root, workFiles: p.files, tmp: p.tmp },
|
|
216
|
+
signal: cancel.signal,
|
|
217
|
+
logSink: io.stderr,
|
|
218
|
+
callbackUrls: callbackUrlsFromEnv(),
|
|
219
|
+
...(cell?.ok ? { cell: cell.cell } : {}),
|
|
220
|
+
});
|
|
221
|
+
exec = await executeStep({
|
|
222
|
+
step,
|
|
223
|
+
fn,
|
|
224
|
+
rawInput: raw.value,
|
|
225
|
+
ctx,
|
|
226
|
+
workFiles: p.files,
|
|
227
|
+
log: io.stderr,
|
|
228
|
+
});
|
|
159
229
|
}
|
|
160
230
|
return await finish(exec, io);
|
|
161
231
|
} finally {
|
|
@@ -164,64 +234,121 @@ async function runMode(cfg: HarnessConfig, name: string | undefined, io: Harness
|
|
|
164
234
|
}
|
|
165
235
|
|
|
166
236
|
function isWebhookEvent(v: unknown): boolean {
|
|
167
|
-
if (typeof v !==
|
|
237
|
+
if (typeof v !== 'object' || v === null || Array.isArray(v) || !('body' in v))
|
|
238
|
+
return false;
|
|
168
239
|
const headers = (v as { headers?: unknown }).headers;
|
|
169
|
-
return typeof headers ===
|
|
240
|
+
return typeof headers === 'object' && headers !== null && !Array.isArray(headers);
|
|
170
241
|
}
|
|
171
242
|
|
|
172
|
-
async function mapTriggerMode(
|
|
173
|
-
|
|
243
|
+
async function mapTriggerMode(
|
|
244
|
+
cfg: HarnessConfig,
|
|
245
|
+
name: string | undefined,
|
|
246
|
+
io: HarnessIO,
|
|
247
|
+
): Promise<number> {
|
|
248
|
+
if (!name) return (io.stderr(USAGE), EXIT.usage);
|
|
174
249
|
const data = pipelineData(cfg.pipeline);
|
|
175
250
|
const trigger = data.triggers.find((t) => t.name === name);
|
|
176
|
-
if (!trigger)
|
|
251
|
+
if (!trigger)
|
|
252
|
+
return (
|
|
253
|
+
io.stderr(
|
|
254
|
+
`unknown trigger '${name}' (known: ${data.triggers.map((t) => t.name).join(', ') || 'none'})`,
|
|
255
|
+
),
|
|
256
|
+
EXIT.usage
|
|
257
|
+
);
|
|
177
258
|
const p = paths();
|
|
178
259
|
removeStale(p.work);
|
|
179
260
|
mkdirSync(p.work, { recursive: true });
|
|
180
261
|
const raw = readJson(p.input);
|
|
181
|
-
const fail = (
|
|
262
|
+
const fail = (
|
|
263
|
+
kind: 'input' | 'output' | 'handler',
|
|
264
|
+
message: string,
|
|
265
|
+
exitCode: number,
|
|
266
|
+
): Execution => {
|
|
182
267
|
io.stderr(message);
|
|
183
|
-
return { envelope: { status:
|
|
268
|
+
return { envelope: { status: 'failed', error: { kind, message } }, exitCode };
|
|
184
269
|
};
|
|
185
270
|
let exec: Execution;
|
|
186
|
-
if (!raw.ok) exec = fail(
|
|
187
|
-
else if (
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
271
|
+
if (!raw.ok) exec = fail('input', raw.message, EXIT.inputInvalid);
|
|
272
|
+
else if (
|
|
273
|
+
trigger.kind === 'poll' &&
|
|
274
|
+
(typeof raw.value !== 'object' ||
|
|
275
|
+
raw.value === null ||
|
|
276
|
+
typeof (raw.value as { value?: unknown }).value !== 'string')
|
|
277
|
+
)
|
|
278
|
+
exec = fail(
|
|
279
|
+
'input',
|
|
280
|
+
`poll trigger '${name}': the event must be {"value": "<string>"}`,
|
|
281
|
+
EXIT.inputInvalid,
|
|
282
|
+
);
|
|
283
|
+
else if (
|
|
284
|
+
trigger.kind === 'push' &&
|
|
285
|
+
(typeof raw.value !== 'object' || raw.value === null || Array.isArray(raw.value))
|
|
286
|
+
)
|
|
287
|
+
exec = fail(
|
|
288
|
+
'input',
|
|
289
|
+
`push trigger: the event must be the webhook payload object`,
|
|
290
|
+
EXIT.inputInvalid,
|
|
291
|
+
);
|
|
292
|
+
else if (trigger.kind === 'webhook' && !isWebhookEvent(raw.value))
|
|
293
|
+
exec = fail(
|
|
294
|
+
'input',
|
|
295
|
+
`webhook trigger '${name}': the event must be {"headers": {...}, "body": ...}`,
|
|
296
|
+
EXIT.inputInvalid,
|
|
297
|
+
);
|
|
193
298
|
else {
|
|
194
299
|
let mapped: unknown;
|
|
195
300
|
let threw: unknown = undefined;
|
|
196
301
|
try {
|
|
197
302
|
mapped =
|
|
198
|
-
trigger.kind ===
|
|
199
|
-
|
|
200
|
-
|
|
303
|
+
trigger.kind === 'poll'
|
|
304
|
+
? await trigger.map((raw.value as { value: string }).value)
|
|
305
|
+
: trigger.kind === 'webhook'
|
|
306
|
+
? await trigger.map(raw.value as WebhookEvent)
|
|
307
|
+
: await trigger.map(raw.value);
|
|
201
308
|
} catch (e) {
|
|
202
|
-
threw = e ?? new Error(
|
|
309
|
+
threw = e ?? new Error('undefined');
|
|
203
310
|
}
|
|
204
|
-
if (threw !== undefined)
|
|
205
|
-
|
|
311
|
+
if (threw !== undefined)
|
|
312
|
+
exec = fail(
|
|
313
|
+
'handler',
|
|
314
|
+
`trigger '${name}' mapper threw: ${(threw as Error)?.message ?? String(threw)}`,
|
|
315
|
+
EXIT.handlerFailed,
|
|
316
|
+
);
|
|
317
|
+
else if (mapped === null && trigger.kind === 'webhook')
|
|
318
|
+
exec = {
|
|
319
|
+
envelope: { status: 'skipped', reason: `trigger '${name}' ignored the delivery` },
|
|
320
|
+
exitCode: EXIT.ok,
|
|
321
|
+
};
|
|
206
322
|
else {
|
|
207
|
-
const r = parseStrict(data.input.schema, mapped,
|
|
323
|
+
const r = parseStrict(data.input.schema, mapped, 'input');
|
|
208
324
|
exec = r.ok
|
|
209
|
-
? {
|
|
210
|
-
|
|
325
|
+
? {
|
|
326
|
+
envelope: { status: 'succeeded', output: r.value } satisfies Envelope,
|
|
327
|
+
exitCode: EXIT.ok,
|
|
328
|
+
}
|
|
329
|
+
: fail(
|
|
330
|
+
'output',
|
|
331
|
+
`trigger '${name}': mapped input does not match ${data.input.name}:\n ${r.issues.join('\n ')}`,
|
|
332
|
+
EXIT.outputInvalid,
|
|
333
|
+
);
|
|
211
334
|
}
|
|
212
335
|
}
|
|
213
336
|
return finish(exec, io);
|
|
214
337
|
}
|
|
215
338
|
|
|
216
339
|
/** Entry point of the compiled binary (and the dev runner). Returns the exit code. */
|
|
217
|
-
export async function runHarness(
|
|
340
|
+
export async function runHarness(
|
|
341
|
+
cfg: HarnessConfig,
|
|
342
|
+
argv: readonly string[],
|
|
343
|
+
io: HarnessIO = defaultIO,
|
|
344
|
+
): Promise<number> {
|
|
218
345
|
const [mode, ...rest] = argv;
|
|
219
346
|
switch (mode) {
|
|
220
|
-
case
|
|
347
|
+
case 'describe':
|
|
221
348
|
return describeMode(cfg, rest, io);
|
|
222
|
-
case
|
|
349
|
+
case 'run':
|
|
223
350
|
return runMode(cfg, rest[0], io);
|
|
224
|
-
case
|
|
351
|
+
case 'map-trigger':
|
|
225
352
|
return mapTriggerMode(cfg, rest[0], io);
|
|
226
353
|
default:
|
|
227
354
|
io.stderr(USAGE);
|
|
@@ -230,3 +357,12 @@ export async function runHarness(cfg: HarnessConfig, argv: readonly string[], io
|
|
|
230
357
|
}
|
|
231
358
|
|
|
232
359
|
export { formatIssues, formatLog };
|
|
360
|
+
|
|
361
|
+
function selectedCases(): string[] {
|
|
362
|
+
try {
|
|
363
|
+
const v = JSON.parse(process.env[ENV_CASES] ?? '[]') as unknown;
|
|
364
|
+
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
|
365
|
+
} catch {
|
|
366
|
+
return [];
|
|
367
|
+
}
|
|
368
|
+
}
|
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
|
|
|
@@ -40,15 +53,20 @@ export async function uploadFile(
|
|
|
40
53
|
url: string,
|
|
41
54
|
file: string,
|
|
42
55
|
log: (line: string) => void,
|
|
43
|
-
opts: {
|
|
56
|
+
opts: {
|
|
57
|
+
attempts?: number;
|
|
58
|
+
baseDelayMs?: number;
|
|
59
|
+
sleep?: (ms: number) => Promise<void>;
|
|
60
|
+
} = {},
|
|
44
61
|
): Promise<void> {
|
|
45
62
|
const attempts = opts.attempts ?? 5;
|
|
46
63
|
const base = opts.baseDelayMs ?? 1000;
|
|
64
|
+
const sleep = opts.sleep ?? ((ms: number) => Bun.sleep(ms));
|
|
47
65
|
for (let i = 1; ; i++) {
|
|
48
66
|
let status = 0;
|
|
49
67
|
let detail: string;
|
|
50
68
|
try {
|
|
51
|
-
const res = await fetch(url, { method:
|
|
69
|
+
const res = await fetch(url, { method: 'PUT', body: Bun.file(file) });
|
|
52
70
|
status = res.status;
|
|
53
71
|
if (res.ok) return;
|
|
54
72
|
detail = `HTTP ${res.status} ${res.statusText}`;
|
|
@@ -57,9 +75,10 @@ export async function uploadFile(
|
|
|
57
75
|
detail = (e as Error).message;
|
|
58
76
|
}
|
|
59
77
|
const transient = status === 0 || TRANSIENT(status);
|
|
60
|
-
if (!transient || i >= attempts)
|
|
78
|
+
if (!transient || i >= attempts)
|
|
79
|
+
throw new Error(`upload to ${redactUrl(url)} failed: ${detail}`);
|
|
61
80
|
log(`upload to ${redactUrl(url)} failed (${detail}); retry ${i}/${attempts - 1}`);
|
|
62
|
-
await
|
|
81
|
+
await sleep(base * 2 ** (i - 1));
|
|
63
82
|
}
|
|
64
83
|
}
|
|
65
84
|
|
|
@@ -68,8 +87,10 @@ export async function uploadFile(
|
|
|
68
87
|
* *contents* of files/. The directories themselves are kept (olve-init creates them 1777).
|
|
69
88
|
*/
|
|
70
89
|
export function removeStale(work: string): void {
|
|
71
|
-
rmSync(join(work,
|
|
72
|
-
rmSync(join(work,
|
|
73
|
-
const files = join(work,
|
|
74
|
-
if (existsSync(files))
|
|
90
|
+
rmSync(join(work, 'output.json'), { force: true });
|
|
91
|
+
rmSync(join(work, 'output.json.partial'), { force: true });
|
|
92
|
+
const files = join(work, 'files');
|
|
93
|
+
if (existsSync(files))
|
|
94
|
+
for (const e of readdirSync(files))
|
|
95
|
+
rmSync(join(files, e), { recursive: true, force: true });
|
|
75
96
|
}
|