@olivervea/pipelines 2.0.0-beta.1 → 2.0.0-beta.11
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 +122 -62
- package/manifest.schema.json +161 -11
- package/package.json +2 -2
- package/src/build/build.ts +19 -17
- package/src/build/cli.ts +1 -1
- package/src/build/hashes.ts +32 -3
- package/src/build/project.ts +33 -5
- package/src/builder/pipeline.ts +92 -10
- package/src/core/brands.ts +2 -0
- package/src/core/config-refs.ts +77 -0
- package/src/core/config.ts +147 -0
- package/src/core/handlers.ts +16 -10
- package/src/core/origin.ts +20 -0
- package/src/core/steps.ts +53 -22
- package/src/core/webhooks.ts +30 -0
- package/src/ctx/index.ts +3 -0
- package/src/ctx/sh.ts +2 -1
- package/src/ctx/types.ts +4 -1
- package/src/describe/describe.ts +213 -27
- package/src/describe/manifest-schema.ts +53 -5
- package/src/dev/cli.ts +16 -9
- package/src/harness/execute.ts +7 -4
- package/src/harness/index.ts +1 -1
- package/src/harness/main.ts +68 -17
- package/src/harness/validate.ts +19 -1
- package/src/index.ts +7 -3
- package/src/library/checks.ts +23 -0
- package/src/library/compose-deploy.ts +96 -0
- package/src/library/github-source.ts +61 -38
- package/src/library/helm-deploy-ssh.ts +306 -99
- package/src/library/helm-deploy.ts +271 -0
- package/src/library/image-build.ts +350 -66
- package/src/library/index.ts +11 -7
- package/src/library/models.ts +17 -1
- package/src/library/ssh.ts +50 -0
- package/src/testing/index.ts +11 -15
- package/src/util/handler-hash.ts +4 -4
- package/src/util/tar.ts +13 -0
- package/src/library/script.ts +0 -85
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Secret } from "./secrets.ts";
|
|
2
|
+
|
|
3
|
+
/** How a webhook trigger authenticates deliveries. Build one with `hmacSha256`, `githubSignature` or `bearer`. */
|
|
4
|
+
export type WebhookVerify =
|
|
5
|
+
| { readonly scheme: "hmac-sha256"; readonly secret: Secret; readonly header: string; readonly prefix: string }
|
|
6
|
+
| { readonly scheme: "bearer"; readonly secret: Secret };
|
|
7
|
+
|
|
8
|
+
/** HMAC-SHA256 of the raw body keyed by `secret`, hex, in `header` after `prefix`. */
|
|
9
|
+
export function hmacSha256(o: { secret: Secret; header: string; prefix?: string }): WebhookVerify {
|
|
10
|
+
return Object.freeze({ scheme: "hmac-sha256", secret: o.secret, header: o.header, prefix: o.prefix ?? "" });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** GitHub's delivery signature: `X-Hub-Signature-256: sha256=<hex>`. */
|
|
14
|
+
export function githubSignature(secret: Secret): WebhookVerify {
|
|
15
|
+
return hmacSha256({ secret, header: "X-Hub-Signature-256", prefix: "sha256=" });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `Authorization: Bearer <secret>`. */
|
|
19
|
+
export function bearer(secret: Secret): WebhookVerify {
|
|
20
|
+
return Object.freeze({ scheme: "bearer", secret });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What a webhook trigger mapper receives: the request headers (lower-case names, without `authorization`
|
|
25
|
+
* and the signature header) and the body (parsed JSON, or the raw text when it isn't JSON).
|
|
26
|
+
*/
|
|
27
|
+
export interface WebhookEvent {
|
|
28
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
29
|
+
readonly body: unknown;
|
|
30
|
+
}
|
package/src/ctx/index.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface CtxOptions {
|
|
|
12
12
|
secrets: readonly Secret[];
|
|
13
13
|
/** Secret values that take precedence over the environment (tests). */
|
|
14
14
|
secretValues?: Readonly<Record<string, string>>;
|
|
15
|
+
/** Parsed config entries of the step (`ctx.config`). */
|
|
16
|
+
config?: Readonly<Record<string, unknown>>;
|
|
15
17
|
paths: { root: string; workFiles: string; tmp: string };
|
|
16
18
|
signal: AbortSignal;
|
|
17
19
|
/** Where log lines go (default stderr). */
|
|
@@ -102,6 +104,7 @@ export function createCtx(o: CtxOptions): Ctx {
|
|
|
102
104
|
const filesDeps = { run: o.run, workFiles: o.paths.workFiles, tmpDir: o.paths.tmp, root: o.paths.root, signal: o.signal, log };
|
|
103
105
|
return {
|
|
104
106
|
secrets: createSecrets(o.secrets, o.secretValues) as Ctx["secrets"],
|
|
107
|
+
config: Object.freeze({ ...o.config }) as Ctx["config"],
|
|
105
108
|
sh,
|
|
106
109
|
exec,
|
|
107
110
|
readText: async (p) => readFileSync(resolve(p), "utf8"),
|
package/src/ctx/sh.ts
CHANGED
|
@@ -85,7 +85,8 @@ export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[]
|
|
|
85
85
|
const scriptPath = typeof cmd === "object" ? cmd.script : isEmbeddedScript(cmd) ? cmd : undefined;
|
|
86
86
|
if (scriptPath === undefined) {
|
|
87
87
|
const shell = detectShell(deps.shellCandidates);
|
|
88
|
-
|
|
88
|
+
// `sh -c cmd sh a b`: the args are $1, $2… of the command.
|
|
89
|
+
return { argv: [shell, "-c", cmd as string, "sh", ...args], what: `command \`${truncate(cmd as string)}\`` };
|
|
89
90
|
}
|
|
90
91
|
const file = materialize(scriptPath);
|
|
91
92
|
const first = readFileSync(file, "utf8").split("\n", 1)[0] ?? "";
|
package/src/ctx/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Skipped } from "../core/handlers.ts";
|
|
2
2
|
import type { FileRef } from "../core/models.ts";
|
|
3
|
+
import type { AnyConfigEntry, ConfigValues } from "../core/config.ts";
|
|
3
4
|
import type { DeclaredSecrets } from "../core/secrets.ts";
|
|
4
5
|
|
|
5
6
|
/** Facts about the current execution, from the service (see README "Environment"). */
|
|
@@ -79,9 +80,11 @@ export interface OutputFileOptions {
|
|
|
79
80
|
name?: string;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
export interface Ctx<Sec extends string = string> {
|
|
83
|
+
export interface Ctx<Sec extends string = string, Cfg extends AnyConfigEntry = never> {
|
|
83
84
|
/** Exactly the secrets the step declares, read from the environment. */
|
|
84
85
|
readonly secrets: DeclaredSecrets<Sec>;
|
|
86
|
+
/** Exactly the config entries the step declares, from the run's config snapshot (defaults applied). */
|
|
87
|
+
readonly config: ConfigValues<Cfg>;
|
|
85
88
|
readonly sh: Sh;
|
|
86
89
|
/** Run a binary directly, without a shell (works in shell-less images). Rejects on non-zero exit. */
|
|
87
90
|
exec(argv: readonly string[], opts?: ExecOptions): Promise<void>;
|
package/src/describe/describe.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
// `describe`: evaluates the contracts (models + pipeline, never handlers) into the manifest and runs
|
|
2
2
|
// every describe-time check. All problems are collected and reported together.
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { CONFIG_NAME, isConfigEntry, SECRET_REF_KIND } from "../core/config.ts";
|
|
5
|
+
import { CONFIG_CASE_KEY, CONFIG_REF_KEY } from "../core/config-refs.ts";
|
|
6
|
+
import { configRefPath, configRefTarget, isConfigRef, unionAt } from "../core/config.ts";
|
|
4
7
|
import { isModel, isOptional, MODEL_NAME, type AnyModel } from "../core/models.ts";
|
|
5
8
|
import { SECRET_NAME, type Secret } from "../core/secrets.ts";
|
|
6
9
|
import { RESERVED_STEP_NAMES, STEP_NAME, isStep, type AnyStep } from "../core/steps.ts";
|
|
10
|
+
import { isImplementation } from "../core/handlers.ts";
|
|
11
|
+
import { recordConfigRefs } from "../core/config-refs.ts";
|
|
7
12
|
import { pipelineData, type MapperFunction, type StageNode, type StageStepNode } from "../builder/pipeline.ts";
|
|
8
13
|
import { FailureContext } from "../library/models.ts";
|
|
9
14
|
import { canonicalize, canonicalJson } from "../util/canonical.ts";
|
|
@@ -20,10 +25,12 @@ export class DescribeError extends Error {
|
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
export interface DescribeOptions {
|
|
23
|
-
/** Step names that have a handler
|
|
28
|
+
/** Step names that have a handler (a `handlers/*.ts` default-exporting `implement(step, …)`). */
|
|
24
29
|
handlerNames: Iterable<string>;
|
|
25
30
|
/** Step name → handler hash (from the build; includes the harness identity). */
|
|
26
31
|
handlerHashes: Readonly<Record<string, string>>;
|
|
32
|
+
/** Kind → hash of the code that implements the steps of that kind which carry their implementation (from the build). */
|
|
33
|
+
kindHashes?: Readonly<Record<string, string>>;
|
|
27
34
|
harness: HarnessInfo;
|
|
28
35
|
}
|
|
29
36
|
|
|
@@ -127,14 +134,46 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
127
134
|
return s.name;
|
|
128
135
|
};
|
|
129
136
|
|
|
137
|
+
// ── config entries ──
|
|
138
|
+
const configs = new Map<string, { description: string; required: boolean; schema: JsonSchema; key: string; secretRefs: string[] }>();
|
|
139
|
+
const useConfig = (e: unknown, at: string): string | undefined => {
|
|
140
|
+
if (!isConfigEntry(e)) return void problems.push(`${at}: config entries must be config("name", "description", schema)`);
|
|
141
|
+
if (!CONFIG_NAME.test(e.name)) problems.push(`${at}: config name '${e.name}' must match ${CONFIG_NAME}`);
|
|
142
|
+
let schema: JsonSchema;
|
|
143
|
+
try {
|
|
144
|
+
schema = z.toJSONSchema(e.schema, { io: "input", target: "draft-2020-12" }) as JsonSchema;
|
|
145
|
+
delete schema.$schema;
|
|
146
|
+
schema = canonicalize(schema) as JsonSchema;
|
|
147
|
+
} catch (err) {
|
|
148
|
+
problems.push(`${at}: config '${e.name}' is not representable as JSON Schema (${(err as Error).message})`);
|
|
149
|
+
return e.name;
|
|
150
|
+
}
|
|
151
|
+
const key = JSON.stringify([e.description, schema]);
|
|
152
|
+
const prev = configs.get(e.name);
|
|
153
|
+
if (!prev) configs.set(e.name, { description: e.description, required: !e.schema.safeParse(undefined).success, schema, key, secretRefs: secretRefsOf(schema) });
|
|
154
|
+
else if (prev.key !== key) problems.push(`two different config entries are named '${e.name}' (one description and schema per config name)`);
|
|
155
|
+
return e.name;
|
|
156
|
+
};
|
|
157
|
+
|
|
130
158
|
// ── step identity ──
|
|
131
159
|
const steps = new Map<string, AnyStep>();
|
|
132
|
-
const
|
|
160
|
+
const placements = new Map<string, { step: AnyStep; cases: readonly CaseOf[] }[]>();
|
|
161
|
+
// Two places are mutually exclusive when they sit in different cases of the same select.
|
|
162
|
+
const exclusive = (a: readonly CaseOf[], b: readonly CaseOf[]) => a.some((x) => b.some((y) => y.select === x.select && y.value !== x.value));
|
|
163
|
+
const registerStep = (s: AnyStep, where: string, cases: readonly CaseOf[] = []) => {
|
|
133
164
|
if (typeof s.name !== "string" || !STEP_NAME.test(s.name))
|
|
134
165
|
problems.push(`${where}: step name '${String(s.name)}' must be lower-case kebab (${STEP_NAME})`);
|
|
135
166
|
if (RESERVED.has(s.name)) problems.push(`${where}: '${s.name}' is reserved and cannot be a step name`);
|
|
136
|
-
|
|
137
|
-
|
|
167
|
+
const prev = placements.get(s.name) ?? [];
|
|
168
|
+
if (prev.some((p) => !exclusive(p.cases, cases))) problems.push(`duplicate step name '${s.name}'`);
|
|
169
|
+
else if (prev.length) {
|
|
170
|
+
if (prev.some((p) => p.step !== s && !isImplementation(p.step) && !isImplementation(s)))
|
|
171
|
+
problems.push(`step '${s.name}': two different steps with handler files share the name across select cases; handlers pair by name, so use one step or different names`);
|
|
172
|
+
if (prev.some((p) => (p.step.output?.name ?? null) !== (s.output?.name ?? null)))
|
|
173
|
+
problems.push(`step '${s.name}': its select cases must give it the same output model`);
|
|
174
|
+
}
|
|
175
|
+
placements.set(s.name, [...prev, { step: s, cases }]);
|
|
176
|
+
if (!steps.has(s.name)) steps.set(s.name, s);
|
|
138
177
|
};
|
|
139
178
|
|
|
140
179
|
const inputModel = data.input;
|
|
@@ -144,7 +183,7 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
144
183
|
const outputViewOf = (modelName: string | null): JsonSchema | undefined => (modelName ? models.get(modelName)?.schemas.output : undefined);
|
|
145
184
|
|
|
146
185
|
// ── one step ──
|
|
147
|
-
const describeStep = (node: StageStepNode, avail: Map<string, Avail>, failureHandler: boolean): StepDescriptorT => {
|
|
186
|
+
const describeStep = (node: StageStepNode, avail: Map<string, Avail>, failureHandler: boolean, cases: readonly CaseOf[] = []): StepDescriptorT => {
|
|
148
187
|
const s = node.step;
|
|
149
188
|
const at = `step '${s.name}'`;
|
|
150
189
|
if (typeof s.image !== "string" || s.image.trim() === "") problems.push(`${at}: image must be a non-empty string`);
|
|
@@ -194,24 +233,51 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
194
233
|
}
|
|
195
234
|
}
|
|
196
235
|
|
|
236
|
+
for (const key of Object.keys(s.input as Record<string, unknown>))
|
|
237
|
+
if (key.startsWith("$")) problems.push(`${at}: input '${key}': keys starting with '$' are reserved`);
|
|
197
238
|
const secretNames = s.secrets.map((x) => useSecret(x, at)).sort();
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (handlerNames.has(s.name) && !handlerHash) problems.push(`${at}: no handler hash was computed`);
|
|
204
|
-
} else if (s.builtin) {
|
|
239
|
+
const configNames = [...new Set((s.config ?? []).map((c) => useConfig(c, at)).filter((n): n is string => n !== undefined))].sort();
|
|
240
|
+
const carried = isImplementation(s);
|
|
241
|
+
let settings: Record<string, unknown> | undefined;
|
|
242
|
+
let settingsProblem: string | undefined;
|
|
243
|
+
if (s.settings !== undefined || carried) {
|
|
205
244
|
try {
|
|
206
|
-
|
|
245
|
+
settings = canonicalize(recordConfigRefs(s.settings ?? {})) as Record<string, unknown>;
|
|
207
246
|
} catch (e) {
|
|
208
|
-
|
|
209
|
-
|
|
247
|
+
settingsProblem = (e as Error).message;
|
|
248
|
+
settings = {};
|
|
210
249
|
}
|
|
211
|
-
|
|
250
|
+
}
|
|
251
|
+
const refs = settings ? recordedConfigRefs(settings) : [];
|
|
252
|
+
// A step whose settings reference parts of an entry needs the secrets those parts name; any other
|
|
253
|
+
// step receiving an entry needs every secret the entry names.
|
|
254
|
+
for (const name of configNames) {
|
|
255
|
+
const into = refs.filter((r) => r.ref.split(".")[0] === name);
|
|
256
|
+
const needed = into.length === 0
|
|
257
|
+
? configs.get(name)?.secretRefs ?? []
|
|
258
|
+
: [...new Set(into.flatMap((r) => secretRefsOf(subSchemas(configs.get(name)?.schema, r.ref.split(".").slice(1), r.case, name))))].sort();
|
|
259
|
+
for (const ref of needed)
|
|
260
|
+
if (!secretNames.includes(ref)) problems.push(`${at}: config '${name}' references secret '${ref}' (secretRef), so the step must declare it in secrets`);
|
|
261
|
+
}
|
|
262
|
+
let handlerHash: string;
|
|
263
|
+
if (settingsProblem !== undefined) problems.push(`${at}: invalid ${s.kind} settings (${settingsProblem})`);
|
|
264
|
+
if (typeof s.kind !== "string" || s.kind === "") problems.push(`${at}: kind must be a non-empty string`);
|
|
265
|
+
if (carried && s.kind === "custom") problems.push(`${at} carries its implementation, so it needs a kind (step({ kind: "…", … })); a step with kind custom has its handler in handlers/`);
|
|
266
|
+
for (const { ref, case: kase } of refs) {
|
|
267
|
+
const [entry, ...path] = ref.split(".") as [string, ...string[]];
|
|
268
|
+
if (kase !== undefined && !cases.some((c) => c.cond === kase))
|
|
269
|
+
problems.push(`${at}: config reference '${ref}' is narrowed to the select case '${kase}' and can only be used inside it`);
|
|
270
|
+
if (!configNames.includes(entry)) problems.push(`${at}: config reference '${ref}': the step does not receive config entry '${entry}'`);
|
|
271
|
+
else if (!resolveField(configs.get(entry)?.schema, path)) problems.push(`${at}: config reference '${ref}': config '${entry}' has no field '${path.join(".")}'`);
|
|
272
|
+
}
|
|
273
|
+
if (carried) {
|
|
274
|
+
const code = opts.kindHashes?.[s.kind];
|
|
275
|
+
if (opts.kindHashes && !code) problems.push(`${at}: no hash was computed for the code of kind '${s.kind}'`);
|
|
276
|
+
handlerHash = libraryHandlerHash(s.kind, settings, opts.harness, code);
|
|
212
277
|
} else {
|
|
213
|
-
problems.push(`${at}
|
|
214
|
-
handlerHash = "";
|
|
278
|
+
if (!handlerNames.has(s.name)) problems.push(`${at} has no handler (add a handlers/*.ts default-exporting implement(${camel(s.name)}, …))`);
|
|
279
|
+
handlerHash = opts.handlerHashes[s.name] ?? "";
|
|
280
|
+
if (handlerNames.has(s.name) && !handlerHash) problems.push(`${at}: no handler hash was computed`);
|
|
215
281
|
}
|
|
216
282
|
if (typeof s.description !== "string") problems.push(`${at}: description must be a string (it may be empty)`);
|
|
217
283
|
if (s.timeoutSeconds !== undefined && !(Number.isInteger(s.timeoutSeconds) && s.timeoutSeconds > 0)) problems.push(`${at}: timeoutSeconds must be a positive integer`);
|
|
@@ -229,8 +295,11 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
229
295
|
handlerHash: handlerHash || "sha256:" + "0".repeat(64),
|
|
230
296
|
...(s.timeoutSeconds !== undefined ? { timeoutSeconds: s.timeoutSeconds } : {}),
|
|
231
297
|
...(s.retries !== undefined ? { retries: s.retries } : {}),
|
|
232
|
-
...(
|
|
298
|
+
...(settings !== undefined ? { config: settings as Record<string, z.core.util.JSONType> } : {}),
|
|
299
|
+
...(configNames.length ? { configEntries: configNames } : {}),
|
|
300
|
+
...(s.clusterAccess === true ? { clusterAccess: true as const } : {}),
|
|
233
301
|
};
|
|
302
|
+
if (s.clusterAccess !== undefined && typeof s.clusterAccess !== "boolean") problems.push(`${at}: clusterAccess must be a boolean`);
|
|
234
303
|
if (s.output && !isModel(s.output)) problems.push(`${at}: output must be a model`);
|
|
235
304
|
return out;
|
|
236
305
|
};
|
|
@@ -275,17 +344,22 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
275
344
|
};
|
|
276
345
|
|
|
277
346
|
// ── stages ──
|
|
278
|
-
|
|
347
|
+
let selects = 0;
|
|
348
|
+
const walk = (nodes: readonly StageNode[], avail: Map<string, Avail>, cases: readonly CaseOf[] = []): Stage[] => {
|
|
279
349
|
const out: Stage[] = [];
|
|
280
350
|
for (const n of nodes) {
|
|
351
|
+
if (n.kind === "select") {
|
|
352
|
+
out.push(describeSelect(n, avail, cases, ++selects));
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
281
355
|
if (n.kind === "stage") {
|
|
282
356
|
const descs = n.steps.map((ss) => {
|
|
283
357
|
if (!isStep(ss.step)) {
|
|
284
358
|
problems.push("a stage entry is not a step");
|
|
285
359
|
return undefined;
|
|
286
360
|
}
|
|
287
|
-
registerStep(ss.step, "stage");
|
|
288
|
-
return describeStep(ss, avail, false);
|
|
361
|
+
registerStep(ss.step, "stage", cases);
|
|
362
|
+
return describeStep(ss, avail, false, cases);
|
|
289
363
|
}).filter((d): d is StepDescriptorT => d !== undefined);
|
|
290
364
|
// Outputs become available only after the barrier (same-stage siblings can't see each other).
|
|
291
365
|
for (const ss of n.steps) if (isStep(ss.step)) avail.set(ss.step.name, { model: ss.step.output?.name ?? null, maybe: false });
|
|
@@ -305,13 +379,43 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
305
379
|
when[field] = values;
|
|
306
380
|
}
|
|
307
381
|
const inner = new Map(avail);
|
|
308
|
-
const stages = walk(n.nodes, inner);
|
|
382
|
+
const stages = walk(n.nodes, inner, cases);
|
|
309
383
|
for (const [name, a] of inner) if (!avail.has(name)) avail.set(name, { model: a.model, maybe: true });
|
|
310
384
|
out.push({ when, stages });
|
|
311
385
|
}
|
|
312
386
|
}
|
|
313
387
|
return out;
|
|
314
388
|
};
|
|
389
|
+
const describeSelect = (n: Extract<StageNode, { kind: "select" }>, avail: Map<string, Avail>, cases: readonly CaseOf[], id: number): Stage => {
|
|
390
|
+
if (!isConfigRef(n.on)) {
|
|
391
|
+
problems.push("select: the first argument must be a config reference");
|
|
392
|
+
return { select: "", discriminator: "", cases: {} };
|
|
393
|
+
}
|
|
394
|
+
const ref = configRefPath(n.on);
|
|
395
|
+
const { entry, case: kase } = configRefTarget(n.on);
|
|
396
|
+
useConfig(entry, `select '${ref}'`);
|
|
397
|
+
if (kase !== undefined && !cases.some((c) => c.cond === kase))
|
|
398
|
+
problems.push(`select '${ref}': the reference is narrowed to the case '${kase}' and can only be used inside it`);
|
|
399
|
+
const union = unionAt(n.on);
|
|
400
|
+
if (!union) problems.push(`select '${ref}': it must reference a z.discriminatedUnion in config '${entry.name}'`);
|
|
401
|
+
else {
|
|
402
|
+
for (const v of union.values) if (!Object.hasOwn(n.cases, v)) problems.push(`select '${ref}': no case for ${union.discriminator} '${v}'`);
|
|
403
|
+
for (const v of Object.keys(n.cases)) if (!union.values.includes(v)) problems.push(`select '${ref}': '${v}' is not a value of ${union.discriminator} (${union.values.join(", ")})`);
|
|
404
|
+
}
|
|
405
|
+
const discriminator = union?.discriminator ?? "";
|
|
406
|
+
const branches = Object.entries(n.cases).map(([value, nodes]) => {
|
|
407
|
+
const inner = new Map(avail);
|
|
408
|
+
const stages = walk(nodes, inner, [...cases, { select: id, value, cond: `${ref}.${discriminator}=${value}` }]);
|
|
409
|
+
return { value, inner, stages };
|
|
410
|
+
});
|
|
411
|
+
// A step every case has is as available as in its cases; one only some cases have is maybe-available.
|
|
412
|
+
const added = new Set(branches.flatMap((b) => [...b.inner.keys()].filter((k) => !avail.has(k))));
|
|
413
|
+
for (const name of added) {
|
|
414
|
+
const present = branches.map((b) => b.inner.get(name)).filter((a): a is Avail => a !== undefined);
|
|
415
|
+
avail.set(name, { model: present[0]!.model, maybe: present.length < branches.length || present.some((a) => a.maybe) });
|
|
416
|
+
}
|
|
417
|
+
return { select: ref, discriminator, cases: Object.fromEntries(branches.map((b) => [b.value, b.stages])) };
|
|
418
|
+
};
|
|
315
419
|
const stages = walk(data.nodes, new Map([["input", { model: inputModel.name, maybe: false }]]));
|
|
316
420
|
|
|
317
421
|
// ── failure handlers ──
|
|
@@ -329,19 +433,26 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
329
433
|
return { step: describeStep({ step: fh.step }, new Map(), true), steps: scope };
|
|
330
434
|
}).filter((x) => x !== undefined);
|
|
331
435
|
|
|
332
|
-
// ──
|
|
436
|
+
// ── handlers without a step ──
|
|
333
437
|
for (const h of handlerNames) {
|
|
334
438
|
const s = steps.get(h);
|
|
335
|
-
if (!s) problems.push(`
|
|
336
|
-
else if (s
|
|
439
|
+
if (!s) problems.push(`a handler implements step '${h}', which is not in the pipeline`);
|
|
440
|
+
else if (isImplementation(s)) problems.push(`a handler implements '${h}', a ${s.kind} step that carries its own implementation`);
|
|
337
441
|
}
|
|
338
442
|
|
|
339
443
|
// ── triggers ──
|
|
340
444
|
const triggerNames = new Set<string>();
|
|
341
445
|
const triggers: TriggerT[] = data.triggers.map((t) => {
|
|
342
|
-
if (triggerNames.has(t.name)) problems.push(`duplicate trigger '${t.name}'${t.kind === "push" ? " (onPush may be used once)" : " (give each onPoll a distinct name)"}`);
|
|
446
|
+
if (triggerNames.has(t.name)) problems.push(`duplicate trigger '${t.name}'${t.kind === "push" ? " (onPush may be used once)" : " (give each onPoll and onWebhook a distinct name)"}`);
|
|
343
447
|
triggerNames.add(t.name);
|
|
344
448
|
if (t.kind === "push") return { kind: "push", name: "push" };
|
|
449
|
+
if (t.kind === "webhook") {
|
|
450
|
+
if (!STEP_NAME.test(t.name)) problems.push(`webhook trigger name '${t.name}' must be lower-case kebab`);
|
|
451
|
+
const secret = useSecret(t.verify.secret, `webhook '${t.name}'`);
|
|
452
|
+
if (t.verify.scheme === "bearer") return { kind: "webhook", name: t.name, verify: { scheme: "bearer", secret } };
|
|
453
|
+
if (!t.verify.header) problems.push(`webhook '${t.name}': the signature header must not be empty`);
|
|
454
|
+
return { kind: "webhook", name: t.name, verify: { scheme: "hmac-sha256", secret, header: t.verify.header, prefix: t.verify.prefix } };
|
|
455
|
+
}
|
|
345
456
|
if (!STEP_NAME.test(t.name)) problems.push(`poll trigger name '${t.name}' must be lower-case kebab`);
|
|
346
457
|
if (!(Number.isInteger(t.intervalSeconds) && t.intervalSeconds > 0)) problems.push(`poll '${t.name}': intervalSeconds must be a positive integer`);
|
|
347
458
|
const declared = new Set(t.secrets.map((s) => useSecret(s, `poll '${t.name}'`)));
|
|
@@ -362,6 +473,13 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
362
473
|
harness: opts.harness,
|
|
363
474
|
models: Object.fromEntries([...models].map(([k, v]) => [k, v.schemas])),
|
|
364
475
|
secrets: [...secrets].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([name, description]) => ({ name, description })),
|
|
476
|
+
...(configs.size
|
|
477
|
+
? {
|
|
478
|
+
config: [...configs]
|
|
479
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
480
|
+
.map(([name, c]) => ({ name, description: c.description, required: c.required, schema: c.schema })),
|
|
481
|
+
}
|
|
482
|
+
: {}),
|
|
365
483
|
triggers,
|
|
366
484
|
stages,
|
|
367
485
|
failureHandlers,
|
|
@@ -374,6 +492,74 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
374
492
|
/** Canonical manifest text (what `describe` prints and the service hashes). */
|
|
375
493
|
export const manifestText = (m: ManifestT): string => canonicalJson(m);
|
|
376
494
|
|
|
495
|
+
/**
|
|
496
|
+
* The schemas at `path` inside an entry's schema: every union branch that has the field, except where
|
|
497
|
+
* `kase` (`<entry>.<path>.<discriminator>=<value>`) narrows a union to one branch.
|
|
498
|
+
*/
|
|
499
|
+
export function subSchemas(schema: unknown, path: readonly string[], kase: string | undefined, entry: string): unknown[] {
|
|
500
|
+
let narrow: { at: string; discriminator: string; value: string } | undefined;
|
|
501
|
+
if (kase) {
|
|
502
|
+
const eq = kase.lastIndexOf("=");
|
|
503
|
+
const left = kase.slice(0, eq);
|
|
504
|
+
const dot = left.lastIndexOf(".");
|
|
505
|
+
narrow = { at: left.slice(0, dot), discriminator: left.slice(dot + 1), value: kase.slice(eq + 1) };
|
|
506
|
+
}
|
|
507
|
+
const walk = (node: unknown, rest: readonly string[], here: string): unknown[] => {
|
|
508
|
+
if (node === null || typeof node !== "object") return [];
|
|
509
|
+
const o = node as JsonSchema;
|
|
510
|
+
const branches = (o.oneOf ?? o.anyOf) as JsonSchema[] | undefined;
|
|
511
|
+
if (Array.isArray(branches)) {
|
|
512
|
+
const chosen = narrow && narrow.at === here
|
|
513
|
+
? branches.filter((b) => ((b.properties as Record<string, JsonSchema> | undefined)?.[narrow!.discriminator] as JsonSchema | undefined)?.const === narrow!.value)
|
|
514
|
+
: branches;
|
|
515
|
+
return chosen.flatMap((b) => walk(b, rest, here));
|
|
516
|
+
}
|
|
517
|
+
if (rest.length === 0) return [node];
|
|
518
|
+
const props = o.properties as Record<string, JsonSchema> | undefined;
|
|
519
|
+
if (props && Object.hasOwn(props, rest[0]!)) return walk(props[rest[0]!], rest.slice(1), `${here}.${rest[0]}`);
|
|
520
|
+
return [];
|
|
521
|
+
};
|
|
522
|
+
return walk(schema, path, entry);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Secret names of every secretRef inside a config entry's JSON Schema. */
|
|
526
|
+
export function secretRefsOf(schema: unknown): string[] {
|
|
527
|
+
const out = new Set<string>();
|
|
528
|
+
const walk = (v: unknown) => {
|
|
529
|
+
if (Array.isArray(v)) return v.forEach(walk);
|
|
530
|
+
if (v === null || typeof v !== "object") return;
|
|
531
|
+
const o = v as JsonSchema;
|
|
532
|
+
if (o["x-olve-kind"] === SECRET_REF_KIND) {
|
|
533
|
+
const name = (o.properties as { secret?: { const?: unknown } } | undefined)?.secret?.const;
|
|
534
|
+
if (typeof name === "string") out.add(name);
|
|
535
|
+
}
|
|
536
|
+
Object.values(o).forEach(walk);
|
|
537
|
+
};
|
|
538
|
+
walk(schema);
|
|
539
|
+
return [...out].sort();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** The `{"$config": "<entry>.<path>"[, "$case": …]}` references in a step's recorded settings. */
|
|
543
|
+
export function recordedConfigRefs(v: unknown, out: { ref: string; case?: string }[] = []): { ref: string; case?: string }[] {
|
|
544
|
+
if (Array.isArray(v)) v.forEach((x) => recordedConfigRefs(x, out));
|
|
545
|
+
else if (v !== null && typeof v === "object") {
|
|
546
|
+
const o = v as Record<string, unknown>;
|
|
547
|
+
const keys = Object.keys(o).sort();
|
|
548
|
+
const isRef = typeof o[CONFIG_REF_KEY] === "string"
|
|
549
|
+
&& (keys.length === 1 ? keys[0] === CONFIG_REF_KEY : keys.length === 2 && keys[0] === CONFIG_CASE_KEY && keys[1] === CONFIG_REF_KEY && typeof o[CONFIG_CASE_KEY] === "string");
|
|
550
|
+
if (isRef) out.push({ ref: o[CONFIG_REF_KEY] as string, ...(keys.length === 2 ? { case: o[CONFIG_CASE_KEY] as string } : {}) });
|
|
551
|
+
else Object.values(o).forEach((x) => recordedConfigRefs(x, out));
|
|
552
|
+
}
|
|
553
|
+
return out;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** A select case a step sits in: which select (numbered in describe order), which value, and its condition text. */
|
|
557
|
+
interface CaseOf {
|
|
558
|
+
select: number;
|
|
559
|
+
value: string;
|
|
560
|
+
cond: string;
|
|
561
|
+
}
|
|
562
|
+
|
|
377
563
|
function camel(name: string): string {
|
|
378
564
|
return name.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
|
|
379
565
|
}
|
|
@@ -7,6 +7,7 @@ export const API_VERSION = "2.0";
|
|
|
7
7
|
const stepName = z.string().regex(/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/).describe("Step name: lower-case kebab, 1-63 chars. The step's identity.");
|
|
8
8
|
const modelName = z.string().regex(/^[A-Za-z][A-Za-z0-9_]*$/).describe("Model name (key of `models`).");
|
|
9
9
|
const secretName = z.string().regex(/^[A-Z_][A-Z0-9_]*$/).describe("Secret name; also the env var name in the step container.");
|
|
10
|
+
const configName = z.string().regex(/^[a-z][a-zA-Z0-9]*$/).describe("Config entry name; a key of the pipeline's config document.");
|
|
10
11
|
const hash = z.string().regex(/^sha256:[0-9a-f]{64}$/);
|
|
11
12
|
const Json = z.json().meta({ id: "Json", description: "Any JSON value." });
|
|
12
13
|
const jsonSchema = z.record(z.string(), z.unknown()).describe("A JSON Schema (draft 2020-12) object, canonical (sorted keys, no $schema).");
|
|
@@ -50,20 +51,34 @@ export const StepDescriptor = z
|
|
|
50
51
|
.strictObject({
|
|
51
52
|
name: stepName,
|
|
52
53
|
description: z.string().describe("What the step does, shown in the UI (may be empty)."),
|
|
53
|
-
kind: z.
|
|
54
|
+
kind: z.string().min(1).describe("'custom' = the handler is a handlers/*.ts file; any other kind is a step that carries its implementation (an SDK or package step). A label: the service accepts any kind."),
|
|
54
55
|
image: z.string().min(1).describe("Container image the step runs in."),
|
|
55
56
|
inputTypes: z.record(z.string(), InputType).describe("Declared input entries, keyed like input.json."),
|
|
56
57
|
input: z.record(z.string(), Mapping).describe("How the service assembles input.json: one mapping per input key (same keys as inputTypes)."),
|
|
57
58
|
output: modelName.nullable().describe("Output model, or null if the step produces no output."),
|
|
58
59
|
secrets: z.array(secretName).describe("Declared secrets (sorted). Mount exactly these, as env vars of the same name."),
|
|
59
|
-
handlerHash: hash.describe("Implementation identity: handler bundle (contract files excluded) + harness (bun + sdkHash); for
|
|
60
|
+
handlerHash: hash.describe("Implementation identity: handler bundle (contract files excluded) + harness (bun + sdkHash); for a step that carries its implementation kind + config + harness + the hash of the code of its kind."),
|
|
60
61
|
timeoutSeconds: z.number().int().positive().optional(),
|
|
61
62
|
retries: z.number().int().nonnegative().optional(),
|
|
62
|
-
config: z
|
|
63
|
+
config: z
|
|
64
|
+
.record(z.string(), Json)
|
|
65
|
+
.optional()
|
|
66
|
+
.describe("The step's settings (display only; already covered by handlerHash). A config reference is recorded as {\"$config\": \"<entry>.<field>…\"}; the entry is in configEntries."),
|
|
67
|
+
configEntries: z
|
|
68
|
+
.array(configName)
|
|
69
|
+
.optional()
|
|
70
|
+
.describe("Config entries the step receives (sorted; omitted when none). The service delivers them in input.json under '$config'."),
|
|
71
|
+
clusterAccess: z
|
|
72
|
+
.literal(true)
|
|
73
|
+
.optional()
|
|
74
|
+
.describe("The step pod gets the instance's step ServiceAccount token (Kubernetes:StepServiceAccount), if the instance grants one. Omitted = no token."),
|
|
63
75
|
})
|
|
64
76
|
.meta({ id: "Step" });
|
|
65
77
|
|
|
66
|
-
export type Stage =
|
|
78
|
+
export type Stage =
|
|
79
|
+
| { steps: z.infer<typeof StepDescriptor>[] }
|
|
80
|
+
| { when: Record<string, (string | number | boolean)[]>; stages: Stage[] }
|
|
81
|
+
| { select: string; discriminator: string; cases: Record<string, Stage[]> };
|
|
67
82
|
export const Stage: z.ZodType<Stage> = z
|
|
68
83
|
.union([
|
|
69
84
|
z.strictObject({ steps: z.array(StepDescriptor).min(1).describe("Steps run in parallel; the stage is a barrier.") }),
|
|
@@ -73,6 +88,13 @@ export const Stage: z.ZodType<Stage> = z
|
|
|
73
88
|
),
|
|
74
89
|
stages: z.array(z.lazy(() => Stage)),
|
|
75
90
|
}),
|
|
91
|
+
z.strictObject({
|
|
92
|
+
select: z.string().min(1).describe("Config reference (`<entry>.<field>…`) to a discriminated union; the run's config snapshot picks the case."),
|
|
93
|
+
discriminator: z.string().min(1).describe("The union's literal field whose value names the case."),
|
|
94
|
+
cases: z
|
|
95
|
+
.record(z.string(), z.array(z.lazy(() => Stage)))
|
|
96
|
+
.describe("Discriminator value → stages. Exactly one case runs; a step name may repeat across cases, nowhere else."),
|
|
97
|
+
}),
|
|
76
98
|
])
|
|
77
99
|
.meta({ id: "Stage" });
|
|
78
100
|
|
|
@@ -90,6 +112,21 @@ export const Trigger = z
|
|
|
90
112
|
intervalSeconds: z.number().int().positive(),
|
|
91
113
|
})
|
|
92
114
|
.describe("Background poller."),
|
|
115
|
+
z
|
|
116
|
+
.strictObject({
|
|
117
|
+
kind: z.literal("webhook"),
|
|
118
|
+
name: z.string().regex(/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/),
|
|
119
|
+
verify: z.discriminatedUnion("scheme", [
|
|
120
|
+
z.strictObject({
|
|
121
|
+
scheme: z.literal("hmac-sha256"),
|
|
122
|
+
secret: secretName,
|
|
123
|
+
header: z.string().min(1),
|
|
124
|
+
prefix: z.string().describe("Text before the hex digest in the header, e.g. 'sha256='."),
|
|
125
|
+
}),
|
|
126
|
+
z.strictObject({ scheme: z.literal("bearer"), secret: secretName }),
|
|
127
|
+
]),
|
|
128
|
+
})
|
|
129
|
+
.describe("Inbound HTTP delivery to /api/hooks/<pipeline id>/<name>. Event = {headers, body}; a null mapping ignores it."),
|
|
93
130
|
])
|
|
94
131
|
.meta({ id: "Trigger" });
|
|
95
132
|
|
|
@@ -102,12 +139,23 @@ export const Manifest = z
|
|
|
102
139
|
harness: z
|
|
103
140
|
.strictObject({
|
|
104
141
|
bun: z.string().describe("Bun runtime version compiled into the binary."),
|
|
105
|
-
sdk: z.string().describe("@
|
|
142
|
+
sdk: z.string().describe("@olivervea/pipelines package version."),
|
|
106
143
|
sdkHash: hash.describe("Hash of the SDK runtime sources + zod version."),
|
|
107
144
|
})
|
|
108
145
|
.describe("Harness identity. Already folded into every handlerHash."),
|
|
109
146
|
models: z.record(modelName, ModelSchemas).describe("Every model referenced by the pipeline (input, step inputs/outputs)."),
|
|
110
147
|
secrets: z.array(z.strictObject({ name: secretName, description: z.string() })).describe("All declared secrets (steps + poll triggers), sorted by name."),
|
|
148
|
+
config: z
|
|
149
|
+
.array(
|
|
150
|
+
z.strictObject({
|
|
151
|
+
name: configName,
|
|
152
|
+
description: z.string(),
|
|
153
|
+
required: z.boolean().describe("False when the schema accepts a missing value (optional, or a default)."),
|
|
154
|
+
schema: jsonSchema.describe("z.input view: validates the config document's value for this entry (defaults listed)."),
|
|
155
|
+
}),
|
|
156
|
+
)
|
|
157
|
+
.optional()
|
|
158
|
+
.describe("Config entries used by steps, sorted by name (omitted when none). Fields of kind secretRef ({secret: NAME}) name a secret."),
|
|
111
159
|
triggers: z.array(Trigger),
|
|
112
160
|
stages: z.array(Stage),
|
|
113
161
|
failureHandlers: z.array(
|
package/src/dev/cli.ts
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
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
|
-
// olve-dev run <dir> <step> [--input-file f] [--olve-dir d] [--secret NAME=value]...
|
|
4
|
+
// olve-dev run <dir> <step> [--input-file f] [--config-file f] [--olve-dir d] [--secret NAME=value]...
|
|
5
5
|
// olve-dev map-trigger <dir> <trigger> --event-file f [--olve-dir d]
|
|
6
6
|
// $OLVE_DIR defaults to <dir>/.olve; outputs land in <dir>/.olve/work/.
|
|
7
|
-
import { copyFileSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { join, resolve } from "node:path";
|
|
9
9
|
import { parseArgs } from "node:util";
|
|
10
10
|
import { runHarness, type HarnessConfig } from "../harness/main.ts";
|
|
11
11
|
import { EXIT } from "../harness/exit.ts";
|
|
12
|
-
import { handlerHashes } from "../build/hashes.ts";
|
|
13
|
-
import { harnessInfo, loadProject, ProjectError } from "../build/project.ts";
|
|
12
|
+
import { handlerHashes, kindHashes } from "../build/hashes.ts";
|
|
13
|
+
import { harnessInfo, loadPipeline, loadProject, ProjectError } from "../build/project.ts";
|
|
14
14
|
|
|
15
15
|
const USAGE = `usage: olve-dev describe <dir> [--sentinels]
|
|
16
|
-
olve-dev run <dir> <step> [--input-file f] [--olve-dir d] [--secret NAME=value]...
|
|
16
|
+
olve-dev run <dir> <step> [--input-file f] [--config-file f] [--olve-dir d] [--secret NAME=value]...
|
|
17
17
|
olve-dev map-trigger <dir> <trigger> --event-file f [--olve-dir d]`;
|
|
18
18
|
|
|
19
19
|
export async function devConfig(dir: string, withHashes: boolean): Promise<HarnessConfig> {
|
|
20
|
-
const project = loadProject(dir);
|
|
21
|
-
const
|
|
22
|
-
if (!mod.default) throw new ProjectError(`${project.pipelineFile} has no default export (export default pipeline({...})...)`);
|
|
20
|
+
const project = await loadProject(dir);
|
|
21
|
+
const pipeline = await loadPipeline(project);
|
|
23
22
|
const harness = harnessInfo();
|
|
24
23
|
const hashes = withHashes ? await handlerHashes(project, harness) : {};
|
|
25
24
|
return {
|
|
26
|
-
pipeline
|
|
25
|
+
pipeline,
|
|
27
26
|
handlers: Object.fromEntries(project.handlers.map(([name, file]) => [name, () => import(file) as Promise<{ default: unknown }>])),
|
|
28
27
|
handlerHashes: Object.fromEntries(Object.entries(hashes).map(([k, v]) => [k, v.handlerHash])),
|
|
28
|
+
...(withHashes ? { kindHashes: await kindHashes(project, pipeline) } : {}),
|
|
29
29
|
harness,
|
|
30
30
|
};
|
|
31
31
|
}
|
|
@@ -38,6 +38,7 @@ export async function devMain(argv: string[]): Promise<number> {
|
|
|
38
38
|
allowPositionals: true,
|
|
39
39
|
options: {
|
|
40
40
|
"input-file": { type: "string" },
|
|
41
|
+
"config-file": { type: "string" },
|
|
41
42
|
"event-file": { type: "string" },
|
|
42
43
|
"olve-dir": { type: "string" },
|
|
43
44
|
secret: { type: "string", multiple: true },
|
|
@@ -68,6 +69,12 @@ export async function devMain(argv: string[]): Promise<number> {
|
|
|
68
69
|
}
|
|
69
70
|
const file = mode === "run" ? values["input-file"] : values["event-file"];
|
|
70
71
|
if (file) copyFileSync(resolve(file), join(olveDir, "input.json"));
|
|
72
|
+
if (mode === "run" && values["config-file"]) {
|
|
73
|
+
const inputFile = join(olveDir, "input.json");
|
|
74
|
+
const input = (existsSync(inputFile) ? JSON.parse(readFileSync(inputFile, "utf8")) : {}) as Record<string, unknown>;
|
|
75
|
+
input.$config = JSON.parse(readFileSync(resolve(values["config-file"]), "utf8"));
|
|
76
|
+
writeFileSync(inputFile, JSON.stringify(input));
|
|
77
|
+
}
|
|
71
78
|
else if (mode === "map-trigger") return console.error("--event-file is required"), EXIT.usage;
|
|
72
79
|
return await runHarness(await devConfig(dir, false), [mode, name]);
|
|
73
80
|
} catch (e) {
|
package/src/harness/execute.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { Ctx, RunInfo } from "../ctx/types.ts";
|
|
|
8
8
|
import { objectPath } from "../ctx/files.ts";
|
|
9
9
|
import { sha256File } from "../util/hash.ts";
|
|
10
10
|
import { EXIT } from "./exit.ts";
|
|
11
|
-
import { validateInput, validateOutput } from "./validate.ts";
|
|
11
|
+
import { validateConfig, validateInput, validateOutput } from "./validate.ts";
|
|
12
12
|
|
|
13
13
|
export type FailureKind = "handler" | "input" | "output";
|
|
14
14
|
export type Envelope =
|
|
@@ -58,13 +58,16 @@ export async function executeStep(args: {
|
|
|
58
58
|
workFiles: string;
|
|
59
59
|
log: (line: string) => void;
|
|
60
60
|
}): Promise<Execution> {
|
|
61
|
-
const { step,
|
|
61
|
+
const { step, log } = args;
|
|
62
62
|
const input = validateInput(step, args.rawInput);
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
const config = validateConfig(step, args.rawInput);
|
|
64
|
+
if (!input.ok || !config.ok) {
|
|
65
|
+
const issues = [...(input.ok ? [] : input.issues), ...(config.ok ? [] : config.issues)];
|
|
66
|
+
const message = `step '${step.name}': input does not match the step's inputs:\n ${issues.join("\n ")}`;
|
|
65
67
|
log(message);
|
|
66
68
|
return { envelope: { status: "failed", error: { kind: "input", message } }, exitCode: EXIT.inputInvalid };
|
|
67
69
|
}
|
|
70
|
+
const ctx: Ctx = { ...args.ctx, config: Object.freeze(config.value) as Ctx["config"] };
|
|
68
71
|
|
|
69
72
|
let value: unknown;
|
|
70
73
|
try {
|
package/src/harness/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @
|
|
1
|
+
// @olivervea/pipelines/harness — the runtime the build tool compiles into the step binary.
|
|
2
2
|
export { runHarness, defaultIO, allSteps, resolveStepFunction, type HarnessConfig, type HandlerRegistry, type HarnessIO } from "./main.ts";
|
|
3
3
|
export { EXIT } from "./exit.ts";
|
|
4
4
|
export { ENV, paths, runInfoFromEnv } from "./env.ts";
|