@olivervea/pipelines 0.0.0-stage → 2.0.0-beta.2
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/LICENSE +21 -0
- package/README.md +409 -2
- package/manifest.schema.json +596 -0
- package/package.json +60 -4
- package/src/assets.d.ts +10 -0
- package/src/build/build.ts +125 -0
- package/src/build/cli.ts +52 -0
- package/src/build/elf.ts +35 -0
- package/src/build/hashes.ts +67 -0
- package/src/build/project.ts +79 -0
- package/src/builder/pipeline.ts +305 -0
- package/src/builder/refs.ts +26 -0
- package/src/core/brands.ts +13 -0
- package/src/core/handlers.ts +44 -0
- package/src/core/models.ts +69 -0
- package/src/core/secrets.ts +13 -0
- package/src/core/steps.ts +107 -0
- package/src/core/types.ts +6 -0
- package/src/core/webhooks.ts +30 -0
- package/src/ctx/files.ts +161 -0
- package/src/ctx/index.ts +119 -0
- package/src/ctx/sh.ts +196 -0
- package/src/ctx/types.ts +113 -0
- package/src/describe/describe.ts +386 -0
- package/src/describe/manifest-schema.ts +146 -0
- package/src/describe/mapper-source.ts +238 -0
- package/src/describe/record.ts +107 -0
- package/src/dev/cli.ts +80 -0
- package/src/harness/env.ts +58 -0
- package/src/harness/execute.ts +94 -0
- package/src/harness/exit.ts +19 -0
- package/src/harness/index.ts +9 -0
- package/src/harness/main.ts +232 -0
- package/src/harness/result.ts +75 -0
- package/src/harness/validate.ts +74 -0
- package/src/index.ts +12 -0
- package/src/library/github-source.ts +77 -0
- package/src/library/helm-deploy-ssh.ts +151 -0
- package/src/library/http-health.ts +34 -0
- package/src/library/image-build.ts +194 -0
- package/src/library/index.ts +14 -0
- package/src/library/models.ts +63 -0
- package/src/library/script.ts +85 -0
- package/src/testing/index.ts +86 -0
- package/src/util/canonical.ts +19 -0
- package/src/util/handler-hash.ts +22 -0
- package/src/util/hash.ts +22 -0
- package/src/util/tar.ts +154 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// `describe`: evaluates the contracts (models + pipeline, never handlers) into the manifest and runs
|
|
2
|
+
// every describe-time check. All problems are collected and reported together.
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { isModel, isOptional, MODEL_NAME, type AnyModel } from "../core/models.ts";
|
|
5
|
+
import { SECRET_NAME, type Secret } from "../core/secrets.ts";
|
|
6
|
+
import { RESERVED_STEP_NAMES, STEP_NAME, isStep, type AnyStep } from "../core/steps.ts";
|
|
7
|
+
import { pipelineData, type MapperFunction, type StageNode, type StageStepNode } from "../builder/pipeline.ts";
|
|
8
|
+
import { FailureContext } from "../library/models.ts";
|
|
9
|
+
import { canonicalize, canonicalJson } from "../util/canonical.ts";
|
|
10
|
+
import { libraryHandlerHash, type HarnessInfo } from "../util/handler-hash.ts";
|
|
11
|
+
import { analyzeMapperSource } from "./mapper-source.ts";
|
|
12
|
+
import { MapperError, recordMapper, type Mapping } from "./record.ts";
|
|
13
|
+
import { API_VERSION, Manifest, type ManifestT, type ModelSchemasT, type Stage, type StepDescriptorT, type TriggerT } from "./manifest-schema.ts";
|
|
14
|
+
|
|
15
|
+
export class DescribeError extends Error {
|
|
16
|
+
override name = "DescribeError";
|
|
17
|
+
constructor(readonly problems: readonly string[]) {
|
|
18
|
+
super(`describe found ${problems.length} problem(s):\n${problems.map((p) => ` - ${p}`).join("\n")}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DescribeOptions {
|
|
23
|
+
/** Step names that have a handler file (`handlers/<name>.ts`). */
|
|
24
|
+
handlerNames: Iterable<string>;
|
|
25
|
+
/** Step name → handler hash (from the build; includes the harness identity). */
|
|
26
|
+
handlerHashes: Readonly<Record<string, string>>;
|
|
27
|
+
harness: HarnessInfo;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Root name used in failure-handler mappings (the service supplies FailureContext under it). */
|
|
31
|
+
export const FAILURE_ROOT = "failure";
|
|
32
|
+
const RESERVED = new Set([...RESERVED_STEP_NAMES, FAILURE_ROOT]);
|
|
33
|
+
|
|
34
|
+
type JsonSchema = Record<string, unknown>;
|
|
35
|
+
interface Avail {
|
|
36
|
+
model: string | null;
|
|
37
|
+
maybe: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function stripStrict(v: unknown): unknown {
|
|
41
|
+
if (Array.isArray(v)) return v.map(stripStrict);
|
|
42
|
+
if (v !== null && typeof v === "object") {
|
|
43
|
+
const out: Record<string, unknown> = {};
|
|
44
|
+
for (const [k, x] of Object.entries(v)) if (!(k === "additionalProperties" && x === false)) out[k] = stripStrict(x);
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
return v;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** JSON Schema views of a zod schema. Throws if it is not representable (dates, transforms, …). */
|
|
51
|
+
export function schemaViews(schema: z.ZodType): ModelSchemasT {
|
|
52
|
+
const view = (io: "input" | "output") => {
|
|
53
|
+
const js = z.toJSONSchema(schema, { io, target: "draft-2020-12" }) as JsonSchema;
|
|
54
|
+
delete js.$schema; // constant noise; apiVersion pins the dialect
|
|
55
|
+
return canonicalize(js) as JsonSchema;
|
|
56
|
+
};
|
|
57
|
+
const output = view("output");
|
|
58
|
+
const input = view("input");
|
|
59
|
+
const same = JSON.stringify(stripStrict(output)) === JSON.stringify(input);
|
|
60
|
+
return same ? { output } : { output, input };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Follows `segments` through a JSON Schema; undefined if the field doesn't exist. */
|
|
64
|
+
function resolveField(schema: JsonSchema | undefined, segments: readonly string[]): boolean {
|
|
65
|
+
if (!schema) return false;
|
|
66
|
+
if (segments.length === 0) return true;
|
|
67
|
+
const [seg, ...rest] = segments as [string, ...string[]];
|
|
68
|
+
if (Array.isArray(schema.anyOf)) return (schema.anyOf as JsonSchema[]).some((b) => resolveField(b, segments));
|
|
69
|
+
if (Array.isArray(schema.oneOf)) return (schema.oneOf as JsonSchema[]).some((b) => resolveField(b, segments));
|
|
70
|
+
if (Array.isArray(schema.allOf)) return (schema.allOf as JsonSchema[]).some((b) => resolveField(b, segments));
|
|
71
|
+
const props = schema.properties as Record<string, JsonSchema> | undefined;
|
|
72
|
+
if (props && Object.hasOwn(props, seg)) return resolveField(props[seg], rest);
|
|
73
|
+
const extra = schema.additionalProperties;
|
|
74
|
+
if (extra && typeof extra === "object") return resolveField(extra as JsonSchema, rest);
|
|
75
|
+
// A schema without type/properties/anyOf accepts anything (z.unknown / z.any).
|
|
76
|
+
return Object.keys(schema).every((k) => k === "description" || k === "title" || k === "default");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function literalValues(field: JsonSchema | undefined): unknown[] | undefined {
|
|
80
|
+
if (!field) return undefined;
|
|
81
|
+
if (Array.isArray(field.enum)) return field.enum as unknown[];
|
|
82
|
+
if (field.const !== undefined) return [field.const];
|
|
83
|
+
if (field.type === "boolean") return [true, false];
|
|
84
|
+
const alts = (field.anyOf ?? field.oneOf) as JsonSchema[] | undefined;
|
|
85
|
+
if (Array.isArray(alts)) {
|
|
86
|
+
const all: unknown[] = [];
|
|
87
|
+
for (const a of alts) {
|
|
88
|
+
const l = literalValues(a);
|
|
89
|
+
if (!l) return undefined;
|
|
90
|
+
all.push(...l);
|
|
91
|
+
}
|
|
92
|
+
return all;
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function describePipeline(pipeline: unknown, opts: DescribeOptions): ManifestT {
|
|
98
|
+
const data = pipelineData(pipeline);
|
|
99
|
+
const problems: string[] = [...data.problems];
|
|
100
|
+
const handlerNames = new Set(opts.handlerNames);
|
|
101
|
+
|
|
102
|
+
// ── models ──
|
|
103
|
+
const models = new Map<string, { schemas: ModelSchemasT; key: string }>();
|
|
104
|
+
const useModel = (m: AnyModel, at: string): string => {
|
|
105
|
+
if (!MODEL_NAME.test(m.name)) problems.push(`${at}: model name '${m.name}' must match ${MODEL_NAME}`);
|
|
106
|
+
let schemas: ModelSchemasT;
|
|
107
|
+
try {
|
|
108
|
+
schemas = schemaViews(m.schema);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
problems.push(`${at}: model '${m.name}' is not representable as JSON Schema (${(e as Error).message})`);
|
|
111
|
+
return m.name;
|
|
112
|
+
}
|
|
113
|
+
const key = JSON.stringify(schemas);
|
|
114
|
+
const prev = models.get(m.name);
|
|
115
|
+
if (!prev) models.set(m.name, { schemas, key });
|
|
116
|
+
else if (prev.key !== key) problems.push(`two different schemas are registered under the model name '${m.name}' (one schema per model name)`);
|
|
117
|
+
return m.name;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// ── secrets ──
|
|
121
|
+
const secrets = new Map<string, string>();
|
|
122
|
+
const useSecret = (s: Secret, at: string): string => {
|
|
123
|
+
if (!SECRET_NAME.test(s.name)) problems.push(`${at}: secret name '${s.name}' must match ${SECRET_NAME}`);
|
|
124
|
+
const prev = secrets.get(s.name);
|
|
125
|
+
if (prev === undefined) secrets.set(s.name, s.description);
|
|
126
|
+
else if (prev !== s.description) problems.push(`secret '${s.name}' is declared twice with different descriptions`);
|
|
127
|
+
return s.name;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// ── step identity ──
|
|
131
|
+
const steps = new Map<string, AnyStep>();
|
|
132
|
+
const registerStep = (s: AnyStep, where: string) => {
|
|
133
|
+
if (typeof s.name !== "string" || !STEP_NAME.test(s.name))
|
|
134
|
+
problems.push(`${where}: step name '${String(s.name)}' must be lower-case kebab (${STEP_NAME})`);
|
|
135
|
+
if (RESERVED.has(s.name)) problems.push(`${where}: '${s.name}' is reserved and cannot be a step name`);
|
|
136
|
+
if (steps.has(s.name)) problems.push(`duplicate step name '${s.name}'`);
|
|
137
|
+
steps.set(s.name, s);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const inputModel = data.input;
|
|
141
|
+
if (!isModel(inputModel)) throw new DescribeError(["pipeline input must be a model (model(\"Name\", z.object({...})))"]);
|
|
142
|
+
useModel(inputModel, "pipeline input");
|
|
143
|
+
const inputOutputView = (): JsonSchema | undefined => models.get(inputModel.name)?.schemas.output;
|
|
144
|
+
const outputViewOf = (modelName: string | null): JsonSchema | undefined => (modelName ? models.get(modelName)?.schemas.output : undefined);
|
|
145
|
+
|
|
146
|
+
// ── one step ──
|
|
147
|
+
const describeStep = (node: StageStepNode, avail: Map<string, Avail>, failureHandler: boolean): StepDescriptorT => {
|
|
148
|
+
const s = node.step;
|
|
149
|
+
const at = `step '${s.name}'`;
|
|
150
|
+
if (typeof s.image !== "string" || s.image.trim() === "") problems.push(`${at}: image must be a non-empty string`);
|
|
151
|
+
|
|
152
|
+
const inputTypes: StepDescriptorT["inputTypes"] = {};
|
|
153
|
+
const entries = Object.entries(s.input as Record<string, unknown>);
|
|
154
|
+
for (const [key, e] of entries) {
|
|
155
|
+
if (isOptional(e)) inputTypes[key] = { model: useModel(e.model, `${at} input '${key}'`), optional: true };
|
|
156
|
+
else if (isModel(e)) inputTypes[key] = { model: useModel(e, `${at} input '${key}'`), optional: false };
|
|
157
|
+
else if (e instanceof z.ZodType) {
|
|
158
|
+
try {
|
|
159
|
+
inputTypes[key] = { schema: schemaViews(e) };
|
|
160
|
+
} catch (err) {
|
|
161
|
+
problems.push(`${at} input '${key}': schema is not representable as JSON Schema (${(err as Error).message})`);
|
|
162
|
+
}
|
|
163
|
+
} else problems.push(`${at} input '${key}': must be a model, optional(model) or a zod schema`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let input: Record<string, Mapping> = {};
|
|
167
|
+
if (failureHandler) {
|
|
168
|
+
for (const [key, e] of entries) {
|
|
169
|
+
if (e !== FailureContext && !(isModel(e) && e.name === FailureContext.name))
|
|
170
|
+
problems.push(`${at}: a failure handler's inputs must all be FailureContext (input '${key}' is not)`);
|
|
171
|
+
input[key] = { ref: FAILURE_ROOT };
|
|
172
|
+
}
|
|
173
|
+
if (node.mapper) problems.push(`${at}: failure handlers take no mapper`);
|
|
174
|
+
} else if (node.mapper) {
|
|
175
|
+
input = mapStep(at, s, node.mapper, avail);
|
|
176
|
+
} else {
|
|
177
|
+
for (const [key, e] of entries) {
|
|
178
|
+
const optional = isOptional(e);
|
|
179
|
+
const m = optional ? e.model : isModel(e) ? e : undefined;
|
|
180
|
+
if (!m) {
|
|
181
|
+
problems.push(`${at}: input '${key}' is a plain schema, not a model, so it cannot be matched by model; add a mapper ([step, from => ({ ... })])`);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const producers = [...avail].filter(([, a]) => a.model === m.name);
|
|
185
|
+
if (producers.length === 0) {
|
|
186
|
+
problems.push(`${at}: ${m.name} (input '${key}') is not available at this stage${optional ? " (optional() means maybe-skipped; there must still be an earlier producer)" : ""}`);
|
|
187
|
+
} else if (producers.length > 1) {
|
|
188
|
+
problems.push(`${at}: ${m.name} (input '${key}') has several producers (${producers.map(([n]) => n).sort().join(", ")}); use a mapper to pick one`);
|
|
189
|
+
} else {
|
|
190
|
+
const [producer, a] = producers[0]!;
|
|
191
|
+
if (a.maybe && !optional) problems.push(`${at}: ${m.name} (input '${key}') is only maybe-available (produced inside a when block); declare it optional(${m.name})`);
|
|
192
|
+
input[key] = { ref: producer };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const secretNames = s.secrets.map((x) => useSecret(x, at)).sort();
|
|
198
|
+
let handlerHash: string;
|
|
199
|
+
let config: Record<string, unknown> | undefined;
|
|
200
|
+
if (s.kind === "custom") {
|
|
201
|
+
if (!handlerNames.has(s.name)) problems.push(`${at} has no handler (expected handlers/${s.name}.ts default-exporting implement(${camel(s.name)}, …))`);
|
|
202
|
+
handlerHash = opts.handlerHashes[s.name] ?? "";
|
|
203
|
+
if (handlerNames.has(s.name) && !handlerHash) problems.push(`${at}: no handler hash was computed`);
|
|
204
|
+
} else if (s.builtin) {
|
|
205
|
+
try {
|
|
206
|
+
config = canonicalize(s.builtin.manifestConfig()) as Record<string, unknown>;
|
|
207
|
+
} catch (e) {
|
|
208
|
+
problems.push(`${at}: invalid ${s.kind} configuration (${(e as Error).message})`);
|
|
209
|
+
config = {};
|
|
210
|
+
}
|
|
211
|
+
handlerHash = libraryHandlerHash(s.kind, config, opts.harness);
|
|
212
|
+
} else {
|
|
213
|
+
problems.push(`${at}: library step of kind '${s.kind}' has no implementation`);
|
|
214
|
+
handlerHash = "";
|
|
215
|
+
}
|
|
216
|
+
if (typeof s.description !== "string") problems.push(`${at}: description must be a string (it may be empty)`);
|
|
217
|
+
if (s.timeoutSeconds !== undefined && !(Number.isInteger(s.timeoutSeconds) && s.timeoutSeconds > 0)) problems.push(`${at}: timeoutSeconds must be a positive integer`);
|
|
218
|
+
if (s.retries !== undefined && !(Number.isInteger(s.retries) && s.retries >= 0)) problems.push(`${at}: retries must be a non-negative integer`);
|
|
219
|
+
|
|
220
|
+
const out: StepDescriptorT = {
|
|
221
|
+
name: s.name,
|
|
222
|
+
description: typeof s.description === "string" ? s.description : "",
|
|
223
|
+
kind: s.kind,
|
|
224
|
+
image: s.image,
|
|
225
|
+
inputTypes,
|
|
226
|
+
input,
|
|
227
|
+
output: s.output ? useModel(s.output, `${at} output`) : null,
|
|
228
|
+
secrets: secretNames,
|
|
229
|
+
handlerHash: handlerHash || "sha256:" + "0".repeat(64),
|
|
230
|
+
...(s.timeoutSeconds !== undefined ? { timeoutSeconds: s.timeoutSeconds } : {}),
|
|
231
|
+
...(s.retries !== undefined ? { retries: s.retries } : {}),
|
|
232
|
+
...(config !== undefined ? { config: config as Record<string, z.core.util.JSONType> } : {}),
|
|
233
|
+
};
|
|
234
|
+
if (s.output && !isModel(s.output)) problems.push(`${at}: output must be a model`);
|
|
235
|
+
return out;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const mapStep = (at: string, s: AnyStep, mapper: MapperFunction, avail: Map<string, Avail>): Record<string, Mapping> => {
|
|
239
|
+
try {
|
|
240
|
+
analyzeMapperSource(Function.prototype.toString.call(mapper));
|
|
241
|
+
} catch (e) {
|
|
242
|
+
if (e instanceof MapperError) problems.push(`${at}: ${e.message}`);
|
|
243
|
+
else throw e;
|
|
244
|
+
return {};
|
|
245
|
+
}
|
|
246
|
+
const roots = new Set([...avail].filter(([, a]) => a.model !== null).map(([n]) => n));
|
|
247
|
+
let recorded: Record<string, Mapping>;
|
|
248
|
+
try {
|
|
249
|
+
recorded = recordMapper(mapper, roots);
|
|
250
|
+
} catch (e) {
|
|
251
|
+
if (e instanceof MapperError) problems.push(`${at}: ${e.message}`);
|
|
252
|
+
else problems.push(`${at}: mapper threw: ${(e as Error)?.message ?? String(e)}`);
|
|
253
|
+
return {};
|
|
254
|
+
}
|
|
255
|
+
const declared = s.input as Record<string, unknown>;
|
|
256
|
+
for (const k of Object.keys(declared)) if (!(k in recorded)) problems.push(`${at}: mapper does not provide input '${k}'`);
|
|
257
|
+
for (const k of Object.keys(recorded)) if (!(k in declared)) problems.push(`${at}: mapper provides '${k}', which is not an input of the step`);
|
|
258
|
+
for (const [k, m] of Object.entries(recorded)) checkMapping(`${at} input '${k}'`, m, avail, isOptional(declared[k]), true);
|
|
259
|
+
return recorded;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const checkMapping = (at: string, m: Mapping, avail: Map<string, Avail>, optionalTarget: boolean, topLevel: boolean) => {
|
|
263
|
+
const keys = Object.keys(m);
|
|
264
|
+
if (keys.length === 1 && keys[0] === "ref" && typeof (m as { ref: unknown }).ref === "string") {
|
|
265
|
+
const [root, ...path] = (m as { ref: string }).ref.split(".") as [string, ...string[]];
|
|
266
|
+
const a = avail.get(root);
|
|
267
|
+
if (!a) return problems.push(`${at}: '${root}' is not available at this stage`);
|
|
268
|
+
if (!resolveField(outputViewOf(a.model), path)) problems.push(`${at}: '${(m as { ref: string }).ref}' — model ${a.model} has no field '${path.join(".")}'`);
|
|
269
|
+
if (a.maybe && !(optionalTarget && topLevel))
|
|
270
|
+
problems.push(`${at}: '${(m as { ref: string }).ref}' is only maybe-available (produced inside a when block); map it whole into an optional(...) input`);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (keys.length === 1 && keys[0] === "const") return;
|
|
274
|
+
for (const [k, x] of Object.entries(m as Record<string, Mapping>)) checkMapping(`${at}.${k}`, x, avail, optionalTarget, false);
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// ── stages ──
|
|
278
|
+
const walk = (nodes: readonly StageNode[], avail: Map<string, Avail>): Stage[] => {
|
|
279
|
+
const out: Stage[] = [];
|
|
280
|
+
for (const n of nodes) {
|
|
281
|
+
if (n.kind === "stage") {
|
|
282
|
+
const descs = n.steps.map((ss) => {
|
|
283
|
+
if (!isStep(ss.step)) {
|
|
284
|
+
problems.push("a stage entry is not a step");
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
registerStep(ss.step, "stage");
|
|
288
|
+
return describeStep(ss, avail, false);
|
|
289
|
+
}).filter((d): d is StepDescriptorT => d !== undefined);
|
|
290
|
+
// Outputs become available only after the barrier (same-stage siblings can't see each other).
|
|
291
|
+
for (const ss of n.steps) if (isStep(ss.step)) avail.set(ss.step.name, { model: ss.step.output?.name ?? null, maybe: false });
|
|
292
|
+
if (descs.length) out.push({ steps: descs });
|
|
293
|
+
} else {
|
|
294
|
+
const when: Record<string, (string | number | boolean)[]> = {};
|
|
295
|
+
const props = (inputOutputView()?.properties ?? {}) as Record<string, JsonSchema>;
|
|
296
|
+
for (const [field, raw] of Object.entries(n.cond)) {
|
|
297
|
+
const values = (Array.isArray(raw) ? raw : [raw]) as (string | number | boolean)[];
|
|
298
|
+
if (!Object.hasOwn(props, field)) problems.push(`when: '${field}' is not a field of the pipeline input ${inputModel.name}`);
|
|
299
|
+
else {
|
|
300
|
+
const literals = literalValues(props[field]);
|
|
301
|
+
if (!literals) problems.push(`when: '${field}' is not a literal-like field (enum, boolean or literal)`);
|
|
302
|
+
else for (const v of values) if (!literals.includes(v)) problems.push(`when: ${JSON.stringify(v)} is not a valid value of '${field}' (${literals.map((l) => JSON.stringify(l)).join(", ")})`);
|
|
303
|
+
}
|
|
304
|
+
if (values.length === 0) problems.push(`when: '${field}' has no values`);
|
|
305
|
+
when[field] = values;
|
|
306
|
+
}
|
|
307
|
+
const inner = new Map(avail);
|
|
308
|
+
const stages = walk(n.nodes, inner);
|
|
309
|
+
for (const [name, a] of inner) if (!avail.has(name)) avail.set(name, { model: a.model, maybe: true });
|
|
310
|
+
out.push({ when, stages });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
};
|
|
315
|
+
const stages = walk(data.nodes, new Map([["input", { model: inputModel.name, maybe: false }]]));
|
|
316
|
+
|
|
317
|
+
// ── failure handlers ──
|
|
318
|
+
const stageStepNames = new Set(steps.keys());
|
|
319
|
+
const seenHandlers = new Map<string, AnyStep>();
|
|
320
|
+
const failureHandlers = data.failureHandlers.map((fh) => {
|
|
321
|
+
if (!isStep(fh.step)) {
|
|
322
|
+
problems.push("onFailure: handler is not a step");
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
if (seenHandlers.get(fh.step.name) !== fh.step) registerStep(fh.step, "onFailure");
|
|
326
|
+
seenHandlers.set(fh.step.name, fh.step);
|
|
327
|
+
const scope = fh.steps === null ? null : fh.steps.map((x) => x.name);
|
|
328
|
+
for (const n of scope ?? []) if (!stageStepNames.has(n)) problems.push(`onFailure(${fh.step.name}): '${n}' is not a step of this pipeline`);
|
|
329
|
+
return { step: describeStep({ step: fh.step }, new Map(), true), steps: scope };
|
|
330
|
+
}).filter((x) => x !== undefined);
|
|
331
|
+
|
|
332
|
+
// ── handler files without a step ──
|
|
333
|
+
for (const h of handlerNames) {
|
|
334
|
+
const s = steps.get(h);
|
|
335
|
+
if (!s) problems.push(`handlers/${h}.ts does not match any step of the pipeline`);
|
|
336
|
+
else if (s.kind !== "custom") problems.push(`handlers/${h}.ts: '${h}' is a library step (${s.kind}) and must not have a handler file`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── triggers ──
|
|
340
|
+
const triggerNames = new Set<string>();
|
|
341
|
+
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 and onWebhook a distinct name)"}`);
|
|
343
|
+
triggerNames.add(t.name);
|
|
344
|
+
if (t.kind === "push") return { kind: "push", name: "push" };
|
|
345
|
+
if (t.kind === "webhook") {
|
|
346
|
+
if (!STEP_NAME.test(t.name)) problems.push(`webhook trigger name '${t.name}' must be lower-case kebab`);
|
|
347
|
+
const secret = useSecret(t.verify.secret, `webhook '${t.name}'`);
|
|
348
|
+
if (t.verify.scheme === "bearer") return { kind: "webhook", name: t.name, verify: { scheme: "bearer", secret } };
|
|
349
|
+
if (!t.verify.header) problems.push(`webhook '${t.name}': the signature header must not be empty`);
|
|
350
|
+
return { kind: "webhook", name: t.name, verify: { scheme: "hmac-sha256", secret, header: t.verify.header, prefix: t.verify.prefix } };
|
|
351
|
+
}
|
|
352
|
+
if (!STEP_NAME.test(t.name)) problems.push(`poll trigger name '${t.name}' must be lower-case kebab`);
|
|
353
|
+
if (!(Number.isInteger(t.intervalSeconds) && t.intervalSeconds > 0)) problems.push(`poll '${t.name}': intervalSeconds must be a positive integer`);
|
|
354
|
+
const declared = new Set(t.secrets.map((s) => useSecret(s, `poll '${t.name}'`)));
|
|
355
|
+
for (const [h, v] of Object.entries(t.headers)) {
|
|
356
|
+
for (const m of v.matchAll(/\$SECRET:([A-Za-z0-9_]+)/g))
|
|
357
|
+
if (!declared.has(m[1]!)) problems.push(`poll '${t.name}': header '${h}' references secret '${m[1]}', which is not in the poll's secrets`);
|
|
358
|
+
}
|
|
359
|
+
return { kind: "poll", name: t.name, url: t.url, headers: { ...t.headers }, secrets: [...declared].sort(), valuePath: t.valuePath, intervalSeconds: t.intervalSeconds };
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
if (problems.length) throw new DescribeError(problems);
|
|
363
|
+
|
|
364
|
+
const manifest = canonicalize({
|
|
365
|
+
apiVersion: API_VERSION,
|
|
366
|
+
name: data.name,
|
|
367
|
+
description: data.description,
|
|
368
|
+
input: inputModel.name,
|
|
369
|
+
harness: opts.harness,
|
|
370
|
+
models: Object.fromEntries([...models].map(([k, v]) => [k, v.schemas])),
|
|
371
|
+
secrets: [...secrets].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([name, description]) => ({ name, description })),
|
|
372
|
+
triggers,
|
|
373
|
+
stages,
|
|
374
|
+
failureHandlers,
|
|
375
|
+
});
|
|
376
|
+
const checked = Manifest.safeParse(manifest);
|
|
377
|
+
if (!checked.success) throw new DescribeError([`internal: manifest does not match its schema: ${z.prettifyError(checked.error)}`]);
|
|
378
|
+
return manifest as ManifestT;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Canonical manifest text (what `describe` prints and the service hashes). */
|
|
382
|
+
export const manifestText = (m: ManifestT): string => canonicalJson(m);
|
|
383
|
+
|
|
384
|
+
function camel(name: string): string {
|
|
385
|
+
return name.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
|
|
386
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// The manifest format, defined once in zod. `manifest.schema.json` is generated from this
|
|
2
|
+
// (bun scripts/gen-manifest-schema.ts) and describe validates every manifest against it.
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
export const API_VERSION = "2.0";
|
|
6
|
+
|
|
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
|
+
const modelName = z.string().regex(/^[A-Za-z][A-Za-z0-9_]*$/).describe("Model name (key of `models`).");
|
|
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 hash = z.string().regex(/^sha256:[0-9a-f]{64}$/);
|
|
11
|
+
const Json = z.json().meta({ id: "Json", description: "Any JSON value." });
|
|
12
|
+
const jsonSchema = z.record(z.string(), z.unknown()).describe("A JSON Schema (draft 2020-12) object, canonical (sorted keys, no $schema).");
|
|
13
|
+
|
|
14
|
+
export const RefMapping = z
|
|
15
|
+
.strictObject({
|
|
16
|
+
ref: z
|
|
17
|
+
.string()
|
|
18
|
+
.regex(/^(?:input|failure|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.[^.]+)*$/)
|
|
19
|
+
.describe("'<root>' or '<root>.<field>.<field>…'. Root: 'input' (pipeline input), a step name (that step's output) or 'failure' (FailureContext, failure handlers only)."),
|
|
20
|
+
})
|
|
21
|
+
.describe("Value taken from the run pool. A ref whose root step was skipped yields no value (only allowed for optional inputs).");
|
|
22
|
+
export const ConstMapping = z.strictObject({ const: Json }).describe("A JSON constant.");
|
|
23
|
+
|
|
24
|
+
export type Mapping = { ref: string } | { const: unknown } | { [field: string]: Mapping };
|
|
25
|
+
export const Mapping: z.ZodType<Mapping> = z
|
|
26
|
+
.union([
|
|
27
|
+
RefMapping,
|
|
28
|
+
ConstMapping,
|
|
29
|
+
z.record(z.string(), z.lazy(() => Mapping)).describe(
|
|
30
|
+
"An object built field by field. Discrimination: an object whose only key is 'ref' (string value) is a ref, whose only key is 'const' is a constant, anything else is a nested object (describe never emits a nested object whose only key is 'ref' or 'const').",
|
|
31
|
+
),
|
|
32
|
+
])
|
|
33
|
+
.meta({ id: "Mapping" });
|
|
34
|
+
|
|
35
|
+
export const ModelSchemas = z
|
|
36
|
+
.strictObject({
|
|
37
|
+
output: jsonSchema.describe("z.output view: validates step outputs, trigger results and the pool. Every object has additionalProperties: false."),
|
|
38
|
+
input: jsonSchema.optional().describe("z.input view (defaults make fields optional; `default` values are listed). Present only when it differs from `output`."),
|
|
39
|
+
})
|
|
40
|
+
.meta({ id: "ModelSchemas" });
|
|
41
|
+
|
|
42
|
+
export const InputType = z
|
|
43
|
+
.union([
|
|
44
|
+
z.strictObject({ model: modelName, optional: z.boolean().describe("optional(Model): may be absent because its producer was skipped.") }),
|
|
45
|
+
z.strictObject({ schema: ModelSchemas.describe("A plain (unnamed) zod schema. Such inputs always have an explicit mapping.") }),
|
|
46
|
+
])
|
|
47
|
+
.meta({ id: "InputType" });
|
|
48
|
+
|
|
49
|
+
export const StepDescriptor = z
|
|
50
|
+
.strictObject({
|
|
51
|
+
name: stepName,
|
|
52
|
+
description: z.string().describe("What the step does, shown in the UI (may be empty)."),
|
|
53
|
+
kind: z.enum(["custom", "script", "gitHubSource", "imageBuild", "helmDeployOverSsh"]).describe("'custom' = handlers/<name>.ts; others are SDK library steps."),
|
|
54
|
+
image: z.string().min(1).describe("Container image the step runs in."),
|
|
55
|
+
inputTypes: z.record(z.string(), InputType).describe("Declared input entries, keyed like input.json."),
|
|
56
|
+
input: z.record(z.string(), Mapping).describe("How the service assembles input.json: one mapping per input key (same keys as inputTypes)."),
|
|
57
|
+
output: modelName.nullable().describe("Output model, or null if the step produces no output."),
|
|
58
|
+
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 library steps kind + config + harness."),
|
|
60
|
+
timeoutSeconds: z.number().int().positive().optional(),
|
|
61
|
+
retries: z.number().int().nonnegative().optional(),
|
|
62
|
+
config: z.record(z.string(), Json).optional().describe("Library-step configuration (display only; already covered by handlerHash)."),
|
|
63
|
+
})
|
|
64
|
+
.meta({ id: "Step" });
|
|
65
|
+
|
|
66
|
+
export type Stage = { steps: z.infer<typeof StepDescriptor>[] } | { when: Record<string, (string | number | boolean)[]>; stages: Stage[] };
|
|
67
|
+
export const Stage: z.ZodType<Stage> = z
|
|
68
|
+
.union([
|
|
69
|
+
z.strictObject({ steps: z.array(StepDescriptor).min(1).describe("Steps run in parallel; the stage is a barrier.") }),
|
|
70
|
+
z.strictObject({
|
|
71
|
+
when: z.record(z.string(), z.array(z.union([z.string(), z.number(), z.boolean()])).min(1)).describe(
|
|
72
|
+
"Pipeline-input field → accepted values (all fields must match). Empty object = always.",
|
|
73
|
+
),
|
|
74
|
+
stages: z.array(z.lazy(() => Stage)),
|
|
75
|
+
}),
|
|
76
|
+
])
|
|
77
|
+
.meta({ id: "Stage" });
|
|
78
|
+
|
|
79
|
+
export const Trigger = z
|
|
80
|
+
.discriminatedUnion("kind", [
|
|
81
|
+
z.strictObject({ kind: z.literal("push"), name: z.literal("push") }).describe("GitHub push. Event = the webhook payload."),
|
|
82
|
+
z
|
|
83
|
+
.strictObject({
|
|
84
|
+
kind: z.literal("poll"),
|
|
85
|
+
name: z.string().regex(/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/),
|
|
86
|
+
url: z.string(),
|
|
87
|
+
headers: z.record(z.string(), z.string()).describe("Values may contain '$SECRET:NAME' (NAME ∈ secrets)."),
|
|
88
|
+
secrets: z.array(secretName),
|
|
89
|
+
valuePath: z.string().describe("Dot path into the JSON response; a change triggers map-trigger with {\"value\": <string>}."),
|
|
90
|
+
intervalSeconds: z.number().int().positive(),
|
|
91
|
+
})
|
|
92
|
+
.describe("Background poller."),
|
|
93
|
+
z
|
|
94
|
+
.strictObject({
|
|
95
|
+
kind: z.literal("webhook"),
|
|
96
|
+
name: z.string().regex(/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/),
|
|
97
|
+
verify: z.discriminatedUnion("scheme", [
|
|
98
|
+
z.strictObject({
|
|
99
|
+
scheme: z.literal("hmac-sha256"),
|
|
100
|
+
secret: secretName,
|
|
101
|
+
header: z.string().min(1),
|
|
102
|
+
prefix: z.string().describe("Text before the hex digest in the header, e.g. 'sha256='."),
|
|
103
|
+
}),
|
|
104
|
+
z.strictObject({ scheme: z.literal("bearer"), secret: secretName }),
|
|
105
|
+
]),
|
|
106
|
+
})
|
|
107
|
+
.describe("Inbound HTTP delivery to /api/hooks/<pipeline id>/<name>. Event = {headers, body}; a null mapping ignores it."),
|
|
108
|
+
])
|
|
109
|
+
.meta({ id: "Trigger" });
|
|
110
|
+
|
|
111
|
+
export const Manifest = z
|
|
112
|
+
.strictObject({
|
|
113
|
+
apiVersion: z.literal(API_VERSION),
|
|
114
|
+
name: z.string().min(1),
|
|
115
|
+
description: z.string().optional(),
|
|
116
|
+
input: modelName.describe("Pipeline input model."),
|
|
117
|
+
harness: z
|
|
118
|
+
.strictObject({
|
|
119
|
+
bun: z.string().describe("Bun runtime version compiled into the binary."),
|
|
120
|
+
sdk: z.string().describe("@olve/pipelines package version."),
|
|
121
|
+
sdkHash: hash.describe("Hash of the SDK runtime sources + zod version."),
|
|
122
|
+
})
|
|
123
|
+
.describe("Harness identity. Already folded into every handlerHash."),
|
|
124
|
+
models: z.record(modelName, ModelSchemas).describe("Every model referenced by the pipeline (input, step inputs/outputs)."),
|
|
125
|
+
secrets: z.array(z.strictObject({ name: secretName, description: z.string() })).describe("All declared secrets (steps + poll triggers), sorted by name."),
|
|
126
|
+
triggers: z.array(Trigger),
|
|
127
|
+
stages: z.array(Stage),
|
|
128
|
+
failureHandlers: z.array(
|
|
129
|
+
z.strictObject({
|
|
130
|
+
step: StepDescriptor.describe("The handler step. Not part of any stage; its inputs are all FailureContext ({ref: 'failure'})."),
|
|
131
|
+
steps: z.array(stepName).nullable().describe("Steps whose failure triggers the handler (null = any step)."),
|
|
132
|
+
}),
|
|
133
|
+
),
|
|
134
|
+
})
|
|
135
|
+
.meta({ id: "Manifest", title: "Olve.Pipelines v2 manifest" });
|
|
136
|
+
|
|
137
|
+
export type ManifestT = z.infer<typeof Manifest>;
|
|
138
|
+
export type StepDescriptorT = z.infer<typeof StepDescriptor>;
|
|
139
|
+
export type TriggerT = z.infer<typeof Trigger>;
|
|
140
|
+
export type ModelSchemasT = z.infer<typeof ModelSchemas>;
|
|
141
|
+
|
|
142
|
+
/** The JSON Schema document written to manifest.schema.json. */
|
|
143
|
+
export function manifestJsonSchema(): Record<string, unknown> {
|
|
144
|
+
const js = z.toJSONSchema(Manifest, { target: "draft-2020-12" }) as Record<string, unknown>;
|
|
145
|
+
return { ...js, $id: "https://olve.dev/schemas/pipelines/manifest-2.0.json" };
|
|
146
|
+
}
|