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