@olivervea/pipelines 0.0.0-stage → 2.0.0-beta.10
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 +467 -2
- package/manifest.schema.json +674 -0
- package/package.json +60 -4
- package/src/assets.d.ts +10 -0
- package/src/build/build.ts +127 -0
- package/src/build/cli.ts +52 -0
- package/src/build/elf.ts +35 -0
- package/src/build/hashes.ts +96 -0
- package/src/build/project.ts +107 -0
- package/src/builder/pipeline.ts +373 -0
- package/src/builder/refs.ts +26 -0
- package/src/core/brands.ts +15 -0
- package/src/core/config-refs.ts +77 -0
- package/src/core/config.ts +147 -0
- package/src/core/handlers.ts +50 -0
- package/src/core/models.ts +69 -0
- package/src/core/origin.ts +20 -0
- package/src/core/secrets.ts +13 -0
- package/src/core/steps.ts +138 -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 +122 -0
- package/src/ctx/sh.ts +197 -0
- package/src/ctx/types.ts +116 -0
- package/src/describe/describe.ts +565 -0
- package/src/describe/manifest-schema.ts +179 -0
- package/src/describe/mapper-source.ts +238 -0
- package/src/describe/record.ts +107 -0
- package/src/dev/cli.ts +87 -0
- package/src/harness/env.ts +58 -0
- package/src/harness/execute.ts +97 -0
- package/src/harness/exit.ts +19 -0
- package/src/harness/index.ts +9 -0
- package/src/harness/main.ts +270 -0
- package/src/harness/result.ts +75 -0
- package/src/harness/validate.ts +92 -0
- package/src/index.ts +15 -0
- package/src/library/checks.ts +23 -0
- package/src/library/compose-deploy.ts +96 -0
- package/src/library/github-source.ts +100 -0
- package/src/library/helm-deploy-ssh.ts +358 -0
- package/src/library/helm-deploy.ts +271 -0
- package/src/library/http-health.ts +34 -0
- package/src/library/image-build.ts +418 -0
- package/src/library/index.ts +18 -0
- package/src/library/models.ts +79 -0
- package/src/library/ssh.ts +50 -0
- package/src/testing/index.ts +82 -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 +167 -0
|
@@ -0,0 +1,565 @@
|
|
|
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 { 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";
|
|
7
|
+
import { isModel, isOptional, MODEL_NAME, type AnyModel } from "../core/models.ts";
|
|
8
|
+
import { SECRET_NAME, type Secret } from "../core/secrets.ts";
|
|
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";
|
|
12
|
+
import { pipelineData, type MapperFunction, type StageNode, type StageStepNode } from "../builder/pipeline.ts";
|
|
13
|
+
import { FailureContext } from "../library/models.ts";
|
|
14
|
+
import { canonicalize, canonicalJson } from "../util/canonical.ts";
|
|
15
|
+
import { libraryHandlerHash, type HarnessInfo } from "../util/handler-hash.ts";
|
|
16
|
+
import { analyzeMapperSource } from "./mapper-source.ts";
|
|
17
|
+
import { MapperError, recordMapper, type Mapping } from "./record.ts";
|
|
18
|
+
import { API_VERSION, Manifest, type ManifestT, type ModelSchemasT, type Stage, type StepDescriptorT, type TriggerT } from "./manifest-schema.ts";
|
|
19
|
+
|
|
20
|
+
export class DescribeError extends Error {
|
|
21
|
+
override name = "DescribeError";
|
|
22
|
+
constructor(readonly problems: readonly string[]) {
|
|
23
|
+
super(`describe found ${problems.length} problem(s):\n${problems.map((p) => ` - ${p}`).join("\n")}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DescribeOptions {
|
|
28
|
+
/** Step names that have a handler (a `handlers/*.ts` default-exporting `implement(step, …)`). */
|
|
29
|
+
handlerNames: Iterable<string>;
|
|
30
|
+
/** Step name → handler hash (from the build; includes the harness identity). */
|
|
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>>;
|
|
34
|
+
harness: HarnessInfo;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Root name used in failure-handler mappings (the service supplies FailureContext under it). */
|
|
38
|
+
export const FAILURE_ROOT = "failure";
|
|
39
|
+
const RESERVED = new Set([...RESERVED_STEP_NAMES, FAILURE_ROOT]);
|
|
40
|
+
|
|
41
|
+
type JsonSchema = Record<string, unknown>;
|
|
42
|
+
interface Avail {
|
|
43
|
+
model: string | null;
|
|
44
|
+
maybe: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function stripStrict(v: unknown): unknown {
|
|
48
|
+
if (Array.isArray(v)) return v.map(stripStrict);
|
|
49
|
+
if (v !== null && typeof v === "object") {
|
|
50
|
+
const out: Record<string, unknown> = {};
|
|
51
|
+
for (const [k, x] of Object.entries(v)) if (!(k === "additionalProperties" && x === false)) out[k] = stripStrict(x);
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
return v;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** JSON Schema views of a zod schema. Throws if it is not representable (dates, transforms, …). */
|
|
58
|
+
export function schemaViews(schema: z.ZodType): ModelSchemasT {
|
|
59
|
+
const view = (io: "input" | "output") => {
|
|
60
|
+
const js = z.toJSONSchema(schema, { io, target: "draft-2020-12" }) as JsonSchema;
|
|
61
|
+
delete js.$schema; // constant noise; apiVersion pins the dialect
|
|
62
|
+
return canonicalize(js) as JsonSchema;
|
|
63
|
+
};
|
|
64
|
+
const output = view("output");
|
|
65
|
+
const input = view("input");
|
|
66
|
+
const same = JSON.stringify(stripStrict(output)) === JSON.stringify(input);
|
|
67
|
+
return same ? { output } : { output, input };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Follows `segments` through a JSON Schema; undefined if the field doesn't exist. */
|
|
71
|
+
function resolveField(schema: JsonSchema | undefined, segments: readonly string[]): boolean {
|
|
72
|
+
if (!schema) return false;
|
|
73
|
+
if (segments.length === 0) return true;
|
|
74
|
+
const [seg, ...rest] = segments as [string, ...string[]];
|
|
75
|
+
if (Array.isArray(schema.anyOf)) return (schema.anyOf as JsonSchema[]).some((b) => resolveField(b, segments));
|
|
76
|
+
if (Array.isArray(schema.oneOf)) return (schema.oneOf as JsonSchema[]).some((b) => resolveField(b, segments));
|
|
77
|
+
if (Array.isArray(schema.allOf)) return (schema.allOf as JsonSchema[]).some((b) => resolveField(b, segments));
|
|
78
|
+
const props = schema.properties as Record<string, JsonSchema> | undefined;
|
|
79
|
+
if (props && Object.hasOwn(props, seg)) return resolveField(props[seg], rest);
|
|
80
|
+
const extra = schema.additionalProperties;
|
|
81
|
+
if (extra && typeof extra === "object") return resolveField(extra as JsonSchema, rest);
|
|
82
|
+
// A schema without type/properties/anyOf accepts anything (z.unknown / z.any).
|
|
83
|
+
return Object.keys(schema).every((k) => k === "description" || k === "title" || k === "default");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function literalValues(field: JsonSchema | undefined): unknown[] | undefined {
|
|
87
|
+
if (!field) return undefined;
|
|
88
|
+
if (Array.isArray(field.enum)) return field.enum as unknown[];
|
|
89
|
+
if (field.const !== undefined) return [field.const];
|
|
90
|
+
if (field.type === "boolean") return [true, false];
|
|
91
|
+
const alts = (field.anyOf ?? field.oneOf) as JsonSchema[] | undefined;
|
|
92
|
+
if (Array.isArray(alts)) {
|
|
93
|
+
const all: unknown[] = [];
|
|
94
|
+
for (const a of alts) {
|
|
95
|
+
const l = literalValues(a);
|
|
96
|
+
if (!l) return undefined;
|
|
97
|
+
all.push(...l);
|
|
98
|
+
}
|
|
99
|
+
return all;
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function describePipeline(pipeline: unknown, opts: DescribeOptions): ManifestT {
|
|
105
|
+
const data = pipelineData(pipeline);
|
|
106
|
+
const problems: string[] = [...data.problems];
|
|
107
|
+
const handlerNames = new Set(opts.handlerNames);
|
|
108
|
+
|
|
109
|
+
// ── models ──
|
|
110
|
+
const models = new Map<string, { schemas: ModelSchemasT; key: string }>();
|
|
111
|
+
const useModel = (m: AnyModel, at: string): string => {
|
|
112
|
+
if (!MODEL_NAME.test(m.name)) problems.push(`${at}: model name '${m.name}' must match ${MODEL_NAME}`);
|
|
113
|
+
let schemas: ModelSchemasT;
|
|
114
|
+
try {
|
|
115
|
+
schemas = schemaViews(m.schema);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
problems.push(`${at}: model '${m.name}' is not representable as JSON Schema (${(e as Error).message})`);
|
|
118
|
+
return m.name;
|
|
119
|
+
}
|
|
120
|
+
const key = JSON.stringify(schemas);
|
|
121
|
+
const prev = models.get(m.name);
|
|
122
|
+
if (!prev) models.set(m.name, { schemas, key });
|
|
123
|
+
else if (prev.key !== key) problems.push(`two different schemas are registered under the model name '${m.name}' (one schema per model name)`);
|
|
124
|
+
return m.name;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// ── secrets ──
|
|
128
|
+
const secrets = new Map<string, string>();
|
|
129
|
+
const useSecret = (s: Secret, at: string): string => {
|
|
130
|
+
if (!SECRET_NAME.test(s.name)) problems.push(`${at}: secret name '${s.name}' must match ${SECRET_NAME}`);
|
|
131
|
+
const prev = secrets.get(s.name);
|
|
132
|
+
if (prev === undefined) secrets.set(s.name, s.description);
|
|
133
|
+
else if (prev !== s.description) problems.push(`secret '${s.name}' is declared twice with different descriptions`);
|
|
134
|
+
return s.name;
|
|
135
|
+
};
|
|
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
|
+
|
|
158
|
+
// ── step identity ──
|
|
159
|
+
const steps = new Map<string, AnyStep>();
|
|
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[] = []) => {
|
|
164
|
+
if (typeof s.name !== "string" || !STEP_NAME.test(s.name))
|
|
165
|
+
problems.push(`${where}: step name '${String(s.name)}' must be lower-case kebab (${STEP_NAME})`);
|
|
166
|
+
if (RESERVED.has(s.name)) problems.push(`${where}: '${s.name}' is reserved and cannot be a step name`);
|
|
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);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const inputModel = data.input;
|
|
180
|
+
if (!isModel(inputModel)) throw new DescribeError(["pipeline input must be a model (model(\"Name\", z.object({...})))"]);
|
|
181
|
+
useModel(inputModel, "pipeline input");
|
|
182
|
+
const inputOutputView = (): JsonSchema | undefined => models.get(inputModel.name)?.schemas.output;
|
|
183
|
+
const outputViewOf = (modelName: string | null): JsonSchema | undefined => (modelName ? models.get(modelName)?.schemas.output : undefined);
|
|
184
|
+
|
|
185
|
+
// ── one step ──
|
|
186
|
+
const describeStep = (node: StageStepNode, avail: Map<string, Avail>, failureHandler: boolean, cases: readonly CaseOf[] = []): StepDescriptorT => {
|
|
187
|
+
const s = node.step;
|
|
188
|
+
const at = `step '${s.name}'`;
|
|
189
|
+
if (typeof s.image !== "string" || s.image.trim() === "") problems.push(`${at}: image must be a non-empty string`);
|
|
190
|
+
|
|
191
|
+
const inputTypes: StepDescriptorT["inputTypes"] = {};
|
|
192
|
+
const entries = Object.entries(s.input as Record<string, unknown>);
|
|
193
|
+
for (const [key, e] of entries) {
|
|
194
|
+
if (isOptional(e)) inputTypes[key] = { model: useModel(e.model, `${at} input '${key}'`), optional: true };
|
|
195
|
+
else if (isModel(e)) inputTypes[key] = { model: useModel(e, `${at} input '${key}'`), optional: false };
|
|
196
|
+
else if (e instanceof z.ZodType) {
|
|
197
|
+
try {
|
|
198
|
+
inputTypes[key] = { schema: schemaViews(e) };
|
|
199
|
+
} catch (err) {
|
|
200
|
+
problems.push(`${at} input '${key}': schema is not representable as JSON Schema (${(err as Error).message})`);
|
|
201
|
+
}
|
|
202
|
+
} else problems.push(`${at} input '${key}': must be a model, optional(model) or a zod schema`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let input: Record<string, Mapping> = {};
|
|
206
|
+
if (failureHandler) {
|
|
207
|
+
for (const [key, e] of entries) {
|
|
208
|
+
if (e !== FailureContext && !(isModel(e) && e.name === FailureContext.name))
|
|
209
|
+
problems.push(`${at}: a failure handler's inputs must all be FailureContext (input '${key}' is not)`);
|
|
210
|
+
input[key] = { ref: FAILURE_ROOT };
|
|
211
|
+
}
|
|
212
|
+
if (node.mapper) problems.push(`${at}: failure handlers take no mapper`);
|
|
213
|
+
} else if (node.mapper) {
|
|
214
|
+
input = mapStep(at, s, node.mapper, avail);
|
|
215
|
+
} else {
|
|
216
|
+
for (const [key, e] of entries) {
|
|
217
|
+
const optional = isOptional(e);
|
|
218
|
+
const m = optional ? e.model : isModel(e) ? e : undefined;
|
|
219
|
+
if (!m) {
|
|
220
|
+
problems.push(`${at}: input '${key}' is a plain schema, not a model, so it cannot be matched by model; add a mapper ([step, from => ({ ... })])`);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const producers = [...avail].filter(([, a]) => a.model === m.name);
|
|
224
|
+
if (producers.length === 0) {
|
|
225
|
+
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)" : ""}`);
|
|
226
|
+
} else if (producers.length > 1) {
|
|
227
|
+
problems.push(`${at}: ${m.name} (input '${key}') has several producers (${producers.map(([n]) => n).sort().join(", ")}); use a mapper to pick one`);
|
|
228
|
+
} else {
|
|
229
|
+
const [producer, a] = producers[0]!;
|
|
230
|
+
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})`);
|
|
231
|
+
input[key] = { ref: producer };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
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`);
|
|
238
|
+
const secretNames = s.secrets.map((x) => useSecret(x, at)).sort();
|
|
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) {
|
|
244
|
+
try {
|
|
245
|
+
settings = canonicalize(recordConfigRefs(s.settings ?? {})) as Record<string, unknown>;
|
|
246
|
+
} catch (e) {
|
|
247
|
+
settingsProblem = (e as Error).message;
|
|
248
|
+
settings = {};
|
|
249
|
+
}
|
|
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);
|
|
277
|
+
} else {
|
|
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`);
|
|
281
|
+
}
|
|
282
|
+
if (typeof s.description !== "string") problems.push(`${at}: description must be a string (it may be empty)`);
|
|
283
|
+
if (s.timeoutSeconds !== undefined && !(Number.isInteger(s.timeoutSeconds) && s.timeoutSeconds > 0)) problems.push(`${at}: timeoutSeconds must be a positive integer`);
|
|
284
|
+
if (s.retries !== undefined && !(Number.isInteger(s.retries) && s.retries >= 0)) problems.push(`${at}: retries must be a non-negative integer`);
|
|
285
|
+
|
|
286
|
+
const out: StepDescriptorT = {
|
|
287
|
+
name: s.name,
|
|
288
|
+
description: typeof s.description === "string" ? s.description : "",
|
|
289
|
+
kind: s.kind,
|
|
290
|
+
image: s.image,
|
|
291
|
+
inputTypes,
|
|
292
|
+
input,
|
|
293
|
+
output: s.output ? useModel(s.output, `${at} output`) : null,
|
|
294
|
+
secrets: secretNames,
|
|
295
|
+
handlerHash: handlerHash || "sha256:" + "0".repeat(64),
|
|
296
|
+
...(s.timeoutSeconds !== undefined ? { timeoutSeconds: s.timeoutSeconds } : {}),
|
|
297
|
+
...(s.retries !== undefined ? { retries: s.retries } : {}),
|
|
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 } : {}),
|
|
301
|
+
};
|
|
302
|
+
if (s.clusterAccess !== undefined && typeof s.clusterAccess !== "boolean") problems.push(`${at}: clusterAccess must be a boolean`);
|
|
303
|
+
if (s.output && !isModel(s.output)) problems.push(`${at}: output must be a model`);
|
|
304
|
+
return out;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const mapStep = (at: string, s: AnyStep, mapper: MapperFunction, avail: Map<string, Avail>): Record<string, Mapping> => {
|
|
308
|
+
try {
|
|
309
|
+
analyzeMapperSource(Function.prototype.toString.call(mapper));
|
|
310
|
+
} catch (e) {
|
|
311
|
+
if (e instanceof MapperError) problems.push(`${at}: ${e.message}`);
|
|
312
|
+
else throw e;
|
|
313
|
+
return {};
|
|
314
|
+
}
|
|
315
|
+
const roots = new Set([...avail].filter(([, a]) => a.model !== null).map(([n]) => n));
|
|
316
|
+
let recorded: Record<string, Mapping>;
|
|
317
|
+
try {
|
|
318
|
+
recorded = recordMapper(mapper, roots);
|
|
319
|
+
} catch (e) {
|
|
320
|
+
if (e instanceof MapperError) problems.push(`${at}: ${e.message}`);
|
|
321
|
+
else problems.push(`${at}: mapper threw: ${(e as Error)?.message ?? String(e)}`);
|
|
322
|
+
return {};
|
|
323
|
+
}
|
|
324
|
+
const declared = s.input as Record<string, unknown>;
|
|
325
|
+
for (const k of Object.keys(declared)) if (!(k in recorded)) problems.push(`${at}: mapper does not provide input '${k}'`);
|
|
326
|
+
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`);
|
|
327
|
+
for (const [k, m] of Object.entries(recorded)) checkMapping(`${at} input '${k}'`, m, avail, isOptional(declared[k]), true);
|
|
328
|
+
return recorded;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const checkMapping = (at: string, m: Mapping, avail: Map<string, Avail>, optionalTarget: boolean, topLevel: boolean) => {
|
|
332
|
+
const keys = Object.keys(m);
|
|
333
|
+
if (keys.length === 1 && keys[0] === "ref" && typeof (m as { ref: unknown }).ref === "string") {
|
|
334
|
+
const [root, ...path] = (m as { ref: string }).ref.split(".") as [string, ...string[]];
|
|
335
|
+
const a = avail.get(root);
|
|
336
|
+
if (!a) return problems.push(`${at}: '${root}' is not available at this stage`);
|
|
337
|
+
if (!resolveField(outputViewOf(a.model), path)) problems.push(`${at}: '${(m as { ref: string }).ref}' — model ${a.model} has no field '${path.join(".")}'`);
|
|
338
|
+
if (a.maybe && !(optionalTarget && topLevel))
|
|
339
|
+
problems.push(`${at}: '${(m as { ref: string }).ref}' is only maybe-available (produced inside a when block); map it whole into an optional(...) input`);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (keys.length === 1 && keys[0] === "const") return;
|
|
343
|
+
for (const [k, x] of Object.entries(m as Record<string, Mapping>)) checkMapping(`${at}.${k}`, x, avail, optionalTarget, false);
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// ── stages ──
|
|
347
|
+
let selects = 0;
|
|
348
|
+
const walk = (nodes: readonly StageNode[], avail: Map<string, Avail>, cases: readonly CaseOf[] = []): Stage[] => {
|
|
349
|
+
const out: Stage[] = [];
|
|
350
|
+
for (const n of nodes) {
|
|
351
|
+
if (n.kind === "select") {
|
|
352
|
+
out.push(describeSelect(n, avail, cases, ++selects));
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (n.kind === "stage") {
|
|
356
|
+
const descs = n.steps.map((ss) => {
|
|
357
|
+
if (!isStep(ss.step)) {
|
|
358
|
+
problems.push("a stage entry is not a step");
|
|
359
|
+
return undefined;
|
|
360
|
+
}
|
|
361
|
+
registerStep(ss.step, "stage", cases);
|
|
362
|
+
return describeStep(ss, avail, false, cases);
|
|
363
|
+
}).filter((d): d is StepDescriptorT => d !== undefined);
|
|
364
|
+
// Outputs become available only after the barrier (same-stage siblings can't see each other).
|
|
365
|
+
for (const ss of n.steps) if (isStep(ss.step)) avail.set(ss.step.name, { model: ss.step.output?.name ?? null, maybe: false });
|
|
366
|
+
if (descs.length) out.push({ steps: descs });
|
|
367
|
+
} else {
|
|
368
|
+
const when: Record<string, (string | number | boolean)[]> = {};
|
|
369
|
+
const props = (inputOutputView()?.properties ?? {}) as Record<string, JsonSchema>;
|
|
370
|
+
for (const [field, raw] of Object.entries(n.cond)) {
|
|
371
|
+
const values = (Array.isArray(raw) ? raw : [raw]) as (string | number | boolean)[];
|
|
372
|
+
if (!Object.hasOwn(props, field)) problems.push(`when: '${field}' is not a field of the pipeline input ${inputModel.name}`);
|
|
373
|
+
else {
|
|
374
|
+
const literals = literalValues(props[field]);
|
|
375
|
+
if (!literals) problems.push(`when: '${field}' is not a literal-like field (enum, boolean or literal)`);
|
|
376
|
+
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(", ")})`);
|
|
377
|
+
}
|
|
378
|
+
if (values.length === 0) problems.push(`when: '${field}' has no values`);
|
|
379
|
+
when[field] = values;
|
|
380
|
+
}
|
|
381
|
+
const inner = new Map(avail);
|
|
382
|
+
const stages = walk(n.nodes, inner, cases);
|
|
383
|
+
for (const [name, a] of inner) if (!avail.has(name)) avail.set(name, { model: a.model, maybe: true });
|
|
384
|
+
out.push({ when, stages });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return out;
|
|
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
|
+
};
|
|
419
|
+
const stages = walk(data.nodes, new Map([["input", { model: inputModel.name, maybe: false }]]));
|
|
420
|
+
|
|
421
|
+
// ── failure handlers ──
|
|
422
|
+
const stageStepNames = new Set(steps.keys());
|
|
423
|
+
const seenHandlers = new Map<string, AnyStep>();
|
|
424
|
+
const failureHandlers = data.failureHandlers.map((fh) => {
|
|
425
|
+
if (!isStep(fh.step)) {
|
|
426
|
+
problems.push("onFailure: handler is not a step");
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
if (seenHandlers.get(fh.step.name) !== fh.step) registerStep(fh.step, "onFailure");
|
|
430
|
+
seenHandlers.set(fh.step.name, fh.step);
|
|
431
|
+
const scope = fh.steps === null ? null : fh.steps.map((x) => x.name);
|
|
432
|
+
for (const n of scope ?? []) if (!stageStepNames.has(n)) problems.push(`onFailure(${fh.step.name}): '${n}' is not a step of this pipeline`);
|
|
433
|
+
return { step: describeStep({ step: fh.step }, new Map(), true), steps: scope };
|
|
434
|
+
}).filter((x) => x !== undefined);
|
|
435
|
+
|
|
436
|
+
// ── handlers without a step ──
|
|
437
|
+
for (const h of handlerNames) {
|
|
438
|
+
const s = steps.get(h);
|
|
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`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ── triggers ──
|
|
444
|
+
const triggerNames = new Set<string>();
|
|
445
|
+
const triggers: TriggerT[] = data.triggers.map((t) => {
|
|
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)"}`);
|
|
447
|
+
triggerNames.add(t.name);
|
|
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
|
+
}
|
|
456
|
+
if (!STEP_NAME.test(t.name)) problems.push(`poll trigger name '${t.name}' must be lower-case kebab`);
|
|
457
|
+
if (!(Number.isInteger(t.intervalSeconds) && t.intervalSeconds > 0)) problems.push(`poll '${t.name}': intervalSeconds must be a positive integer`);
|
|
458
|
+
const declared = new Set(t.secrets.map((s) => useSecret(s, `poll '${t.name}'`)));
|
|
459
|
+
for (const [h, v] of Object.entries(t.headers)) {
|
|
460
|
+
for (const m of v.matchAll(/\$SECRET:([A-Za-z0-9_]+)/g))
|
|
461
|
+
if (!declared.has(m[1]!)) problems.push(`poll '${t.name}': header '${h}' references secret '${m[1]}', which is not in the poll's secrets`);
|
|
462
|
+
}
|
|
463
|
+
return { kind: "poll", name: t.name, url: t.url, headers: { ...t.headers }, secrets: [...declared].sort(), valuePath: t.valuePath, intervalSeconds: t.intervalSeconds };
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
if (problems.length) throw new DescribeError(problems);
|
|
467
|
+
|
|
468
|
+
const manifest = canonicalize({
|
|
469
|
+
apiVersion: API_VERSION,
|
|
470
|
+
name: data.name,
|
|
471
|
+
description: data.description,
|
|
472
|
+
input: inputModel.name,
|
|
473
|
+
harness: opts.harness,
|
|
474
|
+
models: Object.fromEntries([...models].map(([k, v]) => [k, v.schemas])),
|
|
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
|
+
: {}),
|
|
483
|
+
triggers,
|
|
484
|
+
stages,
|
|
485
|
+
failureHandlers,
|
|
486
|
+
});
|
|
487
|
+
const checked = Manifest.safeParse(manifest);
|
|
488
|
+
if (!checked.success) throw new DescribeError([`internal: manifest does not match its schema: ${z.prettifyError(checked.error)}`]);
|
|
489
|
+
return manifest as ManifestT;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Canonical manifest text (what `describe` prints and the service hashes). */
|
|
493
|
+
export const manifestText = (m: ManifestT): string => canonicalJson(m);
|
|
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
|
+
|
|
563
|
+
function camel(name: string): string {
|
|
564
|
+
return name.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
|
|
565
|
+
}
|