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