@olivervea/pipelines 0.0.0-stage → 2.0.0-beta.1
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/LICENSE +21 -0
- package/README.md +407 -2
- package/manifest.schema.json +524 -0
- package/package.json +60 -4
- package/src/assets.d.ts +10 -0
- package/src/build/build.ts +125 -0
- package/src/build/cli.ts +52 -0
- package/src/build/elf.ts +35 -0
- package/src/build/hashes.ts +67 -0
- package/src/build/project.ts +79 -0
- package/src/builder/pipeline.ts +291 -0
- package/src/builder/refs.ts +26 -0
- package/src/core/brands.ts +13 -0
- package/src/core/handlers.ts +44 -0
- package/src/core/models.ts +69 -0
- package/src/core/secrets.ts +13 -0
- package/src/core/steps.ts +107 -0
- package/src/core/types.ts +6 -0
- package/src/ctx/files.ts +161 -0
- package/src/ctx/index.ts +119 -0
- package/src/ctx/sh.ts +196 -0
- package/src/ctx/types.ts +113 -0
- package/src/describe/describe.ts +379 -0
- package/src/describe/manifest-schema.ts +131 -0
- package/src/describe/mapper-source.ts +238 -0
- package/src/describe/record.ts +107 -0
- package/src/dev/cli.ts +80 -0
- package/src/harness/env.ts +58 -0
- package/src/harness/execute.ts +94 -0
- package/src/harness/exit.ts +19 -0
- package/src/harness/index.ts +9 -0
- package/src/harness/main.ts +219 -0
- package/src/harness/result.ts +75 -0
- package/src/harness/validate.ts +74 -0
- package/src/index.ts +11 -0
- package/src/library/github-source.ts +77 -0
- package/src/library/helm-deploy-ssh.ts +151 -0
- package/src/library/http-health.ts +34 -0
- package/src/library/image-build.ts +134 -0
- package/src/library/index.ts +14 -0
- package/src/library/models.ts +63 -0
- package/src/library/script.ts +85 -0
- package/src/testing/index.ts +86 -0
- package/src/util/canonical.ts +19 -0
- package/src/util/handler-hash.ts +22 -0
- package/src/util/hash.ts +22 -0
- package/src/util/tar.ts +154 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// olve-build: pipeline dir → single executable + step.tar.gz + manifest.json.
|
|
2
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { canonicalJson } from "../util/canonical.ts";
|
|
5
|
+
import { sha256File } from "../util/hash.ts";
|
|
6
|
+
import type { HarnessInfo } from "../util/handler-hash.ts";
|
|
7
|
+
import { writeTarGz } from "../util/tar.ts";
|
|
8
|
+
import { MANIFEST_BEGIN, MANIFEST_END, uploadFile } from "../harness/result.ts";
|
|
9
|
+
import { GLIBC_INTERP, MUSL_INTERP, readInterpreter } from "./elf.ts";
|
|
10
|
+
import { handlerHashes, type HandlerHash } from "./hashes.ts";
|
|
11
|
+
import { harnessInfo, loadProject, relPosix, type Project } from "./project.ts";
|
|
12
|
+
|
|
13
|
+
export const BUILD_FLAGS = ["--no-compile-autoload-dotenv", "--no-compile-autoload-bunfig", "--bytecode", "--format=esm"] as const;
|
|
14
|
+
export type Target = "bun-linux-x64" | "bun-linux-x64-musl";
|
|
15
|
+
|
|
16
|
+
export class BuildError extends Error {
|
|
17
|
+
override name = "BuildError";
|
|
18
|
+
constructor(message: string, readonly exitCode = 1) {
|
|
19
|
+
super(message);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface BuildOptions {
|
|
24
|
+
out: string;
|
|
25
|
+
target?: Target;
|
|
26
|
+
/** Pre-signed PUT for step.tar.gz. */
|
|
27
|
+
uploadUrl?: string;
|
|
28
|
+
/** Print the manifest between sentinels on stdout (for the build Job's pod log). */
|
|
29
|
+
sentinels?: boolean;
|
|
30
|
+
log?: (line: string) => void;
|
|
31
|
+
stdout?: (text: string) => Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface BuildResult {
|
|
35
|
+
binary: string;
|
|
36
|
+
archive: string;
|
|
37
|
+
manifestFile: string;
|
|
38
|
+
manifest: string;
|
|
39
|
+
hashes: Record<string, HandlerHash>;
|
|
40
|
+
harness: HarnessInfo;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Writes `<dir>/.olve/gen/{handlers,entry}.ts`: the literal-import handler registry + binary entry. */
|
|
44
|
+
export function generateEntry(project: Project, hashes: Record<string, string>, harness: HarnessInfo): string {
|
|
45
|
+
const gen = join(project.dir, ".olve", "gen");
|
|
46
|
+
mkdirSync(gen, { recursive: true });
|
|
47
|
+
const registry = project.handlers.map(([name, file]) => ` ${JSON.stringify(name)}: () => import(${JSON.stringify(relPosix(gen, file))}),`).join("\n");
|
|
48
|
+
writeFileSync(
|
|
49
|
+
join(gen, "handlers.ts"),
|
|
50
|
+
`// GENERATED by olve-build — do not edit.\n` +
|
|
51
|
+
`import type { HandlerRegistry, HarnessInfo } from "@olve/pipelines/harness";\n\n` +
|
|
52
|
+
`export const handlers: HandlerRegistry = {\n${registry}\n};\n\n` +
|
|
53
|
+
`export const handlerHashes: Record<string, string> = ${JSON.stringify(hashes, null, 2)};\n\n` +
|
|
54
|
+
`export const harness: HarnessInfo = ${JSON.stringify(harness, null, 2)};\n`,
|
|
55
|
+
);
|
|
56
|
+
const entry = join(gen, "entry.ts");
|
|
57
|
+
writeFileSync(
|
|
58
|
+
entry,
|
|
59
|
+
`// GENERATED by olve-build — do not edit. Entry point of the step binary.\n` +
|
|
60
|
+
`import pipeline from ${JSON.stringify(relPosix(gen, project.pipelineFile))};\n` +
|
|
61
|
+
`import { runHarness } from "@olve/pipelines/harness";\n` +
|
|
62
|
+
`import { handlers, handlerHashes, harness } from "./handlers.ts";\n\n` +
|
|
63
|
+
`process.exit(await runHarness({ pipeline, handlers, handlerHashes, harness }, process.argv.slice(2)));\n`,
|
|
64
|
+
);
|
|
65
|
+
return entry;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function build(dir: string, opts: BuildOptions): Promise<BuildResult> {
|
|
69
|
+
const log = opts.log ?? ((l: string) => process.stderr.write(l + "\n"));
|
|
70
|
+
const stdout = opts.stdout ?? (async (t: string) => void (await Bun.write(Bun.stdout, t)));
|
|
71
|
+
const target = opts.target ?? "bun-linux-x64";
|
|
72
|
+
const project = loadProject(dir);
|
|
73
|
+
const out = resolve(opts.out);
|
|
74
|
+
mkdirSync(out, { recursive: true });
|
|
75
|
+
const harness = harnessInfo();
|
|
76
|
+
|
|
77
|
+
const t0 = performance.now();
|
|
78
|
+
const hashes = await handlerHashes(project, harness);
|
|
79
|
+
const entry = generateEntry(project, Object.fromEntries(Object.entries(hashes).map(([k, v]) => [k, v.handlerHash])), harness);
|
|
80
|
+
log(`olve-build: ${project.handlers.length} handler(s) hashed in ${((performance.now() - t0) / 1000).toFixed(2)}s`);
|
|
81
|
+
|
|
82
|
+
// 1. compile
|
|
83
|
+
const binary = join(out, "step");
|
|
84
|
+
rmSync(binary, { force: true });
|
|
85
|
+
const t1 = performance.now();
|
|
86
|
+
const p = Bun.spawnSync([process.execPath, "build", "--compile", `--target=${target}`, ...BUILD_FLAGS, entry, `--outfile=${binary}`], {
|
|
87
|
+
cwd: project.dir,
|
|
88
|
+
stdout: "pipe",
|
|
89
|
+
stderr: "pipe",
|
|
90
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
91
|
+
});
|
|
92
|
+
if (p.exitCode !== 0 || !existsSync(binary))
|
|
93
|
+
throw new BuildError(`bun build --compile failed (exit ${p.exitCode}):\n${p.stdout.toString()}${p.stderr.toString()}`);
|
|
94
|
+
log(`olve-build: compiled ${target} in ${((performance.now() - t1) / 1000).toFixed(2)}s`);
|
|
95
|
+
|
|
96
|
+
// 2. loader check: the harness launches the binary through /olve/lib/<loader>; a musl binary
|
|
97
|
+
// (e.g. compiled on alpine, where bun-linux-x64 silently yields musl) would not start.
|
|
98
|
+
const expected = target === "bun-linux-x64" ? GLIBC_INTERP : MUSL_INTERP;
|
|
99
|
+
const interp = readInterpreter(binary);
|
|
100
|
+
if (interp !== expected)
|
|
101
|
+
throw new BuildError(`compiled binary has PT_INTERP ${interp ?? "(none)"}, expected ${expected} — compile on a glibc Bun image (oven/bun:<version>-slim), not alpine`);
|
|
102
|
+
|
|
103
|
+
// 3. describe (runs the real binary: the manifest is exactly what the step pods will see)
|
|
104
|
+
const d = Bun.spawnSync([binary, "describe"], { cwd: out, stdout: "pipe", stderr: "pipe", env: { PATH: process.env.PATH ?? "/usr/bin:/bin" } });
|
|
105
|
+
if (d.exitCode !== 0) throw new BuildError(`describe failed (exit ${d.exitCode}):\n${d.stderr.toString()}`, d.exitCode ?? 1);
|
|
106
|
+
const manifest = d.stdout.toString();
|
|
107
|
+
const manifestFile = join(out, "manifest.json");
|
|
108
|
+
writeFileSync(manifestFile, manifest);
|
|
109
|
+
|
|
110
|
+
// 4. step.tar.gz (deterministic: `step` at the root, 0755, mtime 0)
|
|
111
|
+
const archive = join(out, "step.tar.gz");
|
|
112
|
+
await writeTarGz(archive, [{ path: "step", type: "file", mode: 0o755, source: binary }]);
|
|
113
|
+
writeFileSync(
|
|
114
|
+
join(out, "build.json"),
|
|
115
|
+
canonicalJson({ target, flags: BUILD_FLAGS, harness, handlers: hashes, binarySha256: sha256File(binary), archiveSha256: sha256File(archive) }),
|
|
116
|
+
);
|
|
117
|
+
log(`olve-build: wrote ${archive}`);
|
|
118
|
+
|
|
119
|
+
if (opts.uploadUrl) {
|
|
120
|
+
await uploadFile(opts.uploadUrl, archive, log);
|
|
121
|
+
log("olve-build: uploaded step.tar.gz");
|
|
122
|
+
}
|
|
123
|
+
if (opts.sentinels) await stdout(`${MANIFEST_BEGIN}\n${manifest}${MANIFEST_END}\n`);
|
|
124
|
+
return { binary, archive, manifestFile, manifest, hashes, harness };
|
|
125
|
+
}
|
package/src/build/cli.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// olve-build <pipelineDir> --out <dir> [--target bun-linux-x64|bun-linux-x64-musl] [--upload-url <url>] [--sentinels] [--hash-only]
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { canonicalJson } from "../util/canonical.ts";
|
|
5
|
+
import { build, BuildError, type Target } from "./build.ts";
|
|
6
|
+
import { handlerHashes } from "./hashes.ts";
|
|
7
|
+
import { harnessInfo, loadProject, ProjectError } from "./project.ts";
|
|
8
|
+
|
|
9
|
+
const USAGE = "usage: olve-build <pipelineDir> --out <dir> [--target bun-linux-x64|bun-linux-x64-musl] [--upload-url <url>] [--sentinels]\n olve-build <pipelineDir> --hash-only";
|
|
10
|
+
|
|
11
|
+
async function main(): Promise<number> {
|
|
12
|
+
let parsed;
|
|
13
|
+
try {
|
|
14
|
+
parsed = parseArgs({
|
|
15
|
+
allowPositionals: true,
|
|
16
|
+
options: {
|
|
17
|
+
out: { type: "string" },
|
|
18
|
+
target: { type: "string" },
|
|
19
|
+
"upload-url": { type: "string" },
|
|
20
|
+
sentinels: { type: "boolean", default: false },
|
|
21
|
+
"hash-only": { type: "boolean", default: false },
|
|
22
|
+
help: { type: "boolean", short: "h", default: false },
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
} catch (e) {
|
|
26
|
+
console.error(`${(e as Error).message}\n${USAGE}`);
|
|
27
|
+
return 64;
|
|
28
|
+
}
|
|
29
|
+
const { values, positionals } = parsed;
|
|
30
|
+
if (values.help) return console.log(USAGE), 0;
|
|
31
|
+
if (positionals.length !== 1) return console.error(USAGE), 64;
|
|
32
|
+
const dir = positionals[0]!;
|
|
33
|
+
try {
|
|
34
|
+
if (values["hash-only"]) {
|
|
35
|
+
const h = await handlerHashes(loadProject(dir), harnessInfo());
|
|
36
|
+
await Bun.write(Bun.stdout, canonicalJson({ harness: harnessInfo(), handlers: h }));
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
if (!values.out) return console.error(`--out is required\n${USAGE}`), 64;
|
|
40
|
+
const target = (values.target ?? "bun-linux-x64") as Target;
|
|
41
|
+
if (target !== "bun-linux-x64" && target !== "bun-linux-x64-musl") return console.error(`unsupported target ${target}`), 64;
|
|
42
|
+
await build(dir, { out: values.out, target, uploadUrl: values["upload-url"], sentinels: values.sentinels });
|
|
43
|
+
return 0;
|
|
44
|
+
} catch (e) {
|
|
45
|
+
if (e instanceof BuildError) return console.error(e.message), e.exitCode;
|
|
46
|
+
if (e instanceof ProjectError) return console.error(e.message), 78;
|
|
47
|
+
console.error((e as Error)?.stack ?? String(e));
|
|
48
|
+
return 1;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (import.meta.main) process.exit(await main());
|
package/src/build/elf.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export const GLIBC_INTERP = "/lib64/ld-linux-x86-64.so.2";
|
|
4
|
+
export const MUSL_INTERP = "/lib/ld-musl-x86-64.so.1";
|
|
5
|
+
|
|
6
|
+
function read(fd: number, pos: number, len: number): Buffer {
|
|
7
|
+
const b = Buffer.alloc(len);
|
|
8
|
+
const n = readSync(fd, b, 0, len, pos);
|
|
9
|
+
if (n !== len) throw new Error("truncated ELF file");
|
|
10
|
+
return b;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** PT_INTERP of an x86-64 ELF executable, or null if it has none (static). */
|
|
14
|
+
export function readInterpreter(path: string): string | null {
|
|
15
|
+
const fd = openSync(path, "r");
|
|
16
|
+
try {
|
|
17
|
+
const h = read(fd, 0, 64);
|
|
18
|
+
if (h.readUInt32BE(0) !== 0x7f454c46) throw new Error(`${path} is not an ELF file`);
|
|
19
|
+
if (h[4] !== 2 || h[5] !== 1) throw new Error(`${path} is not a 64-bit little-endian ELF file`);
|
|
20
|
+
const phoff = Number(h.readBigUInt64LE(0x20));
|
|
21
|
+
const phentsize = h.readUInt16LE(0x36);
|
|
22
|
+
const phnum = h.readUInt16LE(0x38);
|
|
23
|
+
for (let i = 0; i < phnum; i++) {
|
|
24
|
+
const ph = read(fd, phoff + i * phentsize, phentsize);
|
|
25
|
+
if (ph.readUInt32LE(0) === 3 /* PT_INTERP */) {
|
|
26
|
+
const off = Number(ph.readBigUInt64LE(0x08));
|
|
27
|
+
const size = Number(ph.readBigUInt64LE(0x20));
|
|
28
|
+
return read(fd, off, size).toString("latin1").replace(/\0+$/, "");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
} finally {
|
|
33
|
+
closeSync(fd);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Per-handler hashes: bundle each handler on its own with the contract files and the SDK external
|
|
2
|
+
// (tree-shaken → function-level granularity; embedded scripts are bundle outputs too), then hash the
|
|
3
|
+
// outputs. Contract changes are versioned through the manifest's model/step entries, SDK changes
|
|
4
|
+
// through `harness`; both are combined in composeHandlerHash.
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import type { BunPlugin } from "bun";
|
|
7
|
+
import { composeHandlerHash, type HarnessInfo } from "../util/handler-hash.ts";
|
|
8
|
+
import { sha256Hex } from "../util/hash.ts";
|
|
9
|
+
import type { Project } from "./project.ts";
|
|
10
|
+
|
|
11
|
+
export interface HandlerHash {
|
|
12
|
+
/** Hash of the bundle outputs alone. */
|
|
13
|
+
bundleHash: string;
|
|
14
|
+
/** composeHandlerHash(bundleHash, harness) — what the manifest carries. */
|
|
15
|
+
handlerHash: string;
|
|
16
|
+
/** Bundle output names (for diagnostics). */
|
|
17
|
+
outputs: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function externals(project: Project): BunPlugin {
|
|
21
|
+
const contracts = new Set(project.contractFiles);
|
|
22
|
+
return {
|
|
23
|
+
name: "olve-handler-hash-externals",
|
|
24
|
+
setup(b) {
|
|
25
|
+
b.onResolve({ filter: /^@olve\/pipelines(\/.*)?$/ }, (args) => ({ path: args.path, external: true }));
|
|
26
|
+
b.onResolve({ filter: /^\.{1,2}\// }, (args) => {
|
|
27
|
+
const base = resolve(dirname(args.importer), args.path);
|
|
28
|
+
for (const cand of [base, `${base}.ts`, join(base, "index.ts")]) if (contracts.has(cand)) return { path: args.path, external: true };
|
|
29
|
+
return undefined;
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function bundleHash(project: Project, entry: string): Promise<{ hash: string; outputs: string[] }> {
|
|
36
|
+
const r = await Bun.build({
|
|
37
|
+
entrypoints: [entry],
|
|
38
|
+
target: "bun",
|
|
39
|
+
format: "esm",
|
|
40
|
+
root: project.dir,
|
|
41
|
+
naming: { entry: "[dir]/[name].[ext]", chunk: "[dir]/[name].[ext]", asset: "[dir]/[name].[ext]" },
|
|
42
|
+
plugins: [externals(project)],
|
|
43
|
+
throw: false,
|
|
44
|
+
});
|
|
45
|
+
if (!r.success) throw new AggregateError(r.logs, `bundling ${entry} failed:\n${r.logs.map(String).join("\n")}`);
|
|
46
|
+
const parts: string[] = [];
|
|
47
|
+
const outputs: string[] = [];
|
|
48
|
+
for (const o of [...r.outputs].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
|
|
49
|
+
const name = o.path.replace(/^\.\//, "");
|
|
50
|
+
outputs.push(name);
|
|
51
|
+
const bytes = new Uint8Array(await o.arrayBuffer());
|
|
52
|
+
// Bun prefixes each module with a `// <path>` comment relative to the *process cwd*; drop those
|
|
53
|
+
// so the hash depends only on code, not on where the repo or the build tool runs.
|
|
54
|
+
const content = name.endsWith(".js") ? new TextDecoder().decode(bytes).replace(/^\/\/ \S+\n/gm, "") : bytes;
|
|
55
|
+
parts.push(`${name}\0${sha256Hex(content)}`);
|
|
56
|
+
}
|
|
57
|
+
return { hash: "sha256:" + sha256Hex(parts.join("\n")), outputs };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function handlerHashes(project: Project, harness: HarnessInfo): Promise<Record<string, HandlerHash>> {
|
|
61
|
+
const out: Record<string, HandlerHash> = {};
|
|
62
|
+
for (const [name, file] of project.handlers) {
|
|
63
|
+
const b = await bundleHash(project, file);
|
|
64
|
+
out[name] = { bundleHash: b.hash, handlerHash: composeHandlerHash(b.hash, harness), outputs: b.outputs };
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Layout of a pipeline directory and the identity of the SDK building it.
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
import { sha256Hex } from "../util/hash.ts";
|
|
5
|
+
import type { HarnessInfo } from "../util/handler-hash.ts";
|
|
6
|
+
|
|
7
|
+
export const SDK_ROOT = resolve(import.meta.dir, "../..");
|
|
8
|
+
|
|
9
|
+
export interface Project {
|
|
10
|
+
dir: string;
|
|
11
|
+
pipelineFile: string;
|
|
12
|
+
/** Contract files (excluded from handler bundles): pipeline.ts, models.ts. */
|
|
13
|
+
contractFiles: string[];
|
|
14
|
+
/** step name → absolute handler file, sorted by name. */
|
|
15
|
+
handlers: [string, string][];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class ProjectError extends Error {
|
|
19
|
+
override name = "ProjectError";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function loadProject(dirArg: string): Project {
|
|
23
|
+
const dir = resolve(dirArg);
|
|
24
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new ProjectError(`${dirArg} is not a directory`);
|
|
25
|
+
const pipelineFile = join(dir, "pipeline.ts");
|
|
26
|
+
if (!existsSync(pipelineFile)) throw new ProjectError(`${join(dirArg, "pipeline.ts")} not found (it must default-export pipeline({...}))`);
|
|
27
|
+
const contractFiles = ["pipeline.ts", "models.ts"].map((f) => join(dir, f)).filter((f) => existsSync(f));
|
|
28
|
+
const hdir = join(dir, "handlers");
|
|
29
|
+
const handlers: [string, string][] = existsSync(hdir)
|
|
30
|
+
? readdirSync(hdir)
|
|
31
|
+
.filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts") && !f.endsWith(".d.ts") && statSync(join(hdir, f)).isFile())
|
|
32
|
+
.sort()
|
|
33
|
+
.map((f) => [f.slice(0, -3), join(hdir, f)])
|
|
34
|
+
: [];
|
|
35
|
+
return { dir, pipelineFile, contractFiles, handlers };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function listFiles(dir: string): string[] {
|
|
39
|
+
const out: string[] = [];
|
|
40
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
41
|
+
const p = join(dir, e.name);
|
|
42
|
+
if (e.isDirectory()) out.push(...listFiles(p));
|
|
43
|
+
else if (e.isFile()) out.push(p);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let cached: HarnessInfo | undefined;
|
|
49
|
+
/**
|
|
50
|
+
* Harness identity: Bun version, SDK version, and a hash over the SDK's runtime sources (src/,
|
|
51
|
+
* without the build and dev tools) plus the zod version the SDK resolves.
|
|
52
|
+
*/
|
|
53
|
+
export function harnessInfo(): HarnessInfo {
|
|
54
|
+
if (cached) return cached;
|
|
55
|
+
const pkg = JSON.parse(readFileSync(join(SDK_ROOT, "package.json"), "utf8")) as { version: string };
|
|
56
|
+
const src = join(SDK_ROOT, "src");
|
|
57
|
+
const files = listFiles(src)
|
|
58
|
+
.map((f) => relative(src, f).split("\\").join("/"))
|
|
59
|
+
.filter((f) => !f.startsWith("build/") && !f.startsWith("dev/") && !f.startsWith("testing/") && f.endsWith(".ts"))
|
|
60
|
+
.sort();
|
|
61
|
+
const h = new Bun.CryptoHasher("sha256");
|
|
62
|
+
for (const f of files) h.update(`${f}\0${sha256Hex(readFileSync(join(src, f)))}\n`);
|
|
63
|
+
let zodVersion = "unknown";
|
|
64
|
+
try {
|
|
65
|
+
const zodPkg = Bun.resolveSync("zod/package.json", SDK_ROOT);
|
|
66
|
+
zodVersion = (JSON.parse(readFileSync(zodPkg, "utf8")) as { version: string }).version;
|
|
67
|
+
} catch {
|
|
68
|
+
/* keep unknown */
|
|
69
|
+
}
|
|
70
|
+
h.update(`zod@${zodVersion}\n`);
|
|
71
|
+
cached = { bun: Bun.version, sdk: pkg.version, sdkHash: "sha256:" + h.digest("hex") };
|
|
72
|
+
return cached;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const relPosix = (from: string, to: string) => {
|
|
76
|
+
const r = relative(from, to).split("\\").join("/");
|
|
77
|
+
return r.startsWith(".") ? r : `./${r}`;
|
|
78
|
+
};
|
|
79
|
+
export { dirname };
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { PIPELINE } from "../core/brands.ts";
|
|
2
|
+
import type { AnyModel, Infer, InferInput, Plain } from "../core/models.ts";
|
|
3
|
+
import type { Secret } from "../core/secrets.ts";
|
|
4
|
+
import type { AnyStep, OutputValue, Step } from "../core/steps.ts";
|
|
5
|
+
import { isStep } from "../core/steps.ts";
|
|
6
|
+
import type { IsUnion, OrError, Simplify } from "../core/types.ts";
|
|
7
|
+
import type { Optional } from "../core/models.ts";
|
|
8
|
+
import type { FailureContext } from "../library/models.ts";
|
|
9
|
+
import type { MapperResult, Ref } from "./refs.ts";
|
|
10
|
+
|
|
11
|
+
// ─── Availability (builder state, type level) ────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
interface Slot<M extends string | null = string | null, V = unknown, Maybe extends boolean = boolean> {
|
|
14
|
+
model: M;
|
|
15
|
+
value: V;
|
|
16
|
+
maybe: Maybe;
|
|
17
|
+
}
|
|
18
|
+
type AvailMap = { [step: string]: Slot };
|
|
19
|
+
export type InitialAvail<In extends AnyModel> = { input: Slot<In["name"], Infer<In>, false> };
|
|
20
|
+
|
|
21
|
+
/** The mapper argument: keyed by step name + `input`; maybe-available outputs are `Ref<T | undefined>`. */
|
|
22
|
+
export type InRefs<A extends AvailMap> = {
|
|
23
|
+
readonly [K in keyof A as A[K]["model"] extends null ? never : K]: Ref<
|
|
24
|
+
A[K]["maybe"] extends true ? A[K]["value"] | undefined : A[K]["value"]
|
|
25
|
+
>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type ProducersOf<A extends AvailMap, MN> = { [K in keyof A]: A[K]["model"] extends MN ? K : never }[keyof A];
|
|
29
|
+
type DefiniteProducersOf<A extends AvailMap, MN> = {
|
|
30
|
+
[K in keyof A]: A[K]["model"] extends MN ? (A[K]["maybe"] extends false ? K : never) : never;
|
|
31
|
+
}[keyof A];
|
|
32
|
+
|
|
33
|
+
type EntryError<SN extends string, Key extends string, E, A extends AvailMap> =
|
|
34
|
+
E extends Optional<infer M>
|
|
35
|
+
? [ProducersOf<A, M["name"]>] extends [never]
|
|
36
|
+
? `❌ ${M["name"]} (input '${Key}' of step '${SN}') is not available at this stage`
|
|
37
|
+
: IsUnion<ProducersOf<A, M["name"]>> extends true
|
|
38
|
+
? `❌ ${M["name"]} (input '${Key}' of step '${SN}') has several producers; use a mapper to pick one`
|
|
39
|
+
: never
|
|
40
|
+
: E extends AnyModel
|
|
41
|
+
? [ProducersOf<A, E["name"]>] extends [never]
|
|
42
|
+
? `❌ ${E["name"]} (input '${Key}' of step '${SN}') is not available at this stage`
|
|
43
|
+
: [DefiniteProducersOf<A, E["name"]>] extends [never]
|
|
44
|
+
? `❌ ${E["name"]} (input '${Key}' of step '${SN}') is only maybe-available (produced inside a when block); declare it optional(${E["name"]})`
|
|
45
|
+
: IsUnion<ProducersOf<A, E["name"]>> extends true
|
|
46
|
+
? `❌ ${E["name"]} (input '${Key}' of step '${SN}') has several producers; use a mapper to pick one`
|
|
47
|
+
: never
|
|
48
|
+
: `❌ input '${Key}' of step '${SN}' is a plain schema, not a model, so it cannot be matched by model; add a mapper`;
|
|
49
|
+
|
|
50
|
+
type DefaultMatchError<S extends AnyStep, A extends AvailMap> = {
|
|
51
|
+
[K in keyof S["input"] & string]: EntryError<S["name"], K, S["input"][K], A>;
|
|
52
|
+
}[keyof S["input"] & string] extends infer X extends string ? X : never; // `infer` forces display of the literal
|
|
53
|
+
|
|
54
|
+
type NameError<S extends AnyStep, A extends AvailMap, SiblingNames> =
|
|
55
|
+
// NB: no `string extends S["name"]` test here — it poisons inference of mixed stages;
|
|
56
|
+
// non-literal names are rejected in step() instead.
|
|
57
|
+
S["name"] extends "input" ? "❌ 'input' is reserved for the pipeline input and cannot be a step name"
|
|
58
|
+
: S["name"] extends keyof A ? `❌ duplicate step name '${S["name"]}' (already used in an earlier stage)`
|
|
59
|
+
: S["name"] extends SiblingNames ? `❌ duplicate step name '${S["name"]}' (used twice in this stage)`
|
|
60
|
+
: never;
|
|
61
|
+
|
|
62
|
+
// ─── Stage entries ───────────────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
export type MapperFn<S extends AnyStep, A extends AvailMap> = (from: InRefs<A>) => MapperResult<S>;
|
|
65
|
+
export type StageEntry<A extends AvailMap> = AnyStep | readonly [AnyStep, (from: InRefs<A>) => unknown];
|
|
66
|
+
|
|
67
|
+
type StepOfEntry<X> = X extends readonly [infer S extends AnyStep, unknown] ? S : X extends AnyStep ? X : never;
|
|
68
|
+
type SiblingNames<E extends readonly unknown[], K> = {
|
|
69
|
+
[J in keyof E]: J extends K ? never : StepOfEntry<E[J]>["name"];
|
|
70
|
+
}[number];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Per-entry check. Inference constraints (see spikes/sdk-types):
|
|
74
|
+
* - the [step, mapper] branch must be exactly `readonly [S, (from) => …]` — wrapping S or the whole
|
|
75
|
+
* mapper type in a conditional/intersection breaks inference of E or contextual typing of `from`;
|
|
76
|
+
* so name errors of tuple entries are reported on the mapper's return type instead;
|
|
77
|
+
* - nothing may look at *neighbouring* entries through E except via NoInfer (sibling names only).
|
|
78
|
+
*/
|
|
79
|
+
export type CheckEntry<E extends readonly unknown[], K, X, A extends AvailMap> =
|
|
80
|
+
X extends readonly [infer S extends AnyStep, unknown]
|
|
81
|
+
? readonly [S, (from: InRefs<A>) => OrError<NameError<S, A, SiblingNames<E, K>>, MapperResult<S>>]
|
|
82
|
+
: X extends AnyStep
|
|
83
|
+
? string extends X["name"] ? X : OrError<NameError<X, A, SiblingNames<E, K>> | DefaultMatchError<X, A>, X>
|
|
84
|
+
: "❌ a stage entry must be a step or a [step, mapper] pair";
|
|
85
|
+
|
|
86
|
+
// NB: the template must mention `E[K]` literally, or reverse-mapped inference of E fails.
|
|
87
|
+
export type CheckStage<E extends readonly unknown[], A extends AvailMap> = { [K in keyof E]: CheckEntry<NoInfer<E>, K, E[K], A> };
|
|
88
|
+
|
|
89
|
+
type OutputsOf<E extends readonly unknown[], Maybe extends boolean> = {
|
|
90
|
+
[S in StepOfEntry<E[number]> as S["name"]]: Slot<
|
|
91
|
+
S["output"] extends AnyModel ? S["output"]["name"] : null,
|
|
92
|
+
OutputValue<S>,
|
|
93
|
+
Maybe
|
|
94
|
+
>;
|
|
95
|
+
};
|
|
96
|
+
type MakeMaybe<X extends AvailMap> = { [K in keyof X]: Slot<X[K]["model"], X[K]["value"], true> };
|
|
97
|
+
|
|
98
|
+
// ─── Conditions / triggers ───────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
type IsLiteralLike<T> = [T] extends [string | number | boolean]
|
|
101
|
+
? string extends T ? false : number extends T ? false : true
|
|
102
|
+
: false;
|
|
103
|
+
export type WhenCond<In extends AnyModel> = {
|
|
104
|
+
[K in keyof Plain<In> & string]?: Exclude<Plain<In>[K], undefined> extends infer L
|
|
105
|
+
? IsLiteralLike<L> extends true
|
|
106
|
+
? L | readonly L[]
|
|
107
|
+
: `❌ '${K}' is not a literal-like field (enum, boolean or literal); when cannot match on it`
|
|
108
|
+
: never;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** The push event the service sends a push trigger mapper: exactly these fields, nothing more. */
|
|
112
|
+
export interface PushEvent {
|
|
113
|
+
readonly ref: string;
|
|
114
|
+
readonly after: string;
|
|
115
|
+
readonly repository: { readonly full_name: string };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
type CheckHeader<V, Allowed extends string> = V extends `$SECRET:${infer X}`
|
|
119
|
+
? X extends Allowed ? V : `❌ secret '${X}' is not declared in this poll trigger's secrets`
|
|
120
|
+
: V;
|
|
121
|
+
|
|
122
|
+
/** Rejects extra fields in a trigger mapping (arrow-function returns get no excess-property check). */
|
|
123
|
+
type Exact<R, T> = R & { [K in Exclude<keyof R, keyof T>]: `❌ '${K & string}' is not a field of the pipeline input` };
|
|
124
|
+
|
|
125
|
+
type StepNames<A extends AvailMap> = Exclude<keyof A, "input"> & string;
|
|
126
|
+
|
|
127
|
+
// ─── Runtime records (read by describe/harness) ──────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
export type MapperFunction = (from: unknown) => unknown;
|
|
130
|
+
export interface StageStepNode {
|
|
131
|
+
readonly step: AnyStep;
|
|
132
|
+
readonly mapper?: MapperFunction;
|
|
133
|
+
}
|
|
134
|
+
export type StageNode =
|
|
135
|
+
| { readonly kind: "stage"; readonly steps: readonly StageStepNode[] }
|
|
136
|
+
| { readonly kind: "when"; readonly cond: Readonly<Record<string, unknown>>; readonly nodes: readonly StageNode[] };
|
|
137
|
+
|
|
138
|
+
export type TriggerNode =
|
|
139
|
+
| { readonly kind: "push"; readonly name: string; readonly map: (event: unknown) => unknown }
|
|
140
|
+
| {
|
|
141
|
+
readonly kind: "poll";
|
|
142
|
+
readonly name: string;
|
|
143
|
+
readonly url: string;
|
|
144
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
145
|
+
readonly secrets: readonly Secret[];
|
|
146
|
+
readonly valuePath: string;
|
|
147
|
+
readonly intervalSeconds: number;
|
|
148
|
+
readonly map: (value: string) => unknown;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export interface FailureHandlerNode {
|
|
152
|
+
readonly step: AnyStep;
|
|
153
|
+
/** null = every step. */
|
|
154
|
+
readonly steps: readonly AnyStep[] | null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface PipelineData {
|
|
158
|
+
readonly name: string;
|
|
159
|
+
readonly description: string | undefined;
|
|
160
|
+
readonly input: AnyModel;
|
|
161
|
+
readonly nodes: StageNode[];
|
|
162
|
+
readonly triggers: TriggerNode[];
|
|
163
|
+
readonly failureHandlers: FailureHandlerNode[];
|
|
164
|
+
/** Structural problems found while building (reported by describe). */
|
|
165
|
+
readonly problems: string[];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function pipelineData(p: unknown): PipelineData {
|
|
169
|
+
const d = typeof p === "object" && p !== null ? (p as Record<symbol, unknown>)[PIPELINE] : undefined;
|
|
170
|
+
if (!d) throw new TypeError("not a pipeline (pipeline.ts must default-export pipeline({...})...)");
|
|
171
|
+
return d as PipelineData;
|
|
172
|
+
}
|
|
173
|
+
export function isPipeline(p: unknown): boolean {
|
|
174
|
+
return typeof p === "object" && p !== null && PIPELINE in p;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Pipeline builder ────────────────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
export interface PipelineDef<In extends AnyModel> {
|
|
180
|
+
/** Display name. */
|
|
181
|
+
name: string;
|
|
182
|
+
description?: string;
|
|
183
|
+
/** The pipeline input model (what triggers produce, what `when` matches on). */
|
|
184
|
+
input: In;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export class Pipeline<In extends AnyModel, A extends AvailMap> {
|
|
188
|
+
/** Phantom: the availability map accumulated so far. */
|
|
189
|
+
declare readonly __avail: A;
|
|
190
|
+
readonly [PIPELINE]: PipelineData;
|
|
191
|
+
|
|
192
|
+
constructor(def: PipelineDef<In>, shared?: { triggers: TriggerNode[]; failureHandlers: FailureHandlerNode[]; problems: string[] }) {
|
|
193
|
+
this[PIPELINE] = {
|
|
194
|
+
name: def.name,
|
|
195
|
+
description: def.description,
|
|
196
|
+
input: def.input,
|
|
197
|
+
nodes: [],
|
|
198
|
+
triggers: shared?.triggers ?? [],
|
|
199
|
+
failureHandlers: shared?.failureHandlers ?? [],
|
|
200
|
+
problems: shared?.problems ?? [],
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Map a GitHub push to the pipeline input (`z.input` view). Runs as code in a `map-trigger` Job per event. */
|
|
205
|
+
onPush<R extends InferInput<In>>(map: (event: PushEvent) => Exact<R, InferInput<In>>): this {
|
|
206
|
+
this[PIPELINE].triggers.push({ kind: "push", name: "push", map: map as (event: unknown) => unknown });
|
|
207
|
+
return this;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Poll a URL; when the JSON value at `valuePath` changes, map it to the pipeline input.
|
|
212
|
+
* Header values may reference the poll's own declared secrets as `$SECRET:NAME`.
|
|
213
|
+
*/
|
|
214
|
+
onPoll<R extends InferInput<In>, const Sec extends readonly Secret[] = [], const H extends Record<string, string> = {}>(
|
|
215
|
+
cfg: {
|
|
216
|
+
/** Trigger id, unique per pipeline (default "poll"). Used by `map-trigger <name>`. */
|
|
217
|
+
name?: string;
|
|
218
|
+
url: string;
|
|
219
|
+
secrets?: Sec;
|
|
220
|
+
headers?: { [K in keyof H]: CheckHeader<H[K], Sec[number]["name"]> };
|
|
221
|
+
valuePath: string;
|
|
222
|
+
intervalSeconds: number;
|
|
223
|
+
},
|
|
224
|
+
map: (value: string) => Exact<R, InferInput<In>>,
|
|
225
|
+
): this {
|
|
226
|
+
this[PIPELINE].triggers.push({
|
|
227
|
+
kind: "poll",
|
|
228
|
+
name: cfg.name ?? "poll",
|
|
229
|
+
url: cfg.url,
|
|
230
|
+
headers: { ...((cfg.headers ?? {}) as Record<string, string>) },
|
|
231
|
+
secrets: [...(cfg.secrets ?? [])],
|
|
232
|
+
valuePath: cfg.valuePath,
|
|
233
|
+
intervalSeconds: cfg.intervalSeconds,
|
|
234
|
+
map: map as (value: string) => unknown,
|
|
235
|
+
});
|
|
236
|
+
return this;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Add a stage (its steps run in parallel). Entries: `step` (inputs matched by model) or
|
|
241
|
+
* `[step, from => ({ … })]` (explicit mapping). Every entry sees only what was available
|
|
242
|
+
* *before* this stage.
|
|
243
|
+
*/
|
|
244
|
+
stage<const E extends readonly StageEntry<A>[]>(...entries: CheckStage<E, A>): Pipeline<In, Simplify<A & OutputsOf<E, false>>>;
|
|
245
|
+
stage(...entries: readonly unknown[]): unknown {
|
|
246
|
+
const data = this[PIPELINE];
|
|
247
|
+
const steps: StageStepNode[] = [];
|
|
248
|
+
entries.forEach((e, i) => {
|
|
249
|
+
if (Array.isArray(e) && e.length === 2 && isStep(e[0]) && typeof e[1] === "function") {
|
|
250
|
+
steps.push({ step: e[0], mapper: e[1] as MapperFunction });
|
|
251
|
+
} else if (isStep(e)) {
|
|
252
|
+
steps.push({ step: e });
|
|
253
|
+
} else if (typeof e === "function") {
|
|
254
|
+
data.problems.push(`stage entry ${i + 1} is a bare function; mappers are given as [step, from => ({ ... })] tuples`);
|
|
255
|
+
} else {
|
|
256
|
+
data.problems.push(`stage entry ${i + 1} is neither a step nor a [step, mapper] pair`);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
if (entries.length === 0) data.problems.push("empty stage");
|
|
260
|
+
data.nodes.push({ kind: "stage", steps });
|
|
261
|
+
return this;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Declarative condition on literal-like pipeline-input fields. Outputs produced inside become maybe-available after. */
|
|
265
|
+
when<B extends AvailMap>(cond: WhenCond<In>, block: (p: Pipeline<In, A>) => Pipeline<In, B>): Pipeline<In, Simplify<A & MakeMaybe<Omit<B, keyof A>>>> {
|
|
266
|
+
const data = this[PIPELINE];
|
|
267
|
+
const inner = new Pipeline<In, A>({ name: data.name, input: data.input as In }, data);
|
|
268
|
+
const returned = block(inner);
|
|
269
|
+
if (returned !== (inner as unknown)) data.problems.push("a when block must return the pipeline it was given (p => p.stage(...))");
|
|
270
|
+
data.nodes.push({ kind: "when", cond: { ...(cond as object) }, nodes: inner[PIPELINE].nodes });
|
|
271
|
+
return this as never;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Run `handler` (a step whose inputs are all FailureContext) when one of `steps` (default: any) fails. Untracked, best-effort. */
|
|
275
|
+
onFailure<S extends AnyStep>(
|
|
276
|
+
handler: S &
|
|
277
|
+
([Exclude<S["input"][keyof S["input"]], typeof FailureContext>] extends [never]
|
|
278
|
+
? unknown
|
|
279
|
+
: "❌ a failure handler's inputs must all be FailureContext"),
|
|
280
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
281
|
+
opts?: { steps?: readonly Step<StepNames<A>, any, any, any>[] },
|
|
282
|
+
): this {
|
|
283
|
+
this[PIPELINE].failureHandlers.push({ step: handler as AnyStep, steps: opts?.steps ? [...(opts.steps as readonly AnyStep[])] : null });
|
|
284
|
+
return this;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Start a pipeline definition. `pipeline.ts` must default-export the result. */
|
|
289
|
+
export function pipeline<In extends AnyModel>(def: PipelineDef<In>): Pipeline<In, InitialAvail<In>> {
|
|
290
|
+
return new Pipeline(def) as never;
|
|
291
|
+
}
|