@olivervea/pipelines 2.0.0-beta.3 → 2.0.0-beta.31
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 +165 -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 +110 -39
- package/src/ctx/links.ts +30 -0
- package/src/ctx/sh.ts +106 -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 +245 -99
- package/src/harness/mask.ts +98 -0
- 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
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { isModel, isOptional } from '../core/models.ts';
|
|
3
|
+
import { isExternal } from '../core/external.ts';
|
|
4
|
+
import {
|
|
5
|
+
AXIS_NAME,
|
|
6
|
+
AXIS_VALUE,
|
|
7
|
+
MAX_AXES,
|
|
8
|
+
MAX_CELLS,
|
|
9
|
+
isCells,
|
|
10
|
+
matrixCells,
|
|
11
|
+
type Matrix,
|
|
12
|
+
} from '../core/matrix.ts';
|
|
13
|
+
import type { AnyStep } from '../core/steps.ts';
|
|
14
|
+
import { isImplementation } from '../core/handlers.ts';
|
|
15
|
+
import { recordConfigRefs } from '../core/config-refs.ts';
|
|
16
|
+
import type { MapperFunction, StageStepNode } from '../builder/pipeline.ts';
|
|
17
|
+
import { FailureContext } from '../library/models.ts';
|
|
18
|
+
import { canonicalize } from '../util/canonical.ts';
|
|
19
|
+
import { analyzeMapperSource } from './mapper-source.ts';
|
|
20
|
+
import { MapperError, recordMapper, type Mapping } from './record.ts';
|
|
21
|
+
import type { StepDescriptorT } from './manifest-schema.ts';
|
|
22
|
+
import { recordedConfigRefs, secretRefsOf, subSchemas } from './config-schema.ts';
|
|
23
|
+
import { resolveField, schemaViews } from './schemas.ts';
|
|
24
|
+
import {
|
|
25
|
+
FAILURE_ROOT,
|
|
26
|
+
type Avail,
|
|
27
|
+
type CaseOf,
|
|
28
|
+
type DescribeContext,
|
|
29
|
+
} from './context.ts';
|
|
30
|
+
|
|
31
|
+
type InputTypes = StepDescriptorT['inputTypes'];
|
|
32
|
+
type MatrixT = NonNullable<StepDescriptorT['matrix']>;
|
|
33
|
+
type ExternalInputs = NonNullable<StepDescriptorT['externalInputs']>;
|
|
34
|
+
type InputEntries = [string, unknown][];
|
|
35
|
+
|
|
36
|
+
export function describeStep(
|
|
37
|
+
ctx: DescribeContext,
|
|
38
|
+
node: StageStepNode,
|
|
39
|
+
avail: Map<string, Avail>,
|
|
40
|
+
failureHandler: boolean,
|
|
41
|
+
cases: readonly CaseOf[] = [],
|
|
42
|
+
): StepDescriptorT {
|
|
43
|
+
const problems = ctx.problems;
|
|
44
|
+
const s = node.step;
|
|
45
|
+
const at = `step '${s.name}'`;
|
|
46
|
+
if (typeof s.image !== 'string' || s.image.trim() === '')
|
|
47
|
+
problems.push(`${at}: image must be a non-empty string`);
|
|
48
|
+
|
|
49
|
+
const allEntries = Object.entries(s.input as Record<string, unknown>);
|
|
50
|
+
const externalInputs = describeExternalInputs(ctx, at, allEntries, failureHandler);
|
|
51
|
+
const entries = allEntries.filter(([, e]) => !isExternal(e));
|
|
52
|
+
const inputTypes = describeInputTypes(ctx, at, entries);
|
|
53
|
+
|
|
54
|
+
let input: Record<string, Mapping>;
|
|
55
|
+
if (failureHandler) input = failureHandlerInput(ctx, at, entries, node);
|
|
56
|
+
else if (node.mapper) input = mapStep(ctx, at, s, node.mapper, avail);
|
|
57
|
+
else input = matchInputsByModel(ctx, at, entries, avail);
|
|
58
|
+
|
|
59
|
+
for (const key of Object.keys(s.input as Record<string, unknown>))
|
|
60
|
+
if (key.startsWith('$'))
|
|
61
|
+
problems.push(`${at}: input '${key}': keys starting with '$' are reserved`);
|
|
62
|
+
const secretNames = s.secrets.map((x) => ctx.useSecret(x, at)).sort();
|
|
63
|
+
const configNames = [
|
|
64
|
+
...new Set(
|
|
65
|
+
(s.config ?? [])
|
|
66
|
+
.map((c) => ctx.useConfig(c, at))
|
|
67
|
+
.filter((n): n is string => n !== undefined),
|
|
68
|
+
),
|
|
69
|
+
].sort();
|
|
70
|
+
const carried = isImplementation(s);
|
|
71
|
+
const { settings, settingsProblem } = recordSettings(s, carried);
|
|
72
|
+
const refs = settings ? recordedConfigRefs(settings) : [];
|
|
73
|
+
checkConfigSecrets(ctx, at, configNames, refs, secretNames);
|
|
74
|
+
if (settingsProblem !== undefined)
|
|
75
|
+
problems.push(`${at}: invalid ${s.kind} settings (${settingsProblem})`);
|
|
76
|
+
if (typeof s.kind !== 'string' || s.kind === '')
|
|
77
|
+
problems.push(`${at}: kind must be a non-empty string`);
|
|
78
|
+
checkConfigRefs(ctx, at, refs, configNames, cases);
|
|
79
|
+
if (!carried)
|
|
80
|
+
problems.push(`${at} has no implementation (stage implement(step({...}), fn))`);
|
|
81
|
+
if (typeof s.description !== 'string')
|
|
82
|
+
problems.push(`${at}: description must be a string (it may be empty)`);
|
|
83
|
+
if (
|
|
84
|
+
s.timeoutSeconds !== undefined &&
|
|
85
|
+
!(Number.isInteger(s.timeoutSeconds) && s.timeoutSeconds > 0)
|
|
86
|
+
)
|
|
87
|
+
problems.push(`${at}: timeoutSeconds must be a positive integer`);
|
|
88
|
+
if (s.retries !== undefined && !(Number.isInteger(s.retries) && s.retries >= 0))
|
|
89
|
+
problems.push(`${at}: retries must be a non-negative integer`);
|
|
90
|
+
if (s.matrix !== undefined && failureHandler)
|
|
91
|
+
problems.push(`${at}: a failure handler cannot be a matrix step`);
|
|
92
|
+
const matrix = s.matrix !== undefined ? describeMatrix(ctx, at, s.matrix) : undefined;
|
|
93
|
+
|
|
94
|
+
const out: StepDescriptorT = {
|
|
95
|
+
name: s.name,
|
|
96
|
+
description: typeof s.description === 'string' ? s.description : '',
|
|
97
|
+
kind: s.kind,
|
|
98
|
+
image: s.image,
|
|
99
|
+
inputTypes,
|
|
100
|
+
input,
|
|
101
|
+
output: s.output ? ctx.useModel(s.output, `${at} output`) : null,
|
|
102
|
+
secrets: secretNames,
|
|
103
|
+
...(s.timeoutSeconds !== undefined ? { timeoutSeconds: s.timeoutSeconds } : {}),
|
|
104
|
+
...(s.retries !== undefined ? { retries: s.retries } : {}),
|
|
105
|
+
...(matrix !== undefined ? { matrix } : {}),
|
|
106
|
+
...(settings !== undefined
|
|
107
|
+
? { config: settings as Record<string, z.core.util.JSONType> }
|
|
108
|
+
: {}),
|
|
109
|
+
...(configNames.length ? { configEntries: configNames } : {}),
|
|
110
|
+
...(s.clusterAccess === true ? { clusterAccess: true as const } : {}),
|
|
111
|
+
...(Object.keys(externalInputs).length ? { externalInputs } : {}),
|
|
112
|
+
};
|
|
113
|
+
if (s.clusterAccess !== undefined && typeof s.clusterAccess !== 'boolean')
|
|
114
|
+
problems.push(`${at}: clusterAccess must be a boolean`);
|
|
115
|
+
if (s.output && !isModel(s.output)) problems.push(`${at}: output must be a model`);
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function describeExternalInputs(
|
|
120
|
+
ctx: DescribeContext,
|
|
121
|
+
at: string,
|
|
122
|
+
allEntries: InputEntries,
|
|
123
|
+
failureHandler: boolean,
|
|
124
|
+
): ExternalInputs {
|
|
125
|
+
const problems = ctx.problems;
|
|
126
|
+
const externalInputs: ExternalInputs = {};
|
|
127
|
+
for (const [key, e] of allEntries) {
|
|
128
|
+
if (!isExternal(e)) continue;
|
|
129
|
+
if (failureHandler)
|
|
130
|
+
problems.push(
|
|
131
|
+
`${at}: failure handlers run unattended and cannot wait for input ('${key}')`,
|
|
132
|
+
);
|
|
133
|
+
if (typeof e.title !== 'string' || e.title.trim() === '')
|
|
134
|
+
problems.push(`${at} external input '${key}': title must not be empty`);
|
|
135
|
+
if (e.from.length === 0)
|
|
136
|
+
problems.push(`${at} external input '${key}': from must list at least one source`);
|
|
137
|
+
if (new Set(e.from.map((f) => f.kind)).size !== e.from.length)
|
|
138
|
+
problems.push(`${at} external input '${key}': each source kind may be listed once`);
|
|
139
|
+
if (e.from.some((f) => f.kind === 'callback') && !/^[A-Za-z0-9_-]+$/.test(key))
|
|
140
|
+
problems.push(
|
|
141
|
+
`${at} external input '${key}': a callback input's key must be letters, digits, '_' or '-' (it is part of the URL)`,
|
|
142
|
+
);
|
|
143
|
+
for (const f of e.from) {
|
|
144
|
+
if (f.kind !== 'poll') continue;
|
|
145
|
+
if (!/^https?:\/\//.test(f.url))
|
|
146
|
+
problems.push(
|
|
147
|
+
`${at} external input '${key}': poll url must be an absolute http(s) URL`,
|
|
148
|
+
);
|
|
149
|
+
if (!Number.isInteger(f.intervalSeconds) || f.intervalSeconds < 10)
|
|
150
|
+
problems.push(
|
|
151
|
+
`${at} external input '${key}': poll intervalSeconds must be an integer of at least 10`,
|
|
152
|
+
);
|
|
153
|
+
if (f.valuePath.trim() === '')
|
|
154
|
+
problems.push(`${at} external input '${key}': poll valuePath must not be empty`);
|
|
155
|
+
}
|
|
156
|
+
externalInputs[key] = {
|
|
157
|
+
model: ctx.useModel(e.model, `${at} external input '${key}'`),
|
|
158
|
+
title: e.title,
|
|
159
|
+
description: e.description,
|
|
160
|
+
from: e.from.map((f) => ({ ...f })),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return externalInputs;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function describeInputTypes(
|
|
167
|
+
ctx: DescribeContext,
|
|
168
|
+
at: string,
|
|
169
|
+
entries: InputEntries,
|
|
170
|
+
): InputTypes {
|
|
171
|
+
const inputTypes: InputTypes = {};
|
|
172
|
+
for (const [key, e] of entries) {
|
|
173
|
+
if (isCells(e))
|
|
174
|
+
inputTypes[key] = {
|
|
175
|
+
model: ctx.useModel(e.model, `${at} input '${key}'`),
|
|
176
|
+
optional: false,
|
|
177
|
+
cells: true,
|
|
178
|
+
};
|
|
179
|
+
else if (isOptional(e))
|
|
180
|
+
inputTypes[key] = {
|
|
181
|
+
model: ctx.useModel(e.model, `${at} input '${key}'`),
|
|
182
|
+
optional: true,
|
|
183
|
+
};
|
|
184
|
+
else if (isModel(e))
|
|
185
|
+
inputTypes[key] = {
|
|
186
|
+
model: ctx.useModel(e, `${at} input '${key}'`),
|
|
187
|
+
optional: false,
|
|
188
|
+
};
|
|
189
|
+
else if (e instanceof z.ZodType) {
|
|
190
|
+
try {
|
|
191
|
+
inputTypes[key] = { schema: schemaViews(e) };
|
|
192
|
+
} catch (err) {
|
|
193
|
+
ctx.problems.push(
|
|
194
|
+
`${at} input '${key}': schema is not representable as JSON Schema (${(err as Error).message})`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
} else
|
|
198
|
+
ctx.problems.push(
|
|
199
|
+
`${at} input '${key}': must be a model, optional(model), cells(model) or a zod schema`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return inputTypes;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function failureHandlerInput(
|
|
206
|
+
ctx: DescribeContext,
|
|
207
|
+
at: string,
|
|
208
|
+
entries: InputEntries,
|
|
209
|
+
node: StageStepNode,
|
|
210
|
+
): Record<string, Mapping> {
|
|
211
|
+
const input: Record<string, Mapping> = {};
|
|
212
|
+
for (const [key, e] of entries) {
|
|
213
|
+
if (e !== FailureContext && !(isModel(e) && e.name === FailureContext.name))
|
|
214
|
+
ctx.problems.push(
|
|
215
|
+
`${at}: a failure handler's inputs must all be FailureContext (input '${key}' is not)`,
|
|
216
|
+
);
|
|
217
|
+
input[key] = { ref: FAILURE_ROOT };
|
|
218
|
+
}
|
|
219
|
+
if (node.mapper) ctx.problems.push(`${at}: failure handlers take no mapper`);
|
|
220
|
+
return input;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function matchInputsByModel(
|
|
224
|
+
ctx: DescribeContext,
|
|
225
|
+
at: string,
|
|
226
|
+
entries: InputEntries,
|
|
227
|
+
avail: Map<string, Avail>,
|
|
228
|
+
): Record<string, Mapping> {
|
|
229
|
+
const problems = ctx.problems;
|
|
230
|
+
const input: Record<string, Mapping> = {};
|
|
231
|
+
for (const [key, e] of entries) {
|
|
232
|
+
if (isCells(e)) {
|
|
233
|
+
const producers = [...avail].filter(
|
|
234
|
+
([, a]) => a.matrix && a.model === e.model.name,
|
|
235
|
+
);
|
|
236
|
+
if (producers.length === 1) {
|
|
237
|
+
const [producer, a] = producers[0]!;
|
|
238
|
+
if (a.maybe) problems.push(maybeCells(at, e.model.name, key));
|
|
239
|
+
input[key] = { ref: producer };
|
|
240
|
+
} else
|
|
241
|
+
problems.push(
|
|
242
|
+
producers.length === 0
|
|
243
|
+
? `${at}: ${e.model.name} (input '${key}') has no matrix step producing it at this stage; cells() reads a matrix step`
|
|
244
|
+
: `${at}: ${e.model.name} (input '${key}') has several matrix steps producing it (${producers
|
|
245
|
+
.map(([n]) => n)
|
|
246
|
+
.sort()
|
|
247
|
+
.join(', ')}); use a mapper to pick one`,
|
|
248
|
+
);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const optional = isOptional(e);
|
|
252
|
+
const m = optional ? e.model : isModel(e) ? e : undefined;
|
|
253
|
+
if (!m) {
|
|
254
|
+
problems.push(
|
|
255
|
+
`${at}: input '${key}' is a plain schema, not a model, so it cannot be matched by model; add a mapper ([step, from => ({ ... })])`,
|
|
256
|
+
);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const producers = [...avail].filter(([, a]) => !a.matrix && a.model === m.name);
|
|
260
|
+
const matrixProducer = [...avail].find(([, a]) => a.matrix && a.model === m.name);
|
|
261
|
+
if (producers.length === 0 && matrixProducer) {
|
|
262
|
+
problems.push(
|
|
263
|
+
`${at}: ${m.name} (input '${key}') is produced by matrix step '${matrixProducer[0]}'; read it with cells(${m.name})`,
|
|
264
|
+
);
|
|
265
|
+
} else if (producers.length === 0) {
|
|
266
|
+
problems.push(
|
|
267
|
+
`${at}: ${m.name} (input '${key}') is not available at this stage${optional ? ' (optional() means maybe-skipped; there must still be an earlier producer)' : ''}`,
|
|
268
|
+
);
|
|
269
|
+
} else if (producers.length > 1) {
|
|
270
|
+
problems.push(
|
|
271
|
+
`${at}: ${m.name} (input '${key}') has several producers (${producers
|
|
272
|
+
.map(([n]) => n)
|
|
273
|
+
.sort()
|
|
274
|
+
.join(', ')}); use a mapper to pick one`,
|
|
275
|
+
);
|
|
276
|
+
} else {
|
|
277
|
+
const [producer, a] = producers[0]!;
|
|
278
|
+
if (a.maybe && !optional)
|
|
279
|
+
problems.push(
|
|
280
|
+
`${at}: ${m.name} (input '${key}') is only maybe-available (produced inside a when block); declare it optional(${m.name})`,
|
|
281
|
+
);
|
|
282
|
+
input[key] = { ref: producer };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return input;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function mapStep(
|
|
289
|
+
ctx: DescribeContext,
|
|
290
|
+
at: string,
|
|
291
|
+
s: AnyStep,
|
|
292
|
+
mapper: MapperFunction,
|
|
293
|
+
avail: Map<string, Avail>,
|
|
294
|
+
): Record<string, Mapping> {
|
|
295
|
+
const problems = ctx.problems;
|
|
296
|
+
try {
|
|
297
|
+
analyzeMapperSource(Function.prototype.toString.call(mapper));
|
|
298
|
+
} catch (e) {
|
|
299
|
+
if (e instanceof MapperError) problems.push(`${at}: ${e.message}`);
|
|
300
|
+
else throw e;
|
|
301
|
+
return {};
|
|
302
|
+
}
|
|
303
|
+
const roots = new Set([...avail].filter(([, a]) => a.model !== null).map(([n]) => n));
|
|
304
|
+
let recorded: Record<string, Mapping>;
|
|
305
|
+
try {
|
|
306
|
+
recorded = recordMapper(mapper, roots);
|
|
307
|
+
} catch (e) {
|
|
308
|
+
if (e instanceof MapperError) problems.push(`${at}: ${e.message}`);
|
|
309
|
+
else problems.push(`${at}: mapper threw: ${(e as Error)?.message ?? String(e)}`);
|
|
310
|
+
return {};
|
|
311
|
+
}
|
|
312
|
+
const declared = Object.fromEntries(
|
|
313
|
+
Object.entries(s.input as Record<string, unknown>).filter(([, e]) => !isExternal(e)),
|
|
314
|
+
);
|
|
315
|
+
for (const k of Object.keys(declared))
|
|
316
|
+
if (!(k in recorded)) problems.push(`${at}: mapper does not provide input '${k}'`);
|
|
317
|
+
for (const k of Object.keys(recorded))
|
|
318
|
+
if (!(k in declared))
|
|
319
|
+
problems.push(`${at}: mapper provides '${k}', which is not an input of the step`);
|
|
320
|
+
for (const [k, m] of Object.entries(recorded)) {
|
|
321
|
+
const e = declared[k];
|
|
322
|
+
if (isCells(e)) checkCellsMapping(ctx, `${at} input '${k}'`, m, avail, e.model.name);
|
|
323
|
+
else checkMapping(ctx, `${at} input '${k}'`, m, avail, isOptional(e), true);
|
|
324
|
+
}
|
|
325
|
+
return recorded;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const maybeCells = (at: string, model: string, key: string) =>
|
|
329
|
+
`${at}: ${model} (input '${key}') is only maybe-available (produced inside a when block); cells() cannot read a maybe-skipped matrix step`;
|
|
330
|
+
|
|
331
|
+
function checkCellsMapping(
|
|
332
|
+
ctx: DescribeContext,
|
|
333
|
+
at: string,
|
|
334
|
+
m: Mapping,
|
|
335
|
+
avail: Map<string, Avail>,
|
|
336
|
+
model: string,
|
|
337
|
+
): void {
|
|
338
|
+
const problems = ctx.problems;
|
|
339
|
+
const ref =
|
|
340
|
+
Object.keys(m).length === 1 && typeof (m as { ref?: unknown }).ref === 'string'
|
|
341
|
+
? (m as { ref: string }).ref
|
|
342
|
+
: undefined;
|
|
343
|
+
if (ref === undefined || ref.includes('.'))
|
|
344
|
+
return void problems.push(
|
|
345
|
+
`${at}: a cells() input takes a whole matrix step (from['<step>']), not a field or an object`,
|
|
346
|
+
);
|
|
347
|
+
const a = avail.get(ref);
|
|
348
|
+
if (!a) return void problems.push(`${at}: '${ref}' is not available at this stage`);
|
|
349
|
+
if (!a.matrix)
|
|
350
|
+
return void problems.push(
|
|
351
|
+
`${at}: '${ref}' is not a matrix step; cells() reads a matrix step`,
|
|
352
|
+
);
|
|
353
|
+
if (a.model !== model)
|
|
354
|
+
problems.push(`${at}: '${ref}' outputs ${a.model ?? 'nothing'}, not ${model}`);
|
|
355
|
+
if (a.maybe)
|
|
356
|
+
problems.push(
|
|
357
|
+
`${at}: '${ref}' is only maybe-available (produced inside a when block); cells() cannot read a maybe-skipped matrix step`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function checkMapping(
|
|
362
|
+
ctx: DescribeContext,
|
|
363
|
+
at: string,
|
|
364
|
+
m: Mapping,
|
|
365
|
+
avail: Map<string, Avail>,
|
|
366
|
+
optionalTarget: boolean,
|
|
367
|
+
topLevel: boolean,
|
|
368
|
+
): void {
|
|
369
|
+
const problems = ctx.problems;
|
|
370
|
+
const keys = Object.keys(m);
|
|
371
|
+
if (
|
|
372
|
+
keys.length === 1 &&
|
|
373
|
+
keys[0] === 'ref' &&
|
|
374
|
+
typeof (m as { ref: unknown }).ref === 'string'
|
|
375
|
+
) {
|
|
376
|
+
const [root, ...path] = (m as { ref: string }).ref.split('.') as [
|
|
377
|
+
string,
|
|
378
|
+
...string[],
|
|
379
|
+
];
|
|
380
|
+
const a = avail.get(root);
|
|
381
|
+
if (!a) return void problems.push(`${at}: '${root}' is not available at this stage`);
|
|
382
|
+
if (a.matrix)
|
|
383
|
+
return void problems.push(
|
|
384
|
+
`${at}: '${(m as { ref: string }).ref}' — '${root}' is a matrix step; read it whole through a cells() input`,
|
|
385
|
+
);
|
|
386
|
+
if (!resolveField(ctx.outputViewOf(a.model), path))
|
|
387
|
+
problems.push(
|
|
388
|
+
`${at}: '${(m as { ref: string }).ref}' — model ${a.model} has no field '${path.join('.')}'`,
|
|
389
|
+
);
|
|
390
|
+
if (a.maybe && !(optionalTarget && topLevel))
|
|
391
|
+
problems.push(
|
|
392
|
+
`${at}: '${(m as { ref: string }).ref}' is only maybe-available (produced inside a when block); map it whole into an optional(...) input`,
|
|
393
|
+
);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (keys.length === 1 && keys[0] === 'const') return;
|
|
397
|
+
for (const [k, x] of Object.entries(m as Record<string, Mapping>))
|
|
398
|
+
checkMapping(ctx, `${at}.${k}`, x, avail, optionalTarget, false);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function recordSettings(
|
|
402
|
+
s: AnyStep,
|
|
403
|
+
carried: boolean,
|
|
404
|
+
): { settings?: Record<string, unknown>; settingsProblem?: string } {
|
|
405
|
+
if (s.settings === undefined && !carried) return {};
|
|
406
|
+
try {
|
|
407
|
+
return {
|
|
408
|
+
settings: canonicalize(recordConfigRefs(s.settings ?? {})) as Record<
|
|
409
|
+
string,
|
|
410
|
+
unknown
|
|
411
|
+
>,
|
|
412
|
+
};
|
|
413
|
+
} catch (e) {
|
|
414
|
+
return { settings: {}, settingsProblem: (e as Error).message };
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
type ConfigRefUse = { ref: string; case?: string };
|
|
419
|
+
|
|
420
|
+
// A step whose settings reference parts of an entry needs the secrets those parts name; any other
|
|
421
|
+
// step receiving an entry needs every secret the entry names.
|
|
422
|
+
function checkConfigSecrets(
|
|
423
|
+
ctx: DescribeContext,
|
|
424
|
+
at: string,
|
|
425
|
+
configNames: readonly string[],
|
|
426
|
+
refs: readonly ConfigRefUse[],
|
|
427
|
+
secretNames: readonly string[],
|
|
428
|
+
): void {
|
|
429
|
+
for (const name of configNames) {
|
|
430
|
+
const into = refs.filter((r) => r.ref.split('.')[0] === name);
|
|
431
|
+
const needed =
|
|
432
|
+
into.length === 0
|
|
433
|
+
? (ctx.configs.get(name)?.secretRefs ?? [])
|
|
434
|
+
: [
|
|
435
|
+
...new Set(
|
|
436
|
+
into.flatMap((r) =>
|
|
437
|
+
secretRefsOf(
|
|
438
|
+
subSchemas(
|
|
439
|
+
ctx.configs.get(name)?.schema,
|
|
440
|
+
r.ref.split('.').slice(1),
|
|
441
|
+
r.case,
|
|
442
|
+
name,
|
|
443
|
+
),
|
|
444
|
+
),
|
|
445
|
+
),
|
|
446
|
+
),
|
|
447
|
+
].sort();
|
|
448
|
+
for (const ref of needed)
|
|
449
|
+
if (!secretNames.includes(ref))
|
|
450
|
+
ctx.problems.push(
|
|
451
|
+
`${at}: config '${name}' references secret '${ref}' (secretRef), so the step must declare it in secrets`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function checkConfigRefs(
|
|
457
|
+
ctx: DescribeContext,
|
|
458
|
+
at: string,
|
|
459
|
+
refs: readonly ConfigRefUse[],
|
|
460
|
+
configNames: readonly string[],
|
|
461
|
+
cases: readonly CaseOf[],
|
|
462
|
+
): void {
|
|
463
|
+
const problems = ctx.problems;
|
|
464
|
+
for (const { ref, case: kase } of refs) {
|
|
465
|
+
const [entry, ...path] = ref.split('.') as [string, ...string[]];
|
|
466
|
+
if (kase !== undefined && !cases.some((c) => c.cond === kase))
|
|
467
|
+
problems.push(
|
|
468
|
+
`${at}: config reference '${ref}' is narrowed to the select case '${kase}' and can only be used inside it`,
|
|
469
|
+
);
|
|
470
|
+
if (!configNames.includes(entry))
|
|
471
|
+
problems.push(
|
|
472
|
+
`${at}: config reference '${ref}': the step does not receive config entry '${entry}'`,
|
|
473
|
+
);
|
|
474
|
+
else if (!resolveField(ctx.configs.get(entry)?.schema, path))
|
|
475
|
+
problems.push(
|
|
476
|
+
`${at}: config reference '${ref}': config '${entry}' has no field '${path.join('.')}'`,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function describeMatrix(
|
|
482
|
+
ctx: DescribeContext,
|
|
483
|
+
at: string,
|
|
484
|
+
m: Matrix,
|
|
485
|
+
): MatrixT | undefined {
|
|
486
|
+
const problems = ctx.problems;
|
|
487
|
+
const before = problems.length;
|
|
488
|
+
const axes = Object.entries(
|
|
489
|
+
typeof m.axes === 'object' && m.axes !== null ? m.axes : {},
|
|
490
|
+
) as [string, unknown][];
|
|
491
|
+
if (axes.length < 1 || axes.length > MAX_AXES)
|
|
492
|
+
problems.push(
|
|
493
|
+
`${at}: a matrix has 1 to ${MAX_AXES} axes (this one has ${axes.length})`,
|
|
494
|
+
);
|
|
495
|
+
for (const [name, values] of axes) {
|
|
496
|
+
if (!AXIS_NAME.test(name))
|
|
497
|
+
problems.push(`${at}: matrix axis name '${name}' must match ${AXIS_NAME}`);
|
|
498
|
+
if (!Array.isArray(values) || values.some((v) => typeof v !== 'string')) {
|
|
499
|
+
problems.push(`${at}: matrix axis '${name}' must be a list of strings`);
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (values.length === 0) problems.push(`${at}: matrix axis '${name}' has no values`);
|
|
503
|
+
for (const v of values as string[])
|
|
504
|
+
if (!AXIS_VALUE.test(v))
|
|
505
|
+
problems.push(
|
|
506
|
+
`${at}: matrix axis '${name}': value '${v}' must match ${AXIS_VALUE}`,
|
|
507
|
+
);
|
|
508
|
+
for (const v of new Set(
|
|
509
|
+
(values as string[]).filter((v, i) => values.indexOf(v) !== i),
|
|
510
|
+
))
|
|
511
|
+
problems.push(`${at}: matrix axis '${name}': value '${v}' is listed twice`);
|
|
512
|
+
}
|
|
513
|
+
const known = new Map(axes);
|
|
514
|
+
m.exclude.forEach((x, i) => {
|
|
515
|
+
const where = `${at}: matrix exclude ${i + 1}`;
|
|
516
|
+
if (Object.keys(x).length === 0)
|
|
517
|
+
problems.push(`${where} must name at least one axis`);
|
|
518
|
+
for (const [k, v] of Object.entries(x)) {
|
|
519
|
+
const values = known.get(k);
|
|
520
|
+
if (!values) problems.push(`${where}: '${k}' is not an axis`);
|
|
521
|
+
else if (!(Array.isArray(values) && values.includes(v)))
|
|
522
|
+
problems.push(`${where}: '${String(v)}' is not a value of axis '${k}'`);
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
const kind = (m.failure as { kind?: unknown } | undefined)?.kind;
|
|
526
|
+
if (kind !== 'keepRunning' && kind !== 'cascadeFailure')
|
|
527
|
+
problems.push(`${at}: matrix failure must be keepRunning() or cascadeFailure()`);
|
|
528
|
+
if (
|
|
529
|
+
m.maxParallel !== undefined &&
|
|
530
|
+
!(Number.isInteger(m.maxParallel) && m.maxParallel >= 1)
|
|
531
|
+
)
|
|
532
|
+
problems.push(`${at}: matrix maxParallel must be a positive integer`);
|
|
533
|
+
if (problems.length > before) return undefined;
|
|
534
|
+
const count = matrixCells(m).length;
|
|
535
|
+
if (count === 0) problems.push(`${at}: the matrix excludes every cell`);
|
|
536
|
+
else if (count > MAX_CELLS)
|
|
537
|
+
problems.push(`${at}: the matrix has ${count} cells (at most ${MAX_CELLS})`);
|
|
538
|
+
return {
|
|
539
|
+
axes: axes.map(([name, values]) => ({ name, values: [...(values as string[])] })),
|
|
540
|
+
...(m.exclude.length ? { exclude: m.exclude.map((x) => ({ ...x })) } : {}),
|
|
541
|
+
failure: { kind: kind as MatrixT['failure']['kind'] },
|
|
542
|
+
...(m.maxParallel !== undefined ? { maxParallel: m.maxParallel } : {}),
|
|
543
|
+
};
|
|
544
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { STEP_NAME } from '../core/steps.ts';
|
|
2
|
+
import type { TriggerNode } from '../builder/pipeline.ts';
|
|
3
|
+
import type { TriggerT } from './manifest-schema.ts';
|
|
4
|
+
import type { DescribeContext } from './context.ts';
|
|
5
|
+
|
|
6
|
+
export function describeTriggers(
|
|
7
|
+
ctx: DescribeContext,
|
|
8
|
+
triggers: readonly TriggerNode[],
|
|
9
|
+
): TriggerT[] {
|
|
10
|
+
const names = new Set<string>();
|
|
11
|
+
return triggers.map((t) => {
|
|
12
|
+
if (names.has(t.name))
|
|
13
|
+
ctx.problems.push(
|
|
14
|
+
`duplicate trigger '${t.name}'${t.kind === 'push' ? ' (onPush may be used once)' : ' (give each onPoll and onWebhook a distinct name)'}`,
|
|
15
|
+
);
|
|
16
|
+
names.add(t.name);
|
|
17
|
+
if (t.kind === 'push') return { kind: 'push', name: 'push' };
|
|
18
|
+
if (t.kind === 'webhook') return describeWebhook(ctx, t);
|
|
19
|
+
return describePoll(ctx, t);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function describeWebhook(
|
|
24
|
+
ctx: DescribeContext,
|
|
25
|
+
t: Extract<TriggerNode, { kind: 'webhook' }>,
|
|
26
|
+
): TriggerT {
|
|
27
|
+
if (!STEP_NAME.test(t.name))
|
|
28
|
+
ctx.problems.push(`webhook trigger name '${t.name}' must be lower-case kebab`);
|
|
29
|
+
const secret = ctx.useSecret(t.verify.secret, `webhook '${t.name}'`);
|
|
30
|
+
if (t.verify.scheme === 'bearer')
|
|
31
|
+
return { kind: 'webhook', name: t.name, verify: { scheme: 'bearer', secret } };
|
|
32
|
+
if (!t.verify.header)
|
|
33
|
+
ctx.problems.push(`webhook '${t.name}': the signature header must not be empty`);
|
|
34
|
+
return {
|
|
35
|
+
kind: 'webhook',
|
|
36
|
+
name: t.name,
|
|
37
|
+
verify: {
|
|
38
|
+
scheme: 'hmac-sha256',
|
|
39
|
+
secret,
|
|
40
|
+
header: t.verify.header,
|
|
41
|
+
prefix: t.verify.prefix,
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function describePoll(
|
|
47
|
+
ctx: DescribeContext,
|
|
48
|
+
t: Extract<TriggerNode, { kind: 'poll' }>,
|
|
49
|
+
): TriggerT {
|
|
50
|
+
const problems = ctx.problems;
|
|
51
|
+
if (!STEP_NAME.test(t.name))
|
|
52
|
+
problems.push(`poll trigger name '${t.name}' must be lower-case kebab`);
|
|
53
|
+
if (!(Number.isInteger(t.intervalSeconds) && t.intervalSeconds > 0))
|
|
54
|
+
problems.push(`poll '${t.name}': intervalSeconds must be a positive integer`);
|
|
55
|
+
const declared = new Set(t.secrets.map((s) => ctx.useSecret(s, `poll '${t.name}'`)));
|
|
56
|
+
for (const [h, v] of Object.entries(t.headers)) {
|
|
57
|
+
for (const m of v.matchAll(/\$SECRET:([A-Za-z0-9_]+)/g))
|
|
58
|
+
if (!declared.has(m[1]!))
|
|
59
|
+
problems.push(
|
|
60
|
+
`poll '${t.name}': header '${h}' references secret '${m[1]}', which is not in the poll's secrets`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
kind: 'poll',
|
|
65
|
+
name: t.name,
|
|
66
|
+
url: t.url,
|
|
67
|
+
headers: { ...t.headers },
|
|
68
|
+
secrets: [...declared].sort(),
|
|
69
|
+
valuePath: t.valuePath,
|
|
70
|
+
intervalSeconds: t.intervalSeconds,
|
|
71
|
+
};
|
|
72
|
+
}
|