@olivervea/pipelines 2.0.0-beta.2 → 2.0.0-beta.20
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 +125 -62
- package/manifest.schema.json +208 -11
- package/package.json +6 -4
- package/src/assets.d.ts +2 -2
- package/src/build/build.ts +121 -51
- package/src/build/cli.ts +29 -22
- package/src/build/elf.ts +8 -7
- package/src/build/hashes.ts +82 -20
- package/src/build/project.ts +95 -28
- package/src/builder/pipeline.ts +344 -88
- package/src/builder/refs.ts +15 -5
- package/src/core/brands.ts +7 -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 +61 -15
- package/src/core/models.ts +32 -15
- package/src/core/origin.ts +23 -0
- package/src/core/secrets.ts +2 -2
- package/src/core/steps.ts +89 -38
- package/src/core/types.ts +5 -1
- package/src/core/webhooks.ts +21 -7
- package/src/ctx/files.ts +111 -40
- package/src/ctx/index.ts +84 -31
- package/src/ctx/sh.ts +105 -44
- package/src/ctx/types.ts +12 -4
- package/src/describe/describe.ts +701 -133
- package/src/describe/manifest-schema.ts +258 -61
- package/src/describe/mapper-source.ts +249 -104
- package/src/describe/record.ts +67 -37
- package/src/dev/cli.ts +72 -35
- package/src/harness/env.ts +45 -25
- package/src/harness/execute.ts +58 -29
- package/src/harness/index.ts +22 -9
- package/src/harness/main.ts +239 -78
- package/src/harness/result.ts +38 -22
- package/src/harness/validate.ts +77 -15
- package/src/index.ts +124 -11
- package/src/library/checks.ts +28 -0
- package/src/library/compose-deploy.ts +113 -0
- package/src/library/github-source.ts +100 -47
- package/src/library/helm-deploy-ssh.ts +452 -108
- package/src/library/helm-deploy.ts +449 -0
- package/src/library/http-health.ts +7 -5
- package/src/library/image-build.ts +499 -105
- package/src/library/index.ts +48 -12
- package/src/library/models.ts +33 -9
- package/src/library/ssh.ts +67 -0
- package/src/testing/index.ts +74 -36
- package/src/util/canonical.ts +7 -4
- package/src/util/handler-hash.ts +17 -7
- package/src/util/hash.ts +5 -5
- package/src/util/tar.ts +86 -36
- package/src/library/script.ts +0 -85
package/src/describe/describe.ts
CHANGED
|
@@ -1,34 +1,57 @@
|
|
|
1
1
|
// `describe`: evaluates the contracts (models + pipeline, never handlers) into the manifest and runs
|
|
2
2
|
// every describe-time check. All problems are collected and reported together.
|
|
3
|
-
import { z } from
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
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 { isExternal } from '../core/external.ts';
|
|
9
|
+
import { SECRET_NAME, type Secret } from '../core/secrets.ts';
|
|
10
|
+
import { RESERVED_STEP_NAMES, STEP_NAME, isStep, type AnyStep } from '../core/steps.ts';
|
|
11
|
+
import { isImplementation } from '../core/handlers.ts';
|
|
12
|
+
import { recordConfigRefs } from '../core/config-refs.ts';
|
|
13
|
+
import {
|
|
14
|
+
pipelineData,
|
|
15
|
+
type MapperFunction,
|
|
16
|
+
type StageNode,
|
|
17
|
+
type StageStepNode,
|
|
18
|
+
} from '../builder/pipeline.ts';
|
|
19
|
+
import { FailureContext } from '../library/models.ts';
|
|
20
|
+
import { canonicalize, canonicalJson } from '../util/canonical.ts';
|
|
21
|
+
import { libraryHandlerHash, type HarnessInfo } from '../util/handler-hash.ts';
|
|
22
|
+
import { analyzeMapperSource } from './mapper-source.ts';
|
|
23
|
+
import { MapperError, recordMapper, type Mapping } from './record.ts';
|
|
24
|
+
import {
|
|
25
|
+
API_VERSION,
|
|
26
|
+
Manifest,
|
|
27
|
+
type ManifestT,
|
|
28
|
+
type ModelSchemasT,
|
|
29
|
+
type Stage,
|
|
30
|
+
type StepDescriptorT,
|
|
31
|
+
type TriggerT,
|
|
32
|
+
} from './manifest-schema.ts';
|
|
14
33
|
|
|
15
34
|
export class DescribeError extends Error {
|
|
16
|
-
override name =
|
|
35
|
+
override name = 'DescribeError';
|
|
17
36
|
constructor(readonly problems: readonly string[]) {
|
|
18
|
-
super(
|
|
37
|
+
super(
|
|
38
|
+
`describe found ${problems.length} problem(s):\n${problems.map((p) => ` - ${p}`).join('\n')}`,
|
|
39
|
+
);
|
|
19
40
|
}
|
|
20
41
|
}
|
|
21
42
|
|
|
22
43
|
export interface DescribeOptions {
|
|
23
|
-
/** Step names that have a handler
|
|
44
|
+
/** Step names that have a handler (a `handlers/*.ts` default-exporting `implement(step, …)`). */
|
|
24
45
|
handlerNames: Iterable<string>;
|
|
25
46
|
/** Step name → handler hash (from the build; includes the harness identity). */
|
|
26
47
|
handlerHashes: Readonly<Record<string, string>>;
|
|
48
|
+
/** Kind → hash of the code that implements the steps of that kind which carry their implementation (from the build). */
|
|
49
|
+
kindHashes?: Readonly<Record<string, string>>;
|
|
27
50
|
harness: HarnessInfo;
|
|
28
51
|
}
|
|
29
52
|
|
|
30
53
|
/** Root name used in failure-handler mappings (the service supplies FailureContext under it). */
|
|
31
|
-
export const FAILURE_ROOT =
|
|
54
|
+
export const FAILURE_ROOT = 'failure';
|
|
32
55
|
const RESERVED = new Set([...RESERVED_STEP_NAMES, FAILURE_ROOT]);
|
|
33
56
|
|
|
34
57
|
type JsonSchema = Record<string, unknown>;
|
|
@@ -39,9 +62,10 @@ interface Avail {
|
|
|
39
62
|
|
|
40
63
|
function stripStrict(v: unknown): unknown {
|
|
41
64
|
if (Array.isArray(v)) return v.map(stripStrict);
|
|
42
|
-
if (v !== null && typeof v ===
|
|
65
|
+
if (v !== null && typeof v === 'object') {
|
|
43
66
|
const out: Record<string, unknown> = {};
|
|
44
|
-
for (const [k, x] of Object.entries(v))
|
|
67
|
+
for (const [k, x] of Object.entries(v))
|
|
68
|
+
if (!(k === 'additionalProperties' && x === false)) out[k] = stripStrict(x);
|
|
45
69
|
return out;
|
|
46
70
|
}
|
|
47
71
|
return v;
|
|
@@ -49,38 +73,46 @@ function stripStrict(v: unknown): unknown {
|
|
|
49
73
|
|
|
50
74
|
/** JSON Schema views of a zod schema. Throws if it is not representable (dates, transforms, …). */
|
|
51
75
|
export function schemaViews(schema: z.ZodType): ModelSchemasT {
|
|
52
|
-
const view = (io:
|
|
53
|
-
const js = z.toJSONSchema(schema, { io, target:
|
|
76
|
+
const view = (io: 'input' | 'output') => {
|
|
77
|
+
const js = z.toJSONSchema(schema, { io, target: 'draft-2020-12' }) as JsonSchema;
|
|
54
78
|
delete js.$schema; // constant noise; apiVersion pins the dialect
|
|
55
79
|
return canonicalize(js) as JsonSchema;
|
|
56
80
|
};
|
|
57
|
-
const output = view(
|
|
58
|
-
const input = view(
|
|
81
|
+
const output = view('output');
|
|
82
|
+
const input = view('input');
|
|
59
83
|
const same = JSON.stringify(stripStrict(output)) === JSON.stringify(input);
|
|
60
84
|
return same ? { output } : { output, input };
|
|
61
85
|
}
|
|
62
86
|
|
|
63
87
|
/** Follows `segments` through a JSON Schema; undefined if the field doesn't exist. */
|
|
64
|
-
function resolveField(
|
|
88
|
+
function resolveField(
|
|
89
|
+
schema: JsonSchema | undefined,
|
|
90
|
+
segments: readonly string[],
|
|
91
|
+
): boolean {
|
|
65
92
|
if (!schema) return false;
|
|
66
93
|
if (segments.length === 0) return true;
|
|
67
94
|
const [seg, ...rest] = segments as [string, ...string[]];
|
|
68
|
-
if (Array.isArray(schema.anyOf))
|
|
69
|
-
|
|
70
|
-
if (Array.isArray(schema.
|
|
95
|
+
if (Array.isArray(schema.anyOf))
|
|
96
|
+
return (schema.anyOf as JsonSchema[]).some((b) => resolveField(b, segments));
|
|
97
|
+
if (Array.isArray(schema.oneOf))
|
|
98
|
+
return (schema.oneOf as JsonSchema[]).some((b) => resolveField(b, segments));
|
|
99
|
+
if (Array.isArray(schema.allOf))
|
|
100
|
+
return (schema.allOf as JsonSchema[]).some((b) => resolveField(b, segments));
|
|
71
101
|
const props = schema.properties as Record<string, JsonSchema> | undefined;
|
|
72
102
|
if (props && Object.hasOwn(props, seg)) return resolveField(props[seg], rest);
|
|
73
103
|
const extra = schema.additionalProperties;
|
|
74
|
-
if (extra && typeof extra ===
|
|
104
|
+
if (extra && typeof extra === 'object') return resolveField(extra as JsonSchema, rest);
|
|
75
105
|
// A schema without type/properties/anyOf accepts anything (z.unknown / z.any).
|
|
76
|
-
return Object.keys(schema).every(
|
|
106
|
+
return Object.keys(schema).every(
|
|
107
|
+
(k) => k === 'description' || k === 'title' || k === 'default',
|
|
108
|
+
);
|
|
77
109
|
}
|
|
78
110
|
|
|
79
111
|
function literalValues(field: JsonSchema | undefined): unknown[] | undefined {
|
|
80
112
|
if (!field) return undefined;
|
|
81
113
|
if (Array.isArray(field.enum)) return field.enum as unknown[];
|
|
82
114
|
if (field.const !== undefined) return [field.const];
|
|
83
|
-
if (field.type ===
|
|
115
|
+
if (field.type === 'boolean') return [true, false];
|
|
84
116
|
const alts = (field.anyOf ?? field.oneOf) as JsonSchema[] | undefined;
|
|
85
117
|
if (Array.isArray(alts)) {
|
|
86
118
|
const all: unknown[] = [];
|
|
@@ -102,72 +134,220 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
102
134
|
// ── models ──
|
|
103
135
|
const models = new Map<string, { schemas: ModelSchemasT; key: string }>();
|
|
104
136
|
const useModel = (m: AnyModel, at: string): string => {
|
|
105
|
-
if (!MODEL_NAME.test(m.name))
|
|
137
|
+
if (!MODEL_NAME.test(m.name))
|
|
138
|
+
problems.push(`${at}: model name '${m.name}' must match ${MODEL_NAME}`);
|
|
106
139
|
let schemas: ModelSchemasT;
|
|
107
140
|
try {
|
|
108
141
|
schemas = schemaViews(m.schema);
|
|
109
142
|
} catch (e) {
|
|
110
|
-
problems.push(
|
|
143
|
+
problems.push(
|
|
144
|
+
`${at}: model '${m.name}' is not representable as JSON Schema (${(e as Error).message})`,
|
|
145
|
+
);
|
|
111
146
|
return m.name;
|
|
112
147
|
}
|
|
113
148
|
const key = JSON.stringify(schemas);
|
|
114
149
|
const prev = models.get(m.name);
|
|
115
150
|
if (!prev) models.set(m.name, { schemas, key });
|
|
116
|
-
else if (prev.key !== key)
|
|
151
|
+
else if (prev.key !== key)
|
|
152
|
+
problems.push(
|
|
153
|
+
`two different schemas are registered under the model name '${m.name}' (one schema per model name)`,
|
|
154
|
+
);
|
|
117
155
|
return m.name;
|
|
118
156
|
};
|
|
119
157
|
|
|
120
158
|
// ── secrets ──
|
|
121
159
|
const secrets = new Map<string, string>();
|
|
122
160
|
const useSecret = (s: Secret, at: string): string => {
|
|
123
|
-
if (!SECRET_NAME.test(s.name))
|
|
161
|
+
if (!SECRET_NAME.test(s.name))
|
|
162
|
+
problems.push(`${at}: secret name '${s.name}' must match ${SECRET_NAME}`);
|
|
124
163
|
const prev = secrets.get(s.name);
|
|
125
164
|
if (prev === undefined) secrets.set(s.name, s.description);
|
|
126
|
-
else if (prev !== s.description)
|
|
165
|
+
else if (prev !== s.description)
|
|
166
|
+
problems.push(`secret '${s.name}' is declared twice with different descriptions`);
|
|
127
167
|
return s.name;
|
|
128
168
|
};
|
|
129
169
|
|
|
170
|
+
// ── config entries ──
|
|
171
|
+
const configs = new Map<
|
|
172
|
+
string,
|
|
173
|
+
{
|
|
174
|
+
description: string;
|
|
175
|
+
required: boolean;
|
|
176
|
+
schema: JsonSchema;
|
|
177
|
+
key: string;
|
|
178
|
+
secretRefs: string[];
|
|
179
|
+
}
|
|
180
|
+
>();
|
|
181
|
+
const useConfig = (e: unknown, at: string): string | undefined => {
|
|
182
|
+
if (!isConfigEntry(e))
|
|
183
|
+
return void problems.push(
|
|
184
|
+
`${at}: config entries must be config("name", "description", schema)`,
|
|
185
|
+
);
|
|
186
|
+
if (!CONFIG_NAME.test(e.name))
|
|
187
|
+
problems.push(`${at}: config name '${e.name}' must match ${CONFIG_NAME}`);
|
|
188
|
+
let schema: JsonSchema;
|
|
189
|
+
try {
|
|
190
|
+
schema = z.toJSONSchema(e.schema, {
|
|
191
|
+
io: 'input',
|
|
192
|
+
target: 'draft-2020-12',
|
|
193
|
+
}) as JsonSchema;
|
|
194
|
+
delete schema.$schema;
|
|
195
|
+
schema = canonicalize(schema) as JsonSchema;
|
|
196
|
+
} catch (err) {
|
|
197
|
+
problems.push(
|
|
198
|
+
`${at}: config '${e.name}' is not representable as JSON Schema (${(err as Error).message})`,
|
|
199
|
+
);
|
|
200
|
+
return e.name;
|
|
201
|
+
}
|
|
202
|
+
const key = JSON.stringify([e.description, schema]);
|
|
203
|
+
const prev = configs.get(e.name);
|
|
204
|
+
if (!prev)
|
|
205
|
+
configs.set(e.name, {
|
|
206
|
+
description: e.description,
|
|
207
|
+
required: !e.schema.safeParse(undefined).success,
|
|
208
|
+
schema,
|
|
209
|
+
key,
|
|
210
|
+
secretRefs: secretRefsOf(schema),
|
|
211
|
+
});
|
|
212
|
+
else if (prev.key !== key)
|
|
213
|
+
problems.push(
|
|
214
|
+
`two different config entries are named '${e.name}' (one description and schema per config name)`,
|
|
215
|
+
);
|
|
216
|
+
return e.name;
|
|
217
|
+
};
|
|
218
|
+
|
|
130
219
|
// ── step identity ──
|
|
131
220
|
const steps = new Map<string, AnyStep>();
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
221
|
+
const placements = new Map<string, { step: AnyStep; cases: readonly CaseOf[] }[]>();
|
|
222
|
+
// Two places are mutually exclusive when they sit in different cases of the same select.
|
|
223
|
+
const exclusive = (a: readonly CaseOf[], b: readonly CaseOf[]) =>
|
|
224
|
+
a.some((x) => b.some((y) => y.select === x.select && y.value !== x.value));
|
|
225
|
+
const registerStep = (s: AnyStep, where: string, cases: readonly CaseOf[] = []) => {
|
|
226
|
+
if (typeof s.name !== 'string' || !STEP_NAME.test(s.name))
|
|
227
|
+
problems.push(
|
|
228
|
+
`${where}: step name '${String(s.name)}' must be lower-case kebab (${STEP_NAME})`,
|
|
229
|
+
);
|
|
230
|
+
if (RESERVED.has(s.name))
|
|
231
|
+
problems.push(`${where}: '${s.name}' is reserved and cannot be a step name`);
|
|
232
|
+
const prev = placements.get(s.name) ?? [];
|
|
233
|
+
if (prev.some((p) => !exclusive(p.cases, cases)))
|
|
234
|
+
problems.push(`duplicate step name '${s.name}'`);
|
|
235
|
+
else if (prev.length) {
|
|
236
|
+
if (
|
|
237
|
+
prev.some(
|
|
238
|
+
(p) => p.step !== s && !isImplementation(p.step) && !isImplementation(s),
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
problems.push(
|
|
242
|
+
`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`,
|
|
243
|
+
);
|
|
244
|
+
if (prev.some((p) => (p.step.output?.name ?? null) !== (s.output?.name ?? null)))
|
|
245
|
+
problems.push(
|
|
246
|
+
`step '${s.name}': its select cases must give it the same output model`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
placements.set(s.name, [...prev, { step: s, cases }]);
|
|
250
|
+
if (!steps.has(s.name)) steps.set(s.name, s);
|
|
138
251
|
};
|
|
139
252
|
|
|
140
253
|
const inputModel = data.input;
|
|
141
|
-
if (!isModel(inputModel))
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
254
|
+
if (!isModel(inputModel))
|
|
255
|
+
throw new DescribeError([
|
|
256
|
+
'pipeline input must be a model (model("Name", z.object({...})))',
|
|
257
|
+
]);
|
|
258
|
+
useModel(inputModel, 'pipeline input');
|
|
259
|
+
const inputOutputView = (): JsonSchema | undefined =>
|
|
260
|
+
models.get(inputModel.name)?.schemas.output;
|
|
261
|
+
const outputViewOf = (modelName: string | null): JsonSchema | undefined =>
|
|
262
|
+
modelName ? models.get(modelName)?.schemas.output : undefined;
|
|
145
263
|
|
|
146
264
|
// ── one step ──
|
|
147
|
-
const describeStep = (
|
|
265
|
+
const describeStep = (
|
|
266
|
+
node: StageStepNode,
|
|
267
|
+
avail: Map<string, Avail>,
|
|
268
|
+
failureHandler: boolean,
|
|
269
|
+
cases: readonly CaseOf[] = [],
|
|
270
|
+
): StepDescriptorT => {
|
|
148
271
|
const s = node.step;
|
|
149
272
|
const at = `step '${s.name}'`;
|
|
150
|
-
if (typeof s.image !==
|
|
273
|
+
if (typeof s.image !== 'string' || s.image.trim() === '')
|
|
274
|
+
problems.push(`${at}: image must be a non-empty string`);
|
|
151
275
|
|
|
152
|
-
const inputTypes: StepDescriptorT[
|
|
153
|
-
const
|
|
276
|
+
const inputTypes: StepDescriptorT['inputTypes'] = {};
|
|
277
|
+
const externalInputs: NonNullable<StepDescriptorT['externalInputs']> = {};
|
|
278
|
+
const allEntries = Object.entries(s.input as Record<string, unknown>);
|
|
279
|
+
for (const [key, e] of allEntries) {
|
|
280
|
+
if (!isExternal(e)) continue;
|
|
281
|
+
if (failureHandler)
|
|
282
|
+
problems.push(
|
|
283
|
+
`${at}: failure handlers run unattended and cannot wait for input ('${key}')`,
|
|
284
|
+
);
|
|
285
|
+
if (typeof e.title !== 'string' || e.title.trim() === '')
|
|
286
|
+
problems.push(`${at} external input '${key}': title must not be empty`);
|
|
287
|
+
if (e.from.length === 0)
|
|
288
|
+
problems.push(
|
|
289
|
+
`${at} external input '${key}': from must list at least one source`,
|
|
290
|
+
);
|
|
291
|
+
if (new Set(e.from.map((f) => f.kind)).size !== e.from.length)
|
|
292
|
+
problems.push(
|
|
293
|
+
`${at} external input '${key}': each source kind may be listed once`,
|
|
294
|
+
);
|
|
295
|
+
if (e.from.some((f) => f.kind === 'callback') && !/^[A-Za-z0-9_-]+$/.test(key))
|
|
296
|
+
problems.push(
|
|
297
|
+
`${at} external input '${key}': a callback input's key must be letters, digits, '_' or '-' (it is part of the URL)`,
|
|
298
|
+
);
|
|
299
|
+
for (const f of e.from) {
|
|
300
|
+
if (f.kind !== 'poll') continue;
|
|
301
|
+
if (!/^https?:\/\//.test(f.url))
|
|
302
|
+
problems.push(
|
|
303
|
+
`${at} external input '${key}': poll url must be an absolute http(s) URL`,
|
|
304
|
+
);
|
|
305
|
+
if (!Number.isInteger(f.intervalSeconds) || f.intervalSeconds < 10)
|
|
306
|
+
problems.push(
|
|
307
|
+
`${at} external input '${key}': poll intervalSeconds must be an integer of at least 10`,
|
|
308
|
+
);
|
|
309
|
+
if (f.valuePath.trim() === '')
|
|
310
|
+
problems.push(
|
|
311
|
+
`${at} external input '${key}': poll valuePath must not be empty`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
externalInputs[key] = {
|
|
315
|
+
model: useModel(e.model, `${at} external input '${key}'`),
|
|
316
|
+
title: e.title,
|
|
317
|
+
description: e.description,
|
|
318
|
+
from: e.from.map((f) => ({ ...f })),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
const entries = allEntries.filter(([, e]) => !isExternal(e));
|
|
154
322
|
for (const [key, e] of entries) {
|
|
155
|
-
if (isOptional(e))
|
|
156
|
-
|
|
323
|
+
if (isOptional(e))
|
|
324
|
+
inputTypes[key] = {
|
|
325
|
+
model: useModel(e.model, `${at} input '${key}'`),
|
|
326
|
+
optional: true,
|
|
327
|
+
};
|
|
328
|
+
else if (isModel(e))
|
|
329
|
+
inputTypes[key] = { model: useModel(e, `${at} input '${key}'`), optional: false };
|
|
157
330
|
else if (e instanceof z.ZodType) {
|
|
158
331
|
try {
|
|
159
332
|
inputTypes[key] = { schema: schemaViews(e) };
|
|
160
333
|
} catch (err) {
|
|
161
|
-
problems.push(
|
|
334
|
+
problems.push(
|
|
335
|
+
`${at} input '${key}': schema is not representable as JSON Schema (${(err as Error).message})`,
|
|
336
|
+
);
|
|
162
337
|
}
|
|
163
|
-
} else
|
|
338
|
+
} else
|
|
339
|
+
problems.push(
|
|
340
|
+
`${at} input '${key}': must be a model, optional(model) or a zod schema`,
|
|
341
|
+
);
|
|
164
342
|
}
|
|
165
343
|
|
|
166
344
|
let input: Record<string, Mapping> = {};
|
|
167
345
|
if (failureHandler) {
|
|
168
346
|
for (const [key, e] of entries) {
|
|
169
347
|
if (e !== FailureContext && !(isModel(e) && e.name === FailureContext.name))
|
|
170
|
-
problems.push(
|
|
348
|
+
problems.push(
|
|
349
|
+
`${at}: a failure handler's inputs must all be FailureContext (input '${key}' is not)`,
|
|
350
|
+
);
|
|
171
351
|
input[key] = { ref: FAILURE_ROOT };
|
|
172
352
|
}
|
|
173
353
|
if (node.mapper) problems.push(`${at}: failure handlers take no mapper`);
|
|
@@ -178,64 +358,166 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
178
358
|
const optional = isOptional(e);
|
|
179
359
|
const m = optional ? e.model : isModel(e) ? e : undefined;
|
|
180
360
|
if (!m) {
|
|
181
|
-
problems.push(
|
|
361
|
+
problems.push(
|
|
362
|
+
`${at}: input '${key}' is a plain schema, not a model, so it cannot be matched by model; add a mapper ([step, from => ({ ... })])`,
|
|
363
|
+
);
|
|
182
364
|
continue;
|
|
183
365
|
}
|
|
184
366
|
const producers = [...avail].filter(([, a]) => a.model === m.name);
|
|
185
367
|
if (producers.length === 0) {
|
|
186
|
-
problems.push(
|
|
368
|
+
problems.push(
|
|
369
|
+
`${at}: ${m.name} (input '${key}') is not available at this stage${optional ? ' (optional() means maybe-skipped; there must still be an earlier producer)' : ''}`,
|
|
370
|
+
);
|
|
187
371
|
} else if (producers.length > 1) {
|
|
188
|
-
problems.push(
|
|
372
|
+
problems.push(
|
|
373
|
+
`${at}: ${m.name} (input '${key}') has several producers (${producers
|
|
374
|
+
.map(([n]) => n)
|
|
375
|
+
.sort()
|
|
376
|
+
.join(', ')}); use a mapper to pick one`,
|
|
377
|
+
);
|
|
189
378
|
} else {
|
|
190
379
|
const [producer, a] = producers[0]!;
|
|
191
|
-
if (a.maybe && !optional)
|
|
380
|
+
if (a.maybe && !optional)
|
|
381
|
+
problems.push(
|
|
382
|
+
`${at}: ${m.name} (input '${key}') is only maybe-available (produced inside a when block); declare it optional(${m.name})`,
|
|
383
|
+
);
|
|
192
384
|
input[key] = { ref: producer };
|
|
193
385
|
}
|
|
194
386
|
}
|
|
195
387
|
}
|
|
196
388
|
|
|
389
|
+
for (const key of Object.keys(s.input as Record<string, unknown>))
|
|
390
|
+
if (key.startsWith('$'))
|
|
391
|
+
problems.push(`${at}: input '${key}': keys starting with '$' are reserved`);
|
|
197
392
|
const secretNames = s.secrets.map((x) => useSecret(x, at)).sort();
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
393
|
+
const configNames = [
|
|
394
|
+
...new Set(
|
|
395
|
+
(s.config ?? [])
|
|
396
|
+
.map((c) => useConfig(c, at))
|
|
397
|
+
.filter((n): n is string => n !== undefined),
|
|
398
|
+
),
|
|
399
|
+
].sort();
|
|
400
|
+
const carried = isImplementation(s);
|
|
401
|
+
let settings: Record<string, unknown> | undefined;
|
|
402
|
+
let settingsProblem: string | undefined;
|
|
403
|
+
if (s.settings !== undefined || carried) {
|
|
205
404
|
try {
|
|
206
|
-
|
|
405
|
+
settings = canonicalize(recordConfigRefs(s.settings ?? {})) as Record<
|
|
406
|
+
string,
|
|
407
|
+
unknown
|
|
408
|
+
>;
|
|
207
409
|
} catch (e) {
|
|
208
|
-
|
|
209
|
-
|
|
410
|
+
settingsProblem = (e as Error).message;
|
|
411
|
+
settings = {};
|
|
210
412
|
}
|
|
211
|
-
|
|
413
|
+
}
|
|
414
|
+
const refs = settings ? recordedConfigRefs(settings) : [];
|
|
415
|
+
// A step whose settings reference parts of an entry needs the secrets those parts name; any other
|
|
416
|
+
// step receiving an entry needs every secret the entry names.
|
|
417
|
+
for (const name of configNames) {
|
|
418
|
+
const into = refs.filter((r) => r.ref.split('.')[0] === name);
|
|
419
|
+
const needed =
|
|
420
|
+
into.length === 0
|
|
421
|
+
? (configs.get(name)?.secretRefs ?? [])
|
|
422
|
+
: [
|
|
423
|
+
...new Set(
|
|
424
|
+
into.flatMap((r) =>
|
|
425
|
+
secretRefsOf(
|
|
426
|
+
subSchemas(
|
|
427
|
+
configs.get(name)?.schema,
|
|
428
|
+
r.ref.split('.').slice(1),
|
|
429
|
+
r.case,
|
|
430
|
+
name,
|
|
431
|
+
),
|
|
432
|
+
),
|
|
433
|
+
),
|
|
434
|
+
),
|
|
435
|
+
].sort();
|
|
436
|
+
for (const ref of needed)
|
|
437
|
+
if (!secretNames.includes(ref))
|
|
438
|
+
problems.push(
|
|
439
|
+
`${at}: config '${name}' references secret '${ref}' (secretRef), so the step must declare it in secrets`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
let handlerHash: string;
|
|
443
|
+
if (settingsProblem !== undefined)
|
|
444
|
+
problems.push(`${at}: invalid ${s.kind} settings (${settingsProblem})`);
|
|
445
|
+
if (typeof s.kind !== 'string' || s.kind === '')
|
|
446
|
+
problems.push(`${at}: kind must be a non-empty string`);
|
|
447
|
+
if (carried && s.kind === 'custom')
|
|
448
|
+
problems.push(
|
|
449
|
+
`${at} carries its implementation, so it needs a kind (step({ kind: "…", … })); a step with kind custom has its handler in handlers/`,
|
|
450
|
+
);
|
|
451
|
+
for (const { ref, case: kase } of refs) {
|
|
452
|
+
const [entry, ...path] = ref.split('.') as [string, ...string[]];
|
|
453
|
+
if (kase !== undefined && !cases.some((c) => c.cond === kase))
|
|
454
|
+
problems.push(
|
|
455
|
+
`${at}: config reference '${ref}' is narrowed to the select case '${kase}' and can only be used inside it`,
|
|
456
|
+
);
|
|
457
|
+
if (!configNames.includes(entry))
|
|
458
|
+
problems.push(
|
|
459
|
+
`${at}: config reference '${ref}': the step does not receive config entry '${entry}'`,
|
|
460
|
+
);
|
|
461
|
+
else if (!resolveField(configs.get(entry)?.schema, path))
|
|
462
|
+
problems.push(
|
|
463
|
+
`${at}: config reference '${ref}': config '${entry}' has no field '${path.join('.')}'`,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
if (carried) {
|
|
467
|
+
const code = opts.kindHashes?.[s.kind];
|
|
468
|
+
if (opts.kindHashes && !code)
|
|
469
|
+
problems.push(`${at}: no hash was computed for the code of kind '${s.kind}'`);
|
|
470
|
+
handlerHash = libraryHandlerHash(s.kind, settings, opts.harness, code);
|
|
212
471
|
} else {
|
|
213
|
-
|
|
214
|
-
|
|
472
|
+
if (!handlerNames.has(s.name))
|
|
473
|
+
problems.push(
|
|
474
|
+
`${at} has no handler (add a handlers/*.ts default-exporting implement(${camel(s.name)}, …))`,
|
|
475
|
+
);
|
|
476
|
+
handlerHash = opts.handlerHashes[s.name] ?? '';
|
|
477
|
+
if (handlerNames.has(s.name) && !handlerHash)
|
|
478
|
+
problems.push(`${at}: no handler hash was computed`);
|
|
215
479
|
}
|
|
216
|
-
if (typeof s.description !==
|
|
217
|
-
|
|
218
|
-
if (
|
|
480
|
+
if (typeof s.description !== 'string')
|
|
481
|
+
problems.push(`${at}: description must be a string (it may be empty)`);
|
|
482
|
+
if (
|
|
483
|
+
s.timeoutSeconds !== undefined &&
|
|
484
|
+
!(Number.isInteger(s.timeoutSeconds) && s.timeoutSeconds > 0)
|
|
485
|
+
)
|
|
486
|
+
problems.push(`${at}: timeoutSeconds must be a positive integer`);
|
|
487
|
+
if (s.retries !== undefined && !(Number.isInteger(s.retries) && s.retries >= 0))
|
|
488
|
+
problems.push(`${at}: retries must be a non-negative integer`);
|
|
219
489
|
|
|
220
490
|
const out: StepDescriptorT = {
|
|
221
491
|
name: s.name,
|
|
222
|
-
description: typeof s.description ===
|
|
492
|
+
description: typeof s.description === 'string' ? s.description : '',
|
|
223
493
|
kind: s.kind,
|
|
224
494
|
image: s.image,
|
|
225
495
|
inputTypes,
|
|
226
496
|
input,
|
|
227
497
|
output: s.output ? useModel(s.output, `${at} output`) : null,
|
|
228
498
|
secrets: secretNames,
|
|
229
|
-
handlerHash: handlerHash ||
|
|
499
|
+
handlerHash: handlerHash || 'sha256:' + '0'.repeat(64),
|
|
230
500
|
...(s.timeoutSeconds !== undefined ? { timeoutSeconds: s.timeoutSeconds } : {}),
|
|
231
501
|
...(s.retries !== undefined ? { retries: s.retries } : {}),
|
|
232
|
-
...(
|
|
502
|
+
...(settings !== undefined
|
|
503
|
+
? { config: settings as Record<string, z.core.util.JSONType> }
|
|
504
|
+
: {}),
|
|
505
|
+
...(configNames.length ? { configEntries: configNames } : {}),
|
|
506
|
+
...(s.clusterAccess === true ? { clusterAccess: true as const } : {}),
|
|
507
|
+
...(Object.keys(externalInputs).length ? { externalInputs } : {}),
|
|
233
508
|
};
|
|
509
|
+
if (s.clusterAccess !== undefined && typeof s.clusterAccess !== 'boolean')
|
|
510
|
+
problems.push(`${at}: clusterAccess must be a boolean`);
|
|
234
511
|
if (s.output && !isModel(s.output)) problems.push(`${at}: output must be a model`);
|
|
235
512
|
return out;
|
|
236
513
|
};
|
|
237
514
|
|
|
238
|
-
const mapStep = (
|
|
515
|
+
const mapStep = (
|
|
516
|
+
at: string,
|
|
517
|
+
s: AnyStep,
|
|
518
|
+
mapper: MapperFunction,
|
|
519
|
+
avail: Map<string, Avail>,
|
|
520
|
+
): Record<string, Mapping> => {
|
|
239
521
|
try {
|
|
240
522
|
analyzeMapperSource(Function.prototype.toString.call(mapper));
|
|
241
523
|
} catch (e) {
|
|
@@ -252,113 +534,281 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
252
534
|
else problems.push(`${at}: mapper threw: ${(e as Error)?.message ?? String(e)}`);
|
|
253
535
|
return {};
|
|
254
536
|
}
|
|
255
|
-
const declared =
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
537
|
+
const declared = Object.fromEntries(
|
|
538
|
+
Object.entries(s.input as Record<string, unknown>).filter(
|
|
539
|
+
([, e]) => !isExternal(e),
|
|
540
|
+
),
|
|
541
|
+
);
|
|
542
|
+
for (const k of Object.keys(declared))
|
|
543
|
+
if (!(k in recorded)) problems.push(`${at}: mapper does not provide input '${k}'`);
|
|
544
|
+
for (const k of Object.keys(recorded))
|
|
545
|
+
if (!(k in declared))
|
|
546
|
+
problems.push(`${at}: mapper provides '${k}', which is not an input of the step`);
|
|
547
|
+
for (const [k, m] of Object.entries(recorded))
|
|
548
|
+
checkMapping(`${at} input '${k}'`, m, avail, isOptional(declared[k]), true);
|
|
259
549
|
return recorded;
|
|
260
550
|
};
|
|
261
551
|
|
|
262
|
-
const checkMapping = (
|
|
552
|
+
const checkMapping = (
|
|
553
|
+
at: string,
|
|
554
|
+
m: Mapping,
|
|
555
|
+
avail: Map<string, Avail>,
|
|
556
|
+
optionalTarget: boolean,
|
|
557
|
+
topLevel: boolean,
|
|
558
|
+
) => {
|
|
263
559
|
const keys = Object.keys(m);
|
|
264
|
-
if (
|
|
265
|
-
|
|
560
|
+
if (
|
|
561
|
+
keys.length === 1 &&
|
|
562
|
+
keys[0] === 'ref' &&
|
|
563
|
+
typeof (m as { ref: unknown }).ref === 'string'
|
|
564
|
+
) {
|
|
565
|
+
const [root, ...path] = (m as { ref: string }).ref.split('.') as [
|
|
566
|
+
string,
|
|
567
|
+
...string[],
|
|
568
|
+
];
|
|
266
569
|
const a = avail.get(root);
|
|
267
570
|
if (!a) return problems.push(`${at}: '${root}' is not available at this stage`);
|
|
268
|
-
if (!resolveField(outputViewOf(a.model), path))
|
|
571
|
+
if (!resolveField(outputViewOf(a.model), path))
|
|
572
|
+
problems.push(
|
|
573
|
+
`${at}: '${(m as { ref: string }).ref}' — model ${a.model} has no field '${path.join('.')}'`,
|
|
574
|
+
);
|
|
269
575
|
if (a.maybe && !(optionalTarget && topLevel))
|
|
270
|
-
problems.push(
|
|
576
|
+
problems.push(
|
|
577
|
+
`${at}: '${(m as { ref: string }).ref}' is only maybe-available (produced inside a when block); map it whole into an optional(...) input`,
|
|
578
|
+
);
|
|
271
579
|
return;
|
|
272
580
|
}
|
|
273
|
-
if (keys.length === 1 && keys[0] ===
|
|
274
|
-
for (const [k, x] of Object.entries(m as Record<string, Mapping>))
|
|
581
|
+
if (keys.length === 1 && keys[0] === 'const') return;
|
|
582
|
+
for (const [k, x] of Object.entries(m as Record<string, Mapping>))
|
|
583
|
+
checkMapping(`${at}.${k}`, x, avail, optionalTarget, false);
|
|
275
584
|
};
|
|
276
585
|
|
|
277
586
|
// ── stages ──
|
|
278
|
-
|
|
587
|
+
let selects = 0;
|
|
588
|
+
const walk = (
|
|
589
|
+
nodes: readonly StageNode[],
|
|
590
|
+
avail: Map<string, Avail>,
|
|
591
|
+
cases: readonly CaseOf[] = [],
|
|
592
|
+
): Stage[] => {
|
|
279
593
|
const out: Stage[] = [];
|
|
280
594
|
for (const n of nodes) {
|
|
281
|
-
if (n.kind ===
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
595
|
+
if (n.kind === 'select') {
|
|
596
|
+
out.push(describeSelect(n, avail, cases, ++selects));
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (n.kind === 'stage') {
|
|
600
|
+
const descs = n.steps
|
|
601
|
+
.map((ss) => {
|
|
602
|
+
if (!isStep(ss.step)) {
|
|
603
|
+
problems.push('a stage entry is not a step');
|
|
604
|
+
return undefined;
|
|
605
|
+
}
|
|
606
|
+
registerStep(ss.step, 'stage', cases);
|
|
607
|
+
return describeStep(ss, avail, false, cases);
|
|
608
|
+
})
|
|
609
|
+
.filter((d): d is StepDescriptorT => d !== undefined);
|
|
290
610
|
// Outputs become available only after the barrier (same-stage siblings can't see each other).
|
|
291
|
-
for (const ss of n.steps)
|
|
611
|
+
for (const ss of n.steps)
|
|
612
|
+
if (isStep(ss.step))
|
|
613
|
+
avail.set(ss.step.name, {
|
|
614
|
+
model: ss.step.output?.name ?? null,
|
|
615
|
+
maybe: false,
|
|
616
|
+
});
|
|
292
617
|
if (descs.length) out.push({ steps: descs });
|
|
293
618
|
} else {
|
|
294
619
|
const when: Record<string, (string | number | boolean)[]> = {};
|
|
295
620
|
const props = (inputOutputView()?.properties ?? {}) as Record<string, JsonSchema>;
|
|
296
621
|
for (const [field, raw] of Object.entries(n.cond)) {
|
|
297
|
-
const values = (Array.isArray(raw) ? raw : [raw]) as (
|
|
298
|
-
|
|
622
|
+
const values = (Array.isArray(raw) ? raw : [raw]) as (
|
|
623
|
+
string | number | boolean
|
|
624
|
+
)[];
|
|
625
|
+
if (!Object.hasOwn(props, field))
|
|
626
|
+
problems.push(
|
|
627
|
+
`when: '${field}' is not a field of the pipeline input ${inputModel.name}`,
|
|
628
|
+
);
|
|
299
629
|
else {
|
|
300
630
|
const literals = literalValues(props[field]);
|
|
301
|
-
if (!literals)
|
|
302
|
-
|
|
631
|
+
if (!literals)
|
|
632
|
+
problems.push(
|
|
633
|
+
`when: '${field}' is not a literal-like field (enum, boolean or literal)`,
|
|
634
|
+
);
|
|
635
|
+
else
|
|
636
|
+
for (const v of values)
|
|
637
|
+
if (!literals.includes(v))
|
|
638
|
+
problems.push(
|
|
639
|
+
`when: ${JSON.stringify(v)} is not a valid value of '${field}' (${literals.map((l) => JSON.stringify(l)).join(', ')})`,
|
|
640
|
+
);
|
|
303
641
|
}
|
|
304
642
|
if (values.length === 0) problems.push(`when: '${field}' has no values`);
|
|
305
643
|
when[field] = values;
|
|
306
644
|
}
|
|
307
645
|
const inner = new Map(avail);
|
|
308
|
-
const stages = walk(n.nodes, inner);
|
|
309
|
-
for (const [name, a] of inner)
|
|
646
|
+
const stages = walk(n.nodes, inner, cases);
|
|
647
|
+
for (const [name, a] of inner)
|
|
648
|
+
if (!avail.has(name)) avail.set(name, { model: a.model, maybe: true });
|
|
310
649
|
out.push({ when, stages });
|
|
311
650
|
}
|
|
312
651
|
}
|
|
313
652
|
return out;
|
|
314
653
|
};
|
|
315
|
-
const
|
|
654
|
+
const describeSelect = (
|
|
655
|
+
n: Extract<StageNode, { kind: 'select' }>,
|
|
656
|
+
avail: Map<string, Avail>,
|
|
657
|
+
cases: readonly CaseOf[],
|
|
658
|
+
id: number,
|
|
659
|
+
): Stage => {
|
|
660
|
+
if (!isConfigRef(n.on)) {
|
|
661
|
+
problems.push('select: the first argument must be a config reference');
|
|
662
|
+
return { select: '', discriminator: '', cases: {} };
|
|
663
|
+
}
|
|
664
|
+
const ref = configRefPath(n.on);
|
|
665
|
+
const { entry, case: kase } = configRefTarget(n.on);
|
|
666
|
+
useConfig(entry, `select '${ref}'`);
|
|
667
|
+
if (kase !== undefined && !cases.some((c) => c.cond === kase))
|
|
668
|
+
problems.push(
|
|
669
|
+
`select '${ref}': the reference is narrowed to the case '${kase}' and can only be used inside it`,
|
|
670
|
+
);
|
|
671
|
+
const union = unionAt(n.on);
|
|
672
|
+
if (!union)
|
|
673
|
+
problems.push(
|
|
674
|
+
`select '${ref}': it must reference a z.discriminatedUnion in config '${entry.name}'`,
|
|
675
|
+
);
|
|
676
|
+
else {
|
|
677
|
+
for (const v of union.values)
|
|
678
|
+
if (!Object.hasOwn(n.cases, v))
|
|
679
|
+
problems.push(`select '${ref}': no case for ${union.discriminator} '${v}'`);
|
|
680
|
+
for (const v of Object.keys(n.cases))
|
|
681
|
+
if (!union.values.includes(v))
|
|
682
|
+
problems.push(
|
|
683
|
+
`select '${ref}': '${v}' is not a value of ${union.discriminator} (${union.values.join(', ')})`,
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
const discriminator = union?.discriminator ?? '';
|
|
687
|
+
const branches = Object.entries(n.cases).map(([value, nodes]) => {
|
|
688
|
+
const inner = new Map(avail);
|
|
689
|
+
const stages = walk(nodes, inner, [
|
|
690
|
+
...cases,
|
|
691
|
+
{ select: id, value, cond: `${ref}.${discriminator}=${value}` },
|
|
692
|
+
]);
|
|
693
|
+
return { value, inner, stages };
|
|
694
|
+
});
|
|
695
|
+
// A step every case has is as available as in its cases; one only some cases have is maybe-available.
|
|
696
|
+
const added = new Set(
|
|
697
|
+
branches.flatMap((b) => [...b.inner.keys()].filter((k) => !avail.has(k))),
|
|
698
|
+
);
|
|
699
|
+
for (const name of added) {
|
|
700
|
+
const present = branches
|
|
701
|
+
.map((b) => b.inner.get(name))
|
|
702
|
+
.filter((a): a is Avail => a !== undefined);
|
|
703
|
+
avail.set(name, {
|
|
704
|
+
model: present[0]!.model,
|
|
705
|
+
maybe: present.length < branches.length || present.some((a) => a.maybe),
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
return {
|
|
709
|
+
select: ref,
|
|
710
|
+
discriminator,
|
|
711
|
+
cases: Object.fromEntries(branches.map((b) => [b.value, b.stages])),
|
|
712
|
+
};
|
|
713
|
+
};
|
|
714
|
+
const stages = walk(
|
|
715
|
+
data.nodes,
|
|
716
|
+
new Map([['input', { model: inputModel.name, maybe: false }]]),
|
|
717
|
+
);
|
|
316
718
|
|
|
317
719
|
// ── failure handlers ──
|
|
318
720
|
const stageStepNames = new Set(steps.keys());
|
|
319
721
|
const seenHandlers = new Map<string, AnyStep>();
|
|
320
|
-
const failureHandlers = data.failureHandlers
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
722
|
+
const failureHandlers = data.failureHandlers
|
|
723
|
+
.map((fh) => {
|
|
724
|
+
if (!isStep(fh.step)) {
|
|
725
|
+
problems.push('onFailure: handler is not a step');
|
|
726
|
+
return undefined;
|
|
727
|
+
}
|
|
728
|
+
if (seenHandlers.get(fh.step.name) !== fh.step) registerStep(fh.step, 'onFailure');
|
|
729
|
+
seenHandlers.set(fh.step.name, fh.step);
|
|
730
|
+
const scope = fh.steps === null ? null : fh.steps.map((x) => x.name);
|
|
731
|
+
for (const n of scope ?? [])
|
|
732
|
+
if (!stageStepNames.has(n))
|
|
733
|
+
problems.push(
|
|
734
|
+
`onFailure(${fh.step.name}): '${n}' is not a step of this pipeline`,
|
|
735
|
+
);
|
|
736
|
+
return { step: describeStep({ step: fh.step }, new Map(), true), steps: scope };
|
|
737
|
+
})
|
|
738
|
+
.filter((x) => x !== undefined);
|
|
739
|
+
|
|
740
|
+
// ── handlers without a step ──
|
|
333
741
|
for (const h of handlerNames) {
|
|
334
742
|
const s = steps.get(h);
|
|
335
|
-
if (!s)
|
|
336
|
-
|
|
743
|
+
if (!s)
|
|
744
|
+
problems.push(`a handler implements step '${h}', which is not in the pipeline`);
|
|
745
|
+
else if (isImplementation(s))
|
|
746
|
+
problems.push(
|
|
747
|
+
`a handler implements '${h}', a ${s.kind} step that carries its own implementation`,
|
|
748
|
+
);
|
|
337
749
|
}
|
|
338
750
|
|
|
339
751
|
// ── triggers ──
|
|
340
752
|
const triggerNames = new Set<string>();
|
|
341
753
|
const triggers: TriggerT[] = data.triggers.map((t) => {
|
|
342
|
-
if (triggerNames.has(t.name))
|
|
754
|
+
if (triggerNames.has(t.name))
|
|
755
|
+
problems.push(
|
|
756
|
+
`duplicate trigger '${t.name}'${t.kind === 'push' ? ' (onPush may be used once)' : ' (give each onPoll and onWebhook a distinct name)'}`,
|
|
757
|
+
);
|
|
343
758
|
triggerNames.add(t.name);
|
|
344
|
-
if (t.kind ===
|
|
345
|
-
if (t.kind ===
|
|
346
|
-
if (!STEP_NAME.test(t.name))
|
|
759
|
+
if (t.kind === 'push') return { kind: 'push', name: 'push' };
|
|
760
|
+
if (t.kind === 'webhook') {
|
|
761
|
+
if (!STEP_NAME.test(t.name))
|
|
762
|
+
problems.push(`webhook trigger name '${t.name}' must be lower-case kebab`);
|
|
347
763
|
const secret = useSecret(t.verify.secret, `webhook '${t.name}'`);
|
|
348
|
-
if (t.verify.scheme ===
|
|
349
|
-
|
|
350
|
-
|
|
764
|
+
if (t.verify.scheme === 'bearer')
|
|
765
|
+
return { kind: 'webhook', name: t.name, verify: { scheme: 'bearer', secret } };
|
|
766
|
+
if (!t.verify.header)
|
|
767
|
+
problems.push(`webhook '${t.name}': the signature header must not be empty`);
|
|
768
|
+
return {
|
|
769
|
+
kind: 'webhook',
|
|
770
|
+
name: t.name,
|
|
771
|
+
verify: {
|
|
772
|
+
scheme: 'hmac-sha256',
|
|
773
|
+
secret,
|
|
774
|
+
header: t.verify.header,
|
|
775
|
+
prefix: t.verify.prefix,
|
|
776
|
+
},
|
|
777
|
+
};
|
|
351
778
|
}
|
|
352
|
-
if (!STEP_NAME.test(t.name))
|
|
353
|
-
|
|
779
|
+
if (!STEP_NAME.test(t.name))
|
|
780
|
+
problems.push(`poll trigger name '${t.name}' must be lower-case kebab`);
|
|
781
|
+
if (!(Number.isInteger(t.intervalSeconds) && t.intervalSeconds > 0))
|
|
782
|
+
problems.push(`poll '${t.name}': intervalSeconds must be a positive integer`);
|
|
354
783
|
const declared = new Set(t.secrets.map((s) => useSecret(s, `poll '${t.name}'`)));
|
|
355
784
|
for (const [h, v] of Object.entries(t.headers)) {
|
|
356
785
|
for (const m of v.matchAll(/\$SECRET:([A-Za-z0-9_]+)/g))
|
|
357
|
-
if (!declared.has(m[1]!))
|
|
786
|
+
if (!declared.has(m[1]!))
|
|
787
|
+
problems.push(
|
|
788
|
+
`poll '${t.name}': header '${h}' references secret '${m[1]}', which is not in the poll's secrets`,
|
|
789
|
+
);
|
|
358
790
|
}
|
|
359
|
-
return {
|
|
791
|
+
return {
|
|
792
|
+
kind: 'poll',
|
|
793
|
+
name: t.name,
|
|
794
|
+
url: t.url,
|
|
795
|
+
headers: { ...t.headers },
|
|
796
|
+
secrets: [...declared].sort(),
|
|
797
|
+
valuePath: t.valuePath,
|
|
798
|
+
intervalSeconds: t.intervalSeconds,
|
|
799
|
+
};
|
|
360
800
|
});
|
|
361
801
|
|
|
802
|
+
// ── retention ──
|
|
803
|
+
const retention: Record<string, number> = {};
|
|
804
|
+
for (const key of ['keepLatestRuns', 'minRunAgeDays', 'buildGraceDays'] as const) {
|
|
805
|
+
const v = data.retention?.[key];
|
|
806
|
+
if (v === undefined) continue;
|
|
807
|
+
if (!(Number.isInteger(v) && v >= 0))
|
|
808
|
+
problems.push(`retention.${key} must be a non-negative integer`);
|
|
809
|
+
else retention[key] = v;
|
|
810
|
+
}
|
|
811
|
+
|
|
362
812
|
if (problems.length) throw new DescribeError(problems);
|
|
363
813
|
|
|
364
814
|
const manifest = canonicalize({
|
|
@@ -366,21 +816,139 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
366
816
|
name: data.name,
|
|
367
817
|
description: data.description,
|
|
368
818
|
input: inputModel.name,
|
|
819
|
+
...(Object.keys(retention).length ? { retention } : {}),
|
|
369
820
|
harness: opts.harness,
|
|
370
821
|
models: Object.fromEntries([...models].map(([k, v]) => [k, v.schemas])),
|
|
371
|
-
secrets: [...secrets]
|
|
822
|
+
secrets: [...secrets]
|
|
823
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
824
|
+
.map(([name, description]) => ({ name, description })),
|
|
825
|
+
...(configs.size
|
|
826
|
+
? {
|
|
827
|
+
config: [...configs]
|
|
828
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
829
|
+
.map(([name, c]) => ({
|
|
830
|
+
name,
|
|
831
|
+
description: c.description,
|
|
832
|
+
required: c.required,
|
|
833
|
+
schema: c.schema,
|
|
834
|
+
})),
|
|
835
|
+
}
|
|
836
|
+
: {}),
|
|
372
837
|
triggers,
|
|
373
838
|
stages,
|
|
374
839
|
failureHandlers,
|
|
375
840
|
});
|
|
376
841
|
const checked = Manifest.safeParse(manifest);
|
|
377
|
-
if (!checked.success)
|
|
842
|
+
if (!checked.success)
|
|
843
|
+
throw new DescribeError([
|
|
844
|
+
`internal: manifest does not match its schema: ${z.prettifyError(checked.error)}`,
|
|
845
|
+
]);
|
|
378
846
|
return manifest as ManifestT;
|
|
379
847
|
}
|
|
380
848
|
|
|
381
849
|
/** Canonical manifest text (what `describe` prints and the service hashes). */
|
|
382
850
|
export const manifestText = (m: ManifestT): string => canonicalJson(m);
|
|
383
851
|
|
|
852
|
+
/**
|
|
853
|
+
* The schemas at `path` inside an entry's schema: every union branch that has the field, except where
|
|
854
|
+
* `kase` (`<entry>.<path>.<discriminator>=<value>`) narrows a union to one branch.
|
|
855
|
+
*/
|
|
856
|
+
export function subSchemas(
|
|
857
|
+
schema: unknown,
|
|
858
|
+
path: readonly string[],
|
|
859
|
+
kase: string | undefined,
|
|
860
|
+
entry: string,
|
|
861
|
+
): unknown[] {
|
|
862
|
+
let narrow: { at: string; discriminator: string; value: string } | undefined;
|
|
863
|
+
if (kase) {
|
|
864
|
+
const eq = kase.lastIndexOf('=');
|
|
865
|
+
const left = kase.slice(0, eq);
|
|
866
|
+
const dot = left.lastIndexOf('.');
|
|
867
|
+
narrow = {
|
|
868
|
+
at: left.slice(0, dot),
|
|
869
|
+
discriminator: left.slice(dot + 1),
|
|
870
|
+
value: kase.slice(eq + 1),
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
const walk = (node: unknown, rest: readonly string[], here: string): unknown[] => {
|
|
874
|
+
if (node === null || typeof node !== 'object') return [];
|
|
875
|
+
const o = node as JsonSchema;
|
|
876
|
+
const branches = (o.oneOf ?? o.anyOf) as JsonSchema[] | undefined;
|
|
877
|
+
if (Array.isArray(branches)) {
|
|
878
|
+
const chosen =
|
|
879
|
+
narrow && narrow.at === here
|
|
880
|
+
? branches.filter(
|
|
881
|
+
(b) =>
|
|
882
|
+
(
|
|
883
|
+
(b.properties as Record<string, JsonSchema> | undefined)?.[
|
|
884
|
+
narrow!.discriminator
|
|
885
|
+
] as JsonSchema | undefined
|
|
886
|
+
)?.const === narrow!.value,
|
|
887
|
+
)
|
|
888
|
+
: branches;
|
|
889
|
+
return chosen.flatMap((b) => walk(b, rest, here));
|
|
890
|
+
}
|
|
891
|
+
if (rest.length === 0) return [node];
|
|
892
|
+
const props = o.properties as Record<string, JsonSchema> | undefined;
|
|
893
|
+
if (props && Object.hasOwn(props, rest[0]!))
|
|
894
|
+
return walk(props[rest[0]!], rest.slice(1), `${here}.${rest[0]}`);
|
|
895
|
+
return [];
|
|
896
|
+
};
|
|
897
|
+
return walk(schema, path, entry);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/** Secret names of every secretRef inside a config entry's JSON Schema. */
|
|
901
|
+
export function secretRefsOf(schema: unknown): string[] {
|
|
902
|
+
const out = new Set<string>();
|
|
903
|
+
const walk = (v: unknown) => {
|
|
904
|
+
if (Array.isArray(v)) return v.forEach(walk);
|
|
905
|
+
if (v === null || typeof v !== 'object') return;
|
|
906
|
+
const o = v as JsonSchema;
|
|
907
|
+
if (o['x-olve-kind'] === SECRET_REF_KIND) {
|
|
908
|
+
const name = (o.properties as { secret?: { const?: unknown } } | undefined)?.secret
|
|
909
|
+
?.const;
|
|
910
|
+
if (typeof name === 'string') out.add(name);
|
|
911
|
+
}
|
|
912
|
+
Object.values(o).forEach(walk);
|
|
913
|
+
};
|
|
914
|
+
walk(schema);
|
|
915
|
+
return [...out].sort();
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/** The `{"$config": "<entry>.<path>"[, "$case": …]}` references in a step's recorded settings. */
|
|
919
|
+
export function recordedConfigRefs(
|
|
920
|
+
v: unknown,
|
|
921
|
+
out: { ref: string; case?: string }[] = [],
|
|
922
|
+
): { ref: string; case?: string }[] {
|
|
923
|
+
if (Array.isArray(v)) v.forEach((x) => recordedConfigRefs(x, out));
|
|
924
|
+
else if (v !== null && typeof v === 'object') {
|
|
925
|
+
const o = v as Record<string, unknown>;
|
|
926
|
+
const keys = Object.keys(o).sort();
|
|
927
|
+
const isRef =
|
|
928
|
+
typeof o[CONFIG_REF_KEY] === 'string' &&
|
|
929
|
+
(keys.length === 1
|
|
930
|
+
? keys[0] === CONFIG_REF_KEY
|
|
931
|
+
: keys.length === 2 &&
|
|
932
|
+
keys[0] === CONFIG_CASE_KEY &&
|
|
933
|
+
keys[1] === CONFIG_REF_KEY &&
|
|
934
|
+
typeof o[CONFIG_CASE_KEY] === 'string');
|
|
935
|
+
if (isRef)
|
|
936
|
+
out.push({
|
|
937
|
+
ref: o[CONFIG_REF_KEY] as string,
|
|
938
|
+
...(keys.length === 2 ? { case: o[CONFIG_CASE_KEY] as string } : {}),
|
|
939
|
+
});
|
|
940
|
+
else Object.values(o).forEach((x) => recordedConfigRefs(x, out));
|
|
941
|
+
}
|
|
942
|
+
return out;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** A select case a step sits in: which select (numbered in describe order), which value, and its condition text. */
|
|
946
|
+
interface CaseOf {
|
|
947
|
+
select: number;
|
|
948
|
+
value: string;
|
|
949
|
+
cond: string;
|
|
950
|
+
}
|
|
951
|
+
|
|
384
952
|
function camel(name: string): string {
|
|
385
953
|
return name.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
|
|
386
954
|
}
|