@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
package/src/ctx/sh.ts
CHANGED
|
@@ -1,32 +1,49 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
statSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs';
|
|
9
|
+
import { basename, isAbsolute, join } from 'node:path';
|
|
10
|
+
import type { ExecOptions, Sh, ShOptions } from './types.ts';
|
|
11
|
+
import { PRIVATE_ENV } from '../harness/env.ts';
|
|
5
12
|
|
|
6
13
|
/** Shells looked for, in order. kaniko only has /busybox/sh; distroless has none. */
|
|
7
|
-
export const SHELL_CANDIDATES = [
|
|
14
|
+
export const SHELL_CANDIDATES = ['/bin/sh', '/busybox/sh', '/bin/bash'] as const;
|
|
8
15
|
|
|
9
16
|
export class ShellNotFoundError extends Error {
|
|
10
|
-
override name =
|
|
17
|
+
override name = 'ShellNotFoundError';
|
|
11
18
|
constructor() {
|
|
12
19
|
super(
|
|
13
|
-
`no shell found in this image (looked for ${SHELL_CANDIDATES.join(
|
|
20
|
+
`no shell found in this image (looked for ${SHELL_CANDIDATES.join(', ')}). ` +
|
|
14
21
|
`ctx.sh needs a shell: use an image that has one, or run binaries directly with ctx.exec([...]).`,
|
|
15
22
|
);
|
|
16
23
|
}
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
export class CommandError extends Error {
|
|
20
|
-
override name =
|
|
21
|
-
constructor(
|
|
22
|
-
|
|
27
|
+
override name = 'CommandError';
|
|
28
|
+
constructor(
|
|
29
|
+
readonly what: string,
|
|
30
|
+
readonly exitCode: number | null,
|
|
31
|
+
readonly signalCode: string | null,
|
|
32
|
+
) {
|
|
33
|
+
super(
|
|
34
|
+
`${what} failed with ${signalCode ? `signal ${signalCode}` : `exit code ${exitCode}`}`,
|
|
35
|
+
);
|
|
23
36
|
}
|
|
24
37
|
}
|
|
25
38
|
|
|
26
39
|
/** The command went quiet for longer than its `idleTimeoutSeconds` and was killed. */
|
|
27
40
|
export class CommandStalledError extends CommandError {
|
|
28
|
-
override name =
|
|
29
|
-
constructor(
|
|
41
|
+
override name = 'CommandStalledError';
|
|
42
|
+
constructor(
|
|
43
|
+
what: string,
|
|
44
|
+
readonly idleTimeoutSeconds: number,
|
|
45
|
+
signalCode: string | null,
|
|
46
|
+
) {
|
|
30
47
|
super(what, null, signalCode);
|
|
31
48
|
this.message = `${what} produced no output for ${idleTimeoutSeconds} s and was killed`;
|
|
32
49
|
}
|
|
@@ -42,7 +59,7 @@ export function detectShell(candidates: readonly string[] = SHELL_CANDIDATES): s
|
|
|
42
59
|
|
|
43
60
|
/** True for the value of `import x from "./x.sh" with { type: "file" }` (compiled or from source). */
|
|
44
61
|
export function isEmbeddedScript(s: string): boolean {
|
|
45
|
-
if (s.startsWith(
|
|
62
|
+
if (s.startsWith('/$bunfs/') || s.startsWith('B:/~BUN/')) return true;
|
|
46
63
|
if (!isAbsolute(s) || !/\.(sh|bash)$/.test(s) || /\s/.test(s)) return false;
|
|
47
64
|
try {
|
|
48
65
|
return statSync(s).isFile();
|
|
@@ -54,17 +71,23 @@ export function isEmbeddedScript(s: string): boolean {
|
|
|
54
71
|
/** Environment for child processes: the harness env minus harness-only credentials, plus extras. */
|
|
55
72
|
export function childEnv(extra?: Record<string, string>): Record<string, string> {
|
|
56
73
|
const env: Record<string, string> = {};
|
|
57
|
-
for (const [k, v] of Object.entries(process.env))
|
|
74
|
+
for (const [k, v] of Object.entries(process.env))
|
|
75
|
+
if (v !== undefined && !PRIVATE_ENV.includes(k)) env[k] = v;
|
|
58
76
|
return { ...env, ...extra };
|
|
59
77
|
}
|
|
60
78
|
|
|
61
79
|
export interface ShDeps {
|
|
62
80
|
tmpDir: string;
|
|
63
81
|
signal: AbortSignal;
|
|
82
|
+
/** Pipe commands' output through this process's stdout/stderr (where secrets are masked). */
|
|
83
|
+
pipeOutput?: boolean;
|
|
64
84
|
shellCandidates?: readonly string[];
|
|
65
85
|
}
|
|
66
86
|
|
|
67
|
-
export function createSh(deps: ShDeps): {
|
|
87
|
+
export function createSh(deps: ShDeps): {
|
|
88
|
+
sh: Sh;
|
|
89
|
+
exec: (argv: readonly string[], opts?: ExecOptions) => Promise<void>;
|
|
90
|
+
} {
|
|
68
91
|
const copied = new Map<string, string>();
|
|
69
92
|
let counter = 0;
|
|
70
93
|
|
|
@@ -72,7 +95,7 @@ export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[]
|
|
|
72
95
|
const materialize = (script: string): string => {
|
|
73
96
|
const hit = copied.get(script);
|
|
74
97
|
if (hit) return hit;
|
|
75
|
-
const dir = join(deps.tmpDir,
|
|
98
|
+
const dir = join(deps.tmpDir, 'scripts');
|
|
76
99
|
mkdirSync(dir, { recursive: true });
|
|
77
100
|
const dest = join(dir, `${++counter}-${basename(script)}`);
|
|
78
101
|
writeFileSync(dest, readFileSync(script));
|
|
@@ -81,21 +104,34 @@ export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[]
|
|
|
81
104
|
return dest;
|
|
82
105
|
};
|
|
83
106
|
|
|
84
|
-
const argvFor = (
|
|
85
|
-
|
|
107
|
+
const argvFor = (
|
|
108
|
+
cmd: string | { script: string },
|
|
109
|
+
args: readonly string[],
|
|
110
|
+
): { argv: string[]; what: string } => {
|
|
111
|
+
const scriptPath =
|
|
112
|
+
typeof cmd === 'object' ? cmd.script : isEmbeddedScript(cmd) ? cmd : undefined;
|
|
86
113
|
if (scriptPath === undefined) {
|
|
87
114
|
const shell = detectShell(deps.shellCandidates);
|
|
88
115
|
// `sh -c cmd sh a b`: the args are $1, $2… of the command.
|
|
89
|
-
return {
|
|
116
|
+
return {
|
|
117
|
+
argv: [shell, '-c', cmd as string, 'sh', ...args],
|
|
118
|
+
what: `command \`${truncate(cmd as string)}\``,
|
|
119
|
+
};
|
|
90
120
|
}
|
|
91
121
|
const file = materialize(scriptPath);
|
|
92
|
-
const first = readFileSync(file,
|
|
122
|
+
const first = readFileSync(file, 'utf8').split('\n', 1)[0] ?? '';
|
|
93
123
|
const shebang = /^#!\s*(\S+)(?:\s+(.*))?$/.exec(first);
|
|
94
124
|
if (shebang && existsSync(shebang[1]!)) {
|
|
95
125
|
const interpArgs = shebang[2] ? [shebang[2].trim()] : [];
|
|
96
|
-
return {
|
|
126
|
+
return {
|
|
127
|
+
argv: [shebang[1]!, ...interpArgs, file, ...args],
|
|
128
|
+
what: `script ${basename(scriptPath)}`,
|
|
129
|
+
};
|
|
97
130
|
}
|
|
98
|
-
return {
|
|
131
|
+
return {
|
|
132
|
+
argv: [detectShell(deps.shellCandidates), file, ...args],
|
|
133
|
+
what: `script ${basename(scriptPath)}`,
|
|
134
|
+
};
|
|
99
135
|
};
|
|
100
136
|
|
|
101
137
|
const run = async (
|
|
@@ -104,22 +140,26 @@ export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[]
|
|
|
104
140
|
opts: { env?: Record<string, string>; cwd?: string; idleTimeoutSeconds?: number },
|
|
105
141
|
capture: boolean,
|
|
106
142
|
): Promise<string> => {
|
|
107
|
-
if (deps.signal.aborted)
|
|
108
|
-
|
|
109
|
-
|
|
143
|
+
if (deps.signal.aborted)
|
|
144
|
+
throw new Error(`${what} not started: the step was cancelled`);
|
|
145
|
+
const idleSeconds =
|
|
146
|
+
opts.idleTimeoutSeconds !== undefined && opts.idleTimeoutSeconds > 0
|
|
147
|
+
? opts.idleTimeoutSeconds
|
|
148
|
+
: undefined;
|
|
149
|
+
let proc: Bun.Subprocess<'ignore', 'pipe' | 'inherit', 'pipe' | 'inherit'>;
|
|
110
150
|
try {
|
|
111
151
|
proc = Bun.spawn(argv, {
|
|
112
152
|
cwd: opts.cwd,
|
|
113
153
|
env: childEnv(opts.env),
|
|
114
|
-
stdin:
|
|
115
|
-
stdout: capture || idleSeconds ?
|
|
116
|
-
stderr: idleSeconds ?
|
|
154
|
+
stdin: 'ignore',
|
|
155
|
+
stdout: capture || idleSeconds || deps.pipeOutput ? 'pipe' : 'inherit',
|
|
156
|
+
stderr: idleSeconds || deps.pipeOutput ? 'pipe' : 'inherit',
|
|
117
157
|
});
|
|
118
158
|
} catch (e) {
|
|
119
159
|
throw new Error(`cannot start ${what} (${argv[0]}): ${(e as Error).message}`);
|
|
120
160
|
}
|
|
121
|
-
const onAbort = () => proc.kill(
|
|
122
|
-
deps.signal.addEventListener(
|
|
161
|
+
const onAbort = () => proc.kill('SIGTERM');
|
|
162
|
+
deps.signal.addEventListener('abort', onAbort, { once: true });
|
|
123
163
|
|
|
124
164
|
// Watchdog: every chunk of output re-arms the timer; on expiry TERM, then KILL after a grace.
|
|
125
165
|
let stalled = false;
|
|
@@ -130,13 +170,16 @@ export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[]
|
|
|
130
170
|
clearTimeout(idleTimer);
|
|
131
171
|
idleTimer = setTimeout(() => {
|
|
132
172
|
stalled = true;
|
|
133
|
-
proc.kill(
|
|
134
|
-
killTimer = setTimeout(() => proc.kill(
|
|
173
|
+
proc.kill('SIGTERM');
|
|
174
|
+
killTimer = setTimeout(() => proc.kill('SIGKILL'), STALL_KILL_GRACE_MS);
|
|
135
175
|
}, idleSeconds * 1000);
|
|
136
176
|
};
|
|
137
177
|
const captured: Uint8Array[] = [];
|
|
138
178
|
const readers: { cancel(): Promise<void> }[] = [];
|
|
139
|
-
const pump = async (
|
|
179
|
+
const pump = async (
|
|
180
|
+
stream: ReadableStream<Uint8Array>,
|
|
181
|
+
sink: (chunk: Uint8Array) => void,
|
|
182
|
+
) => {
|
|
140
183
|
const reader = stream.getReader();
|
|
141
184
|
readers.push(reader);
|
|
142
185
|
try {
|
|
@@ -151,9 +194,18 @@ export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[]
|
|
|
151
194
|
}
|
|
152
195
|
};
|
|
153
196
|
const pumps: Promise<void>[] = [];
|
|
154
|
-
if (capture)
|
|
155
|
-
|
|
156
|
-
|
|
197
|
+
if (capture)
|
|
198
|
+
pumps.push(
|
|
199
|
+
pump(proc.stdout as ReadableStream<Uint8Array>, (c) => captured.push(c)),
|
|
200
|
+
);
|
|
201
|
+
else if (idleSeconds || deps.pipeOutput)
|
|
202
|
+
pumps.push(
|
|
203
|
+
pump(proc.stdout as ReadableStream<Uint8Array>, (c) => process.stdout.write(c)),
|
|
204
|
+
);
|
|
205
|
+
if (idleSeconds || deps.pipeOutput)
|
|
206
|
+
pumps.push(
|
|
207
|
+
pump(proc.stderr as ReadableStream<Uint8Array>, (c) => process.stderr.write(c)),
|
|
208
|
+
);
|
|
157
209
|
arm();
|
|
158
210
|
|
|
159
211
|
try {
|
|
@@ -161,13 +213,23 @@ export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[]
|
|
|
161
213
|
// A killed command's children may still hold the pipes open: don't wait for them after a stall.
|
|
162
214
|
if (stalled) await Promise.all(readers.map((r) => r.cancel().catch(() => {})));
|
|
163
215
|
else await Promise.all(pumps);
|
|
164
|
-
if (stalled)
|
|
165
|
-
|
|
166
|
-
|
|
216
|
+
if (stalled)
|
|
217
|
+
throw new CommandStalledError(
|
|
218
|
+
what,
|
|
219
|
+
idleSeconds!,
|
|
220
|
+
proc.signalCode ? String(proc.signalCode) : null,
|
|
221
|
+
);
|
|
222
|
+
if (code !== 0)
|
|
223
|
+
throw new CommandError(
|
|
224
|
+
what,
|
|
225
|
+
proc.signalCode ? null : code,
|
|
226
|
+
proc.signalCode ? String(proc.signalCode) : null,
|
|
227
|
+
);
|
|
228
|
+
return capture ? Buffer.concat(captured).toString('utf8') : '';
|
|
167
229
|
} finally {
|
|
168
230
|
clearTimeout(idleTimer);
|
|
169
231
|
clearTimeout(killTimer);
|
|
170
|
-
deps.signal.removeEventListener(
|
|
232
|
+
deps.signal.removeEventListener('abort', onAbort);
|
|
171
233
|
}
|
|
172
234
|
};
|
|
173
235
|
|
|
@@ -185,13 +247,13 @@ export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[]
|
|
|
185
247
|
) as Sh;
|
|
186
248
|
|
|
187
249
|
const exec = async (argv: readonly string[], opts: ExecOptions = {}) => {
|
|
188
|
-
if (argv.length === 0) throw new Error(
|
|
189
|
-
await run([...argv], `\`${truncate(argv.join(
|
|
250
|
+
if (argv.length === 0) throw new Error('ctx.exec needs at least the program');
|
|
251
|
+
await run([...argv], `\`${truncate(argv.join(' '))}\``, opts, false);
|
|
190
252
|
};
|
|
191
253
|
return { sh, exec };
|
|
192
254
|
}
|
|
193
255
|
|
|
194
256
|
function truncate(s: string, n = 120): string {
|
|
195
|
-
const one = s.replace(/\s+/g,
|
|
196
|
-
return one.length > n ? one.slice(0, n - 1) +
|
|
257
|
+
const one = s.replace(/\s+/g, ' ').trim();
|
|
258
|
+
return one.length > n ? one.slice(0, n - 1) + '…' : one;
|
|
197
259
|
}
|
package/src/ctx/types.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { Skipped } from
|
|
2
|
-
import type { FileRef } from
|
|
3
|
-
import type {
|
|
1
|
+
import type { Skipped } from '../core/handlers.ts';
|
|
2
|
+
import type { FileRef } from '../core/models.ts';
|
|
3
|
+
import type { AnyConfigEntry, ConfigValues } from '../core/config.ts';
|
|
4
|
+
import type { DeclaredSecrets } from '../core/secrets.ts';
|
|
4
5
|
|
|
5
6
|
/** Facts about the current execution, from the service (see README "Environment"). */
|
|
6
7
|
export interface RunInfo {
|
|
@@ -79,9 +80,11 @@ export interface OutputFileOptions {
|
|
|
79
80
|
name?: string;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
export interface Ctx<Sec extends string = string> {
|
|
83
|
+
export interface Ctx<Sec extends string = string, Cfg extends AnyConfigEntry = never> {
|
|
83
84
|
/** Exactly the secrets the step declares, read from the environment. */
|
|
84
85
|
readonly secrets: DeclaredSecrets<Sec>;
|
|
86
|
+
/** Exactly the config entries the step declares, from the run's config snapshot (defaults applied). */
|
|
87
|
+
readonly config: ConfigValues<Cfg>;
|
|
85
88
|
readonly sh: Sh;
|
|
86
89
|
/** Run a binary directly, without a shell (works in shell-less images). Rejects on non-zero exit. */
|
|
87
90
|
exec(argv: readonly string[], opts?: ExecOptions): Promise<void>;
|
|
@@ -108,6 +111,23 @@ export interface Ctx<Sec extends string = string> {
|
|
|
108
111
|
readonly run: RunInfo;
|
|
109
112
|
/** A writable scratch directory (`$OLVE_DIR/tmp`; images like kaniko have no /tmp). */
|
|
110
113
|
readonly tmpDir: string;
|
|
114
|
+
/**
|
|
115
|
+
* Link to what the step made or deployed (a site, a release, an image), shown with the attempt in the
|
|
116
|
+
* UI and `pl run get`. Absolute http(s) URL; at most 10 per attempt.
|
|
117
|
+
*/
|
|
118
|
+
link(url: string, label?: string): void;
|
|
111
119
|
/** Runtime escape hatch: produce no output. Prefer `when` blocks. */
|
|
112
120
|
skip(reason: string): Skipped;
|
|
121
|
+
/**
|
|
122
|
+
* This run's callback URL for an external input with `from: [callback()]` (of any step of the pipeline):
|
|
123
|
+
* hand it to the system that will answer. POST the value as JSON, or `{"$decline": "reason"}`.
|
|
124
|
+
*/
|
|
125
|
+
inputUrl(step: string | { readonly name: string }, input: string): string;
|
|
113
126
|
}
|
|
127
|
+
|
|
128
|
+
/** The ctx a step's handler gets: a matrix step's also carries its cell (`ctx.cell`), in axis order. */
|
|
129
|
+
export type StepCtx<Sec extends string, Cfg extends AnyConfigEntry, Cell> = Ctx<
|
|
130
|
+
Sec,
|
|
131
|
+
Cfg
|
|
132
|
+
> &
|
|
133
|
+
([Cell] extends [never] ? unknown : { readonly cell: Cell });
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { SECRET_REF_KIND } from '../core/config.ts';
|
|
2
|
+
import { CONFIG_CASE_KEY, CONFIG_REF_KEY } from '../core/config-refs.ts';
|
|
3
|
+
import type { JsonSchema } from './schemas.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The schemas at `path` inside an entry's schema (through properties and records): every union branch that has the field, except where
|
|
7
|
+
* `kase` (`<entry>.<path>.<discriminator>=<value>`) narrows a union to one branch.
|
|
8
|
+
*/
|
|
9
|
+
export function subSchemas(
|
|
10
|
+
schema: unknown,
|
|
11
|
+
path: readonly string[],
|
|
12
|
+
kase: string | undefined,
|
|
13
|
+
entry: string,
|
|
14
|
+
): unknown[] {
|
|
15
|
+
let narrow: { at: string; discriminator: string; value: string } | undefined;
|
|
16
|
+
if (kase) {
|
|
17
|
+
const eq = kase.lastIndexOf('=');
|
|
18
|
+
const left = kase.slice(0, eq);
|
|
19
|
+
const dot = left.lastIndexOf('.');
|
|
20
|
+
narrow = {
|
|
21
|
+
at: left.slice(0, dot),
|
|
22
|
+
discriminator: left.slice(dot + 1),
|
|
23
|
+
value: kase.slice(eq + 1),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const walk = (node: unknown, rest: readonly string[], here: string): unknown[] => {
|
|
27
|
+
if (node === null || typeof node !== 'object') return [];
|
|
28
|
+
const o = node as JsonSchema;
|
|
29
|
+
const branches = (o.oneOf ?? o.anyOf) as JsonSchema[] | undefined;
|
|
30
|
+
if (Array.isArray(branches)) {
|
|
31
|
+
const chosen =
|
|
32
|
+
narrow && narrow.at === here
|
|
33
|
+
? branches.filter(
|
|
34
|
+
(b) =>
|
|
35
|
+
(
|
|
36
|
+
(b.properties as Record<string, JsonSchema> | undefined)?.[
|
|
37
|
+
narrow!.discriminator
|
|
38
|
+
] as JsonSchema | undefined
|
|
39
|
+
)?.const === narrow!.value,
|
|
40
|
+
)
|
|
41
|
+
: branches;
|
|
42
|
+
return chosen.flatMap((b) => walk(b, rest, here));
|
|
43
|
+
}
|
|
44
|
+
if (rest.length === 0) return [node];
|
|
45
|
+
const props = o.properties as Record<string, JsonSchema> | undefined;
|
|
46
|
+
if (props && Object.hasOwn(props, rest[0]!))
|
|
47
|
+
return walk(props[rest[0]!], rest.slice(1), `${here}.${rest[0]}`);
|
|
48
|
+
if (o.additionalProperties && typeof o.additionalProperties === 'object')
|
|
49
|
+
return walk(o.additionalProperties, rest.slice(1), `${here}.${rest[0]}`);
|
|
50
|
+
return [];
|
|
51
|
+
};
|
|
52
|
+
return walk(schema, path, entry);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Secret names of every secretRef inside a config entry's JSON Schema. */
|
|
56
|
+
export function secretRefsOf(schema: unknown): string[] {
|
|
57
|
+
const out = new Set<string>();
|
|
58
|
+
const walk = (v: unknown) => {
|
|
59
|
+
if (Array.isArray(v)) return v.forEach(walk);
|
|
60
|
+
if (v === null || typeof v !== 'object') return;
|
|
61
|
+
const o = v as JsonSchema;
|
|
62
|
+
if (o['x-olve-kind'] === SECRET_REF_KIND) {
|
|
63
|
+
const name = (o.properties as { secret?: { const?: unknown } } | undefined)?.secret
|
|
64
|
+
?.const;
|
|
65
|
+
if (typeof name === 'string') out.add(name);
|
|
66
|
+
}
|
|
67
|
+
Object.values(o).forEach(walk);
|
|
68
|
+
};
|
|
69
|
+
walk(schema);
|
|
70
|
+
return [...out].sort();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The `{"$config": "<entry>.<path>"[, "$case": …]}` references in a step's recorded settings. */
|
|
74
|
+
export function recordedConfigRefs(
|
|
75
|
+
v: unknown,
|
|
76
|
+
out: { ref: string; case?: string }[] = [],
|
|
77
|
+
): { ref: string; case?: string }[] {
|
|
78
|
+
if (Array.isArray(v)) v.forEach((x) => recordedConfigRefs(x, out));
|
|
79
|
+
else if (v !== null && typeof v === 'object') {
|
|
80
|
+
const o = v as Record<string, unknown>;
|
|
81
|
+
const keys = Object.keys(o).sort();
|
|
82
|
+
const isRef =
|
|
83
|
+
typeof o[CONFIG_REF_KEY] === 'string' &&
|
|
84
|
+
(keys.length === 1
|
|
85
|
+
? keys[0] === CONFIG_REF_KEY
|
|
86
|
+
: keys.length === 2 &&
|
|
87
|
+
keys[0] === CONFIG_CASE_KEY &&
|
|
88
|
+
keys[1] === CONFIG_REF_KEY &&
|
|
89
|
+
typeof o[CONFIG_CASE_KEY] === 'string');
|
|
90
|
+
if (isRef)
|
|
91
|
+
out.push({
|
|
92
|
+
ref: o[CONFIG_REF_KEY] as string,
|
|
93
|
+
...(keys.length === 2 ? { case: o[CONFIG_CASE_KEY] as string } : {}),
|
|
94
|
+
});
|
|
95
|
+
else Object.values(o).forEach((x) => recordedConfigRefs(x, out));
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { CONFIG_NAME, isConfigEntry } from '../core/config.ts';
|
|
3
|
+
import { MODEL_NAME, type AnyModel } from '../core/models.ts';
|
|
4
|
+
import { SECRET_NAME, type Secret } from '../core/secrets.ts';
|
|
5
|
+
import { RESERVED_STEP_NAMES, STEP_NAME, type AnyStep } from '../core/steps.ts';
|
|
6
|
+
import { canonicalize } from '../util/canonical.ts';
|
|
7
|
+
import { secretRefsOf } from './config-schema.ts';
|
|
8
|
+
import type { ManifestT, ModelSchemasT } from './manifest-schema.ts';
|
|
9
|
+
import { schemaViews, type JsonSchema } from './schemas.ts';
|
|
10
|
+
|
|
11
|
+
/** Root name used in failure-handler mappings (the service supplies FailureContext under it). */
|
|
12
|
+
export const FAILURE_ROOT = 'failure';
|
|
13
|
+
const RESERVED = new Set([...RESERVED_STEP_NAMES, FAILURE_ROOT]);
|
|
14
|
+
|
|
15
|
+
/** What a step can read at a point of the pipeline: its output model, whether it may have been skipped, and whether it is a matrix step. */
|
|
16
|
+
export interface Avail {
|
|
17
|
+
model: string | null;
|
|
18
|
+
maybe: boolean;
|
|
19
|
+
matrix?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A select case a step sits in: which select (numbered in describe order), which value, and its condition text. */
|
|
23
|
+
export interface CaseOf {
|
|
24
|
+
select: number;
|
|
25
|
+
value: string;
|
|
26
|
+
cond: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ConfigUse {
|
|
30
|
+
description: string;
|
|
31
|
+
required: boolean;
|
|
32
|
+
schema: JsonSchema;
|
|
33
|
+
key: string;
|
|
34
|
+
secretRefs: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Two places are mutually exclusive when they sit in different cases of the same select.
|
|
38
|
+
const exclusive = (a: readonly CaseOf[], b: readonly CaseOf[]) =>
|
|
39
|
+
a.some((x) => b.some((y) => y.select === x.select && y.value !== x.value));
|
|
40
|
+
|
|
41
|
+
const byName = ([a]: [string, unknown], [b]: [string, unknown]) =>
|
|
42
|
+
a < b ? -1 : a > b ? 1 : 0;
|
|
43
|
+
|
|
44
|
+
/** The problems found so far and the models, secrets, config entries and step names the pipeline uses. */
|
|
45
|
+
export class DescribeContext {
|
|
46
|
+
readonly problems: string[];
|
|
47
|
+
readonly models = new Map<string, { schemas: ModelSchemasT; key: string }>();
|
|
48
|
+
readonly secrets = new Map<string, string>();
|
|
49
|
+
readonly configs = new Map<string, ConfigUse>();
|
|
50
|
+
readonly steps = new Map<string, AnyStep>();
|
|
51
|
+
private readonly placements = new Map<
|
|
52
|
+
string,
|
|
53
|
+
{ step: AnyStep; cases: readonly CaseOf[] }[]
|
|
54
|
+
>();
|
|
55
|
+
private selects = 0;
|
|
56
|
+
|
|
57
|
+
constructor(problems: readonly string[]) {
|
|
58
|
+
this.problems = [...problems];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
nextSelectId(): number {
|
|
62
|
+
return ++this.selects;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
useModel(m: AnyModel, at: string): string {
|
|
66
|
+
const problems = this.problems;
|
|
67
|
+
if (!MODEL_NAME.test(m.name))
|
|
68
|
+
problems.push(`${at}: model name '${m.name}' must match ${MODEL_NAME}`);
|
|
69
|
+
let schemas: ModelSchemasT;
|
|
70
|
+
try {
|
|
71
|
+
schemas = schemaViews(m.schema);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
problems.push(
|
|
74
|
+
`${at}: model '${m.name}' is not representable as JSON Schema (${(e as Error).message})`,
|
|
75
|
+
);
|
|
76
|
+
return m.name;
|
|
77
|
+
}
|
|
78
|
+
const key = JSON.stringify(schemas);
|
|
79
|
+
const prev = this.models.get(m.name);
|
|
80
|
+
if (!prev) this.models.set(m.name, { schemas, key });
|
|
81
|
+
else if (prev.key !== key)
|
|
82
|
+
problems.push(
|
|
83
|
+
`two different schemas are registered under the model name '${m.name}' (one schema per model name)`,
|
|
84
|
+
);
|
|
85
|
+
return m.name;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
outputViewOf(modelName: string | null): JsonSchema | undefined {
|
|
89
|
+
return modelName ? this.models.get(modelName)?.schemas.output : undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
useSecret(s: Secret, at: string): string {
|
|
93
|
+
if (!SECRET_NAME.test(s.name))
|
|
94
|
+
this.problems.push(`${at}: secret name '${s.name}' must match ${SECRET_NAME}`);
|
|
95
|
+
const prev = this.secrets.get(s.name);
|
|
96
|
+
if (prev === undefined) this.secrets.set(s.name, s.description);
|
|
97
|
+
else if (prev !== s.description)
|
|
98
|
+
this.problems.push(
|
|
99
|
+
`secret '${s.name}' is declared twice with different descriptions`,
|
|
100
|
+
);
|
|
101
|
+
return s.name;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
useConfig(e: unknown, at: string): string | undefined {
|
|
105
|
+
const problems = this.problems;
|
|
106
|
+
if (!isConfigEntry(e))
|
|
107
|
+
return void problems.push(
|
|
108
|
+
`${at}: config entries must be config("name", "description", schema)`,
|
|
109
|
+
);
|
|
110
|
+
if (!CONFIG_NAME.test(e.name))
|
|
111
|
+
problems.push(`${at}: config name '${e.name}' must match ${CONFIG_NAME}`);
|
|
112
|
+
let schema: JsonSchema;
|
|
113
|
+
try {
|
|
114
|
+
schema = z.toJSONSchema(e.schema, {
|
|
115
|
+
io: 'input',
|
|
116
|
+
target: 'draft-2020-12',
|
|
117
|
+
}) as JsonSchema;
|
|
118
|
+
delete schema.$schema;
|
|
119
|
+
schema = canonicalize(schema) as JsonSchema;
|
|
120
|
+
} catch (err) {
|
|
121
|
+
problems.push(
|
|
122
|
+
`${at}: config '${e.name}' is not representable as JSON Schema (${(err as Error).message})`,
|
|
123
|
+
);
|
|
124
|
+
return e.name;
|
|
125
|
+
}
|
|
126
|
+
const key = JSON.stringify([e.description, schema]);
|
|
127
|
+
const prev = this.configs.get(e.name);
|
|
128
|
+
if (!prev)
|
|
129
|
+
this.configs.set(e.name, {
|
|
130
|
+
description: e.description,
|
|
131
|
+
required: !e.schema.safeParse(undefined).success,
|
|
132
|
+
schema,
|
|
133
|
+
key,
|
|
134
|
+
secretRefs: secretRefsOf(schema),
|
|
135
|
+
});
|
|
136
|
+
else if (prev.key !== key)
|
|
137
|
+
problems.push(
|
|
138
|
+
`two different config entries are named '${e.name}' (one description and schema per config name)`,
|
|
139
|
+
);
|
|
140
|
+
return e.name;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
registerStep(s: AnyStep, where: string, cases: readonly CaseOf[] = []): void {
|
|
144
|
+
const problems = this.problems;
|
|
145
|
+
if (typeof s.name !== 'string' || !STEP_NAME.test(s.name))
|
|
146
|
+
problems.push(
|
|
147
|
+
`${where}: step name '${String(s.name)}' must be lower-case kebab (${STEP_NAME})`,
|
|
148
|
+
);
|
|
149
|
+
if (RESERVED.has(s.name))
|
|
150
|
+
problems.push(`${where}: '${s.name}' is reserved and cannot be a step name`);
|
|
151
|
+
const prev = this.placements.get(s.name) ?? [];
|
|
152
|
+
if (prev.some((p) => !exclusive(p.cases, cases)))
|
|
153
|
+
problems.push(`duplicate step name '${s.name}'`);
|
|
154
|
+
else if (prev.some((p) => (p.step.output?.name ?? null) !== (s.output?.name ?? null)))
|
|
155
|
+
problems.push(
|
|
156
|
+
`step '${s.name}': its select cases must give it the same output model`,
|
|
157
|
+
);
|
|
158
|
+
else if (prev.some((p) => !p.step.matrix !== !s.matrix))
|
|
159
|
+
problems.push(
|
|
160
|
+
`step '${s.name}': its select cases must all make it a matrix step, or none`,
|
|
161
|
+
);
|
|
162
|
+
this.placements.set(s.name, [...prev, { step: s, cases }]);
|
|
163
|
+
if (!this.steps.has(s.name)) this.steps.set(s.name, s);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
modelsSection(): ManifestT['models'] {
|
|
167
|
+
return Object.fromEntries([...this.models].map(([k, v]) => [k, v.schemas]));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
secretsSection(): ManifestT['secrets'] {
|
|
171
|
+
return [...this.secrets]
|
|
172
|
+
.sort(byName)
|
|
173
|
+
.map(([name, description]) => ({ name, description }));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
configSection(): Pick<ManifestT, 'config'> {
|
|
177
|
+
if (!this.configs.size) return {};
|
|
178
|
+
return {
|
|
179
|
+
config: [...this.configs].sort(byName).map(([name, c]) => ({
|
|
180
|
+
name,
|
|
181
|
+
description: c.description,
|
|
182
|
+
required: c.required,
|
|
183
|
+
schema: c.schema,
|
|
184
|
+
})),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|