@olivervea/pipelines 2.0.0-beta.3 → 2.0.0-beta.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +163 -78
- package/manifest.schema.json +300 -16
- package/package.json +6 -4
- package/src/assets.d.ts +2 -2
- package/src/build/build.ts +98 -64
- package/src/build/cli.ts +33 -23
- package/src/build/elf.ts +16 -7
- package/src/build/hashes.ts +40 -51
- package/src/build/project.ts +55 -52
- package/src/builder/pipeline.ts +348 -89
- package/src/builder/refs.ts +15 -5
- package/src/core/brands.ts +6 -5
- package/src/core/config-refs.ts +103 -0
- package/src/core/config.ts +224 -0
- package/src/core/external.ts +86 -0
- package/src/core/handlers.ts +62 -15
- package/src/core/matrix.ts +150 -0
- package/src/core/models.ts +37 -15
- package/src/core/secrets.ts +2 -2
- package/src/core/steps.ts +113 -38
- package/src/core/types.ts +5 -1
- package/src/core/webhooks.ts +21 -7
- package/src/ctx/files.ts +119 -42
- package/src/ctx/index.ts +103 -38
- package/src/ctx/links.ts +30 -0
- package/src/ctx/sh.ts +104 -44
- package/src/ctx/types.ts +24 -4
- package/src/describe/config-schema.ts +98 -0
- package/src/describe/context.ts +187 -0
- package/src/describe/describe.ts +63 -355
- package/src/describe/failure-handlers.ts +34 -0
- package/src/describe/manifest-schema.ts +304 -61
- package/src/describe/mapper-source.ts +252 -105
- package/src/describe/record.ts +67 -37
- package/src/describe/schemas.ts +71 -0
- package/src/describe/stages.ts +166 -0
- package/src/describe/step.ts +544 -0
- package/src/describe/triggers.ts +72 -0
- package/src/dev/cli.ts +62 -35
- package/src/harness/env.ts +47 -25
- package/src/harness/execute.ts +74 -30
- package/src/harness/exit.ts +1 -1
- package/src/harness/index.ts +19 -8
- package/src/harness/main.ts +234 -98
- package/src/harness/result.ts +45 -24
- package/src/harness/validate.ts +82 -15
- package/src/index.ts +139 -10
- package/src/library/checks.ts +11 -6
- package/src/library/compose-deploy.ts +115 -0
- package/src/library/github-source.ts +100 -47
- package/src/library/helm-deploy-ssh.ts +426 -112
- package/src/library/helm-deploy.ts +529 -0
- package/src/library/http-health.ts +7 -5
- package/src/library/image-build.ts +510 -162
- package/src/library/index.ts +48 -14
- package/src/library/models.ts +35 -14
- package/src/library/ssh.ts +44 -18
- package/src/testing/index.ts +100 -35
- package/src/util/canonical.ts +7 -4
- package/src/util/code-hash.ts +17 -0
- package/src/util/hash.ts +5 -5
- package/src/util/tar.ts +83 -38
- package/src/library/script.ts +0 -85
- package/src/util/handler-hash.ts +0 -22
package/src/describe/describe.ts
CHANGED
|
@@ -1,386 +1,94 @@
|
|
|
1
|
-
// `describe`: evaluates the
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
1
|
+
// `describe`: evaluates the pipeline into the manifest and runs every describe-time check. All problems are collected and reported together.
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { isModel } from '../core/models.ts';
|
|
4
|
+
import { pipelineData, type RetentionPolicy } from '../builder/pipeline.ts';
|
|
5
|
+
import { canonicalize, canonicalJson } from '../util/canonical.ts';
|
|
6
|
+
import type { HarnessInfo } from '../util/code-hash.ts';
|
|
7
|
+
import { API_VERSION, Manifest, type ManifestT } from './manifest-schema.ts';
|
|
8
|
+
import { DescribeContext } from './context.ts';
|
|
9
|
+
import { describeStages } from './stages.ts';
|
|
10
|
+
import { describeFailureHandlers } from './failure-handlers.ts';
|
|
11
|
+
import { describeTriggers } from './triggers.ts';
|
|
12
|
+
|
|
13
|
+
export { FAILURE_ROOT } from './context.ts';
|
|
14
|
+
export { schemaViews } from './schemas.ts';
|
|
15
|
+
export { recordedConfigRefs, secretRefsOf, subSchemas } from './config-schema.ts';
|
|
14
16
|
|
|
15
17
|
export class DescribeError extends Error {
|
|
16
|
-
override name =
|
|
18
|
+
override name = 'DescribeError';
|
|
17
19
|
constructor(readonly problems: readonly string[]) {
|
|
18
|
-
super(
|
|
20
|
+
super(
|
|
21
|
+
`describe found ${problems.length} problem(s):\n${problems.map((p) => ` - ${p}`).join('\n')}`,
|
|
22
|
+
);
|
|
19
23
|
}
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
export interface DescribeOptions {
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
/** Step name → handler hash (from the build; includes the harness identity). */
|
|
26
|
-
handlerHashes: Readonly<Record<string, string>>;
|
|
27
|
+
/** Hash of the build (from the build; includes the harness identity). */
|
|
28
|
+
codeHash?: string;
|
|
27
29
|
harness: HarnessInfo;
|
|
28
30
|
}
|
|
29
31
|
|
|
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
32
|
export function describePipeline(pipeline: unknown, opts: DescribeOptions): ManifestT {
|
|
98
33
|
const data = pipelineData(pipeline);
|
|
99
|
-
const
|
|
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
|
-
};
|
|
34
|
+
const ctx = new DescribeContext(data.problems);
|
|
139
35
|
|
|
140
36
|
const inputModel = data.input;
|
|
141
|
-
if (!isModel(inputModel))
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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 (add a handlers/*.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
|
-
// ── handlers without a step ──
|
|
333
|
-
for (const h of handlerNames) {
|
|
334
|
-
const s = steps.get(h);
|
|
335
|
-
if (!s) problems.push(`a handler implements step '${h}', which is not in the pipeline`);
|
|
336
|
-
else if (s.kind !== "custom") problems.push(`a handler implements '${h}', a library step (${s.kind}) that carries its own implementation`);
|
|
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);
|
|
37
|
+
if (!isModel(inputModel))
|
|
38
|
+
throw new DescribeError([
|
|
39
|
+
'pipeline input must be a model (model("Name", z.object({...})))',
|
|
40
|
+
]);
|
|
41
|
+
ctx.useModel(inputModel, 'pipeline input');
|
|
42
|
+
|
|
43
|
+
const stages = describeStages(
|
|
44
|
+
ctx,
|
|
45
|
+
inputModel,
|
|
46
|
+
data.nodes,
|
|
47
|
+
new Map([['input', { model: inputModel.name, maybe: false }]]),
|
|
48
|
+
);
|
|
49
|
+
const failureHandlers = describeFailureHandlers(ctx, data.failureHandlers);
|
|
50
|
+
const triggers = describeTriggers(ctx, data.triggers);
|
|
51
|
+
const retention = describeRetention(ctx, data.retention);
|
|
52
|
+
|
|
53
|
+
if (ctx.problems.length) throw new DescribeError(ctx.problems);
|
|
363
54
|
|
|
364
55
|
const manifest = canonicalize({
|
|
365
56
|
apiVersion: API_VERSION,
|
|
366
57
|
name: data.name,
|
|
367
58
|
description: data.description,
|
|
368
59
|
input: inputModel.name,
|
|
60
|
+
...(Object.keys(retention).length ? { retention } : {}),
|
|
369
61
|
harness: opts.harness,
|
|
370
|
-
|
|
371
|
-
|
|
62
|
+
codeHash: opts.codeHash ?? 'sha256:' + '0'.repeat(64),
|
|
63
|
+
models: ctx.modelsSection(),
|
|
64
|
+
secrets: ctx.secretsSection(),
|
|
65
|
+
...ctx.configSection(),
|
|
372
66
|
triggers,
|
|
373
67
|
stages,
|
|
374
68
|
failureHandlers,
|
|
375
69
|
});
|
|
376
70
|
const checked = Manifest.safeParse(manifest);
|
|
377
|
-
if (!checked.success)
|
|
71
|
+
if (!checked.success)
|
|
72
|
+
throw new DescribeError([
|
|
73
|
+
`internal: manifest does not match its schema: ${z.prettifyError(checked.error)}`,
|
|
74
|
+
]);
|
|
378
75
|
return manifest as ManifestT;
|
|
379
76
|
}
|
|
380
77
|
|
|
78
|
+
function describeRetention(
|
|
79
|
+
ctx: DescribeContext,
|
|
80
|
+
policy: RetentionPolicy | undefined,
|
|
81
|
+
): Record<string, number> {
|
|
82
|
+
const retention: Record<string, number> = {};
|
|
83
|
+
for (const key of ['keepLatestRuns', 'minRunAgeDays', 'buildGraceDays'] as const) {
|
|
84
|
+
const v = policy?.[key];
|
|
85
|
+
if (v === undefined) continue;
|
|
86
|
+
if (!(Number.isInteger(v) && v >= 0))
|
|
87
|
+
ctx.problems.push(`retention.${key} must be a non-negative integer`);
|
|
88
|
+
else retention[key] = v;
|
|
89
|
+
}
|
|
90
|
+
return retention;
|
|
91
|
+
}
|
|
92
|
+
|
|
381
93
|
/** Canonical manifest text (what `describe` prints and the service hashes). */
|
|
382
94
|
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,34 @@
|
|
|
1
|
+
import { isStep, type AnyStep } from '../core/steps.ts';
|
|
2
|
+
import type { FailureHandlerNode } from '../builder/pipeline.ts';
|
|
3
|
+
import type { ManifestT } from './manifest-schema.ts';
|
|
4
|
+
import type { DescribeContext } from './context.ts';
|
|
5
|
+
import { describeStep } from './step.ts';
|
|
6
|
+
|
|
7
|
+
/** Describes the `onFailure` handlers; call after the stages, so their scopes can name the stage steps. */
|
|
8
|
+
export function describeFailureHandlers(
|
|
9
|
+
ctx: DescribeContext,
|
|
10
|
+
handlers: readonly FailureHandlerNode[],
|
|
11
|
+
): ManifestT['failureHandlers'] {
|
|
12
|
+
const stageStepNames = new Set(ctx.steps.keys());
|
|
13
|
+
const seen = new Map<string, AnyStep>();
|
|
14
|
+
return handlers
|
|
15
|
+
.map((fh) => {
|
|
16
|
+
if (!isStep(fh.step)) {
|
|
17
|
+
ctx.problems.push('onFailure: handler is not a step');
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
if (seen.get(fh.step.name) !== fh.step) ctx.registerStep(fh.step, 'onFailure');
|
|
21
|
+
seen.set(fh.step.name, fh.step);
|
|
22
|
+
const scope = fh.steps === null ? null : fh.steps.map((x) => x.name);
|
|
23
|
+
for (const n of scope ?? [])
|
|
24
|
+
if (!stageStepNames.has(n))
|
|
25
|
+
ctx.problems.push(
|
|
26
|
+
`onFailure(${fh.step.name}): '${n}' is not a step of this pipeline`,
|
|
27
|
+
);
|
|
28
|
+
return {
|
|
29
|
+
step: describeStep(ctx, { step: fh.step }, new Map(), true),
|
|
30
|
+
steps: scope,
|
|
31
|
+
};
|
|
32
|
+
})
|
|
33
|
+
.filter((x) => x !== undefined);
|
|
34
|
+
}
|