@olivervea/pipelines 0.0.0-stage → 2.0.0-beta.2
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 +409 -2
- package/manifest.schema.json +596 -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 +305 -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/core/webhooks.ts +30 -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 +386 -0
- package/src/describe/manifest-schema.ts +146 -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 +232 -0
- package/src/harness/result.ts +75 -0
- package/src/harness/validate.ts +74 -0
- package/src/index.ts +12 -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 +194 -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
package/src/ctx/sh.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
3
|
+
import type { ExecOptions, Sh, ShOptions } from "./types.ts";
|
|
4
|
+
import { PRIVATE_ENV } from "../harness/env.ts";
|
|
5
|
+
|
|
6
|
+
/** Shells looked for, in order. kaniko only has /busybox/sh; distroless has none. */
|
|
7
|
+
export const SHELL_CANDIDATES = ["/bin/sh", "/busybox/sh", "/bin/bash"] as const;
|
|
8
|
+
|
|
9
|
+
export class ShellNotFoundError extends Error {
|
|
10
|
+
override name = "ShellNotFoundError";
|
|
11
|
+
constructor() {
|
|
12
|
+
super(
|
|
13
|
+
`no shell found in this image (looked for ${SHELL_CANDIDATES.join(", ")}). ` +
|
|
14
|
+
`ctx.sh needs a shell: use an image that has one, or run binaries directly with ctx.exec([...]).`,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class CommandError extends Error {
|
|
20
|
+
override name = "CommandError";
|
|
21
|
+
constructor(readonly what: string, readonly exitCode: number | null, readonly signalCode: string | null) {
|
|
22
|
+
super(`${what} failed with ${signalCode ? `signal ${signalCode}` : `exit code ${exitCode}`}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The command went quiet for longer than its `idleTimeoutSeconds` and was killed. */
|
|
27
|
+
export class CommandStalledError extends CommandError {
|
|
28
|
+
override name = "CommandStalledError";
|
|
29
|
+
constructor(what: string, readonly idleTimeoutSeconds: number, signalCode: string | null) {
|
|
30
|
+
super(what, null, signalCode);
|
|
31
|
+
this.message = `${what} produced no output for ${idleTimeoutSeconds} s and was killed`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Grace between the watchdog's SIGTERM and SIGKILL (a process blocked in a socket read may ignore TERM). */
|
|
36
|
+
export const STALL_KILL_GRACE_MS = 10_000;
|
|
37
|
+
|
|
38
|
+
export function detectShell(candidates: readonly string[] = SHELL_CANDIDATES): string {
|
|
39
|
+
for (const c of candidates) if (existsSync(c)) return c;
|
|
40
|
+
throw new ShellNotFoundError();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True for the value of `import x from "./x.sh" with { type: "file" }` (compiled or from source). */
|
|
44
|
+
export function isEmbeddedScript(s: string): boolean {
|
|
45
|
+
if (s.startsWith("/$bunfs/") || s.startsWith("B:/~BUN/")) return true;
|
|
46
|
+
if (!isAbsolute(s) || !/\.(sh|bash)$/.test(s) || /\s/.test(s)) return false;
|
|
47
|
+
try {
|
|
48
|
+
return statSync(s).isFile();
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Environment for child processes: the harness env minus harness-only credentials, plus extras. */
|
|
55
|
+
export function childEnv(extra?: Record<string, string>): Record<string, string> {
|
|
56
|
+
const env: Record<string, string> = {};
|
|
57
|
+
for (const [k, v] of Object.entries(process.env)) if (v !== undefined && !PRIVATE_ENV.includes(k)) env[k] = v;
|
|
58
|
+
return { ...env, ...extra };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ShDeps {
|
|
62
|
+
tmpDir: string;
|
|
63
|
+
signal: AbortSignal;
|
|
64
|
+
shellCandidates?: readonly string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[], opts?: ExecOptions) => Promise<void> } {
|
|
68
|
+
const copied = new Map<string, string>();
|
|
69
|
+
let counter = 0;
|
|
70
|
+
|
|
71
|
+
// Embedded scripts live in the binary's /$bunfs, which child processes cannot see: copy them out.
|
|
72
|
+
const materialize = (script: string): string => {
|
|
73
|
+
const hit = copied.get(script);
|
|
74
|
+
if (hit) return hit;
|
|
75
|
+
const dir = join(deps.tmpDir, "scripts");
|
|
76
|
+
mkdirSync(dir, { recursive: true });
|
|
77
|
+
const dest = join(dir, `${++counter}-${basename(script)}`);
|
|
78
|
+
writeFileSync(dest, readFileSync(script));
|
|
79
|
+
chmodSync(dest, 0o755);
|
|
80
|
+
copied.set(script, dest);
|
|
81
|
+
return dest;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const argvFor = (cmd: string | { script: string }, args: readonly string[]): { argv: string[]; what: string } => {
|
|
85
|
+
const scriptPath = typeof cmd === "object" ? cmd.script : isEmbeddedScript(cmd) ? cmd : undefined;
|
|
86
|
+
if (scriptPath === undefined) {
|
|
87
|
+
const shell = detectShell(deps.shellCandidates);
|
|
88
|
+
return { argv: [shell, "-c", cmd as string], what: `command \`${truncate(cmd as string)}\`` };
|
|
89
|
+
}
|
|
90
|
+
const file = materialize(scriptPath);
|
|
91
|
+
const first = readFileSync(file, "utf8").split("\n", 1)[0] ?? "";
|
|
92
|
+
const shebang = /^#!\s*(\S+)(?:\s+(.*))?$/.exec(first);
|
|
93
|
+
if (shebang && existsSync(shebang[1]!)) {
|
|
94
|
+
const interpArgs = shebang[2] ? [shebang[2].trim()] : [];
|
|
95
|
+
return { argv: [shebang[1]!, ...interpArgs, file, ...args], what: `script ${basename(scriptPath)}` };
|
|
96
|
+
}
|
|
97
|
+
return { argv: [detectShell(deps.shellCandidates), file, ...args], what: `script ${basename(scriptPath)}` };
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const run = async (
|
|
101
|
+
argv: string[],
|
|
102
|
+
what: string,
|
|
103
|
+
opts: { env?: Record<string, string>; cwd?: string; idleTimeoutSeconds?: number },
|
|
104
|
+
capture: boolean,
|
|
105
|
+
): Promise<string> => {
|
|
106
|
+
if (deps.signal.aborted) throw new Error(`${what} not started: the step was cancelled`);
|
|
107
|
+
const idleSeconds = opts.idleTimeoutSeconds !== undefined && opts.idleTimeoutSeconds > 0 ? opts.idleTimeoutSeconds : undefined;
|
|
108
|
+
let proc: Bun.Subprocess<"ignore", "pipe" | "inherit", "pipe" | "inherit">;
|
|
109
|
+
try {
|
|
110
|
+
proc = Bun.spawn(argv, {
|
|
111
|
+
cwd: opts.cwd,
|
|
112
|
+
env: childEnv(opts.env),
|
|
113
|
+
stdin: "ignore",
|
|
114
|
+
stdout: capture || idleSeconds ? "pipe" : "inherit",
|
|
115
|
+
stderr: idleSeconds ? "pipe" : "inherit",
|
|
116
|
+
});
|
|
117
|
+
} catch (e) {
|
|
118
|
+
throw new Error(`cannot start ${what} (${argv[0]}): ${(e as Error).message}`);
|
|
119
|
+
}
|
|
120
|
+
const onAbort = () => proc.kill("SIGTERM");
|
|
121
|
+
deps.signal.addEventListener("abort", onAbort, { once: true });
|
|
122
|
+
|
|
123
|
+
// Watchdog: every chunk of output re-arms the timer; on expiry TERM, then KILL after a grace.
|
|
124
|
+
let stalled = false;
|
|
125
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
126
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
127
|
+
const arm = () => {
|
|
128
|
+
if (!idleSeconds || stalled) return;
|
|
129
|
+
clearTimeout(idleTimer);
|
|
130
|
+
idleTimer = setTimeout(() => {
|
|
131
|
+
stalled = true;
|
|
132
|
+
proc.kill("SIGTERM");
|
|
133
|
+
killTimer = setTimeout(() => proc.kill("SIGKILL"), STALL_KILL_GRACE_MS);
|
|
134
|
+
}, idleSeconds * 1000);
|
|
135
|
+
};
|
|
136
|
+
const captured: Uint8Array[] = [];
|
|
137
|
+
const readers: { cancel(): Promise<void> }[] = [];
|
|
138
|
+
const pump = async (stream: ReadableStream<Uint8Array>, sink: (chunk: Uint8Array) => void) => {
|
|
139
|
+
const reader = stream.getReader();
|
|
140
|
+
readers.push(reader);
|
|
141
|
+
try {
|
|
142
|
+
for (;;) {
|
|
143
|
+
const { done, value } = await reader.read();
|
|
144
|
+
if (done) return;
|
|
145
|
+
arm();
|
|
146
|
+
sink(value);
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
// cancelled after a stall
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
const pumps: Promise<void>[] = [];
|
|
153
|
+
if (capture) pumps.push(pump(proc.stdout as ReadableStream<Uint8Array>, (c) => captured.push(c)));
|
|
154
|
+
else if (idleSeconds) pumps.push(pump(proc.stdout as ReadableStream<Uint8Array>, (c) => process.stdout.write(c)));
|
|
155
|
+
if (idleSeconds) pumps.push(pump(proc.stderr as ReadableStream<Uint8Array>, (c) => process.stderr.write(c)));
|
|
156
|
+
arm();
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const code = await proc.exited;
|
|
160
|
+
// A killed command's children may still hold the pipes open: don't wait for them after a stall.
|
|
161
|
+
if (stalled) await Promise.all(readers.map((r) => r.cancel().catch(() => {})));
|
|
162
|
+
else await Promise.all(pumps);
|
|
163
|
+
if (stalled) throw new CommandStalledError(what, idleSeconds!, proc.signalCode ? String(proc.signalCode) : null);
|
|
164
|
+
if (code !== 0) throw new CommandError(what, proc.signalCode ? null : code, proc.signalCode ? String(proc.signalCode) : null);
|
|
165
|
+
return capture ? Buffer.concat(captured).toString("utf8") : "";
|
|
166
|
+
} finally {
|
|
167
|
+
clearTimeout(idleTimer);
|
|
168
|
+
clearTimeout(killTimer);
|
|
169
|
+
deps.signal.removeEventListener("abort", onAbort);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const sh = Object.assign(
|
|
174
|
+
async (cmd: string | { script: string }, opts: ShOptions = {}) => {
|
|
175
|
+
const { argv, what } = argvFor(cmd, opts.args ?? []);
|
|
176
|
+
await run(argv, what, opts, false);
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
text: async (cmd: string | { script: string }, opts: ShOptions = {}) => {
|
|
180
|
+
const { argv, what } = argvFor(cmd, opts.args ?? []);
|
|
181
|
+
return (await run(argv, what, opts, true)).trim();
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
) as Sh;
|
|
185
|
+
|
|
186
|
+
const exec = async (argv: readonly string[], opts: ExecOptions = {}) => {
|
|
187
|
+
if (argv.length === 0) throw new Error("ctx.exec needs at least the program");
|
|
188
|
+
await run([...argv], `\`${truncate(argv.join(" "))}\``, opts, false);
|
|
189
|
+
};
|
|
190
|
+
return { sh, exec };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function truncate(s: string, n = 120): string {
|
|
194
|
+
const one = s.replace(/\s+/g, " ").trim();
|
|
195
|
+
return one.length > n ? one.slice(0, n - 1) + "…" : one;
|
|
196
|
+
}
|
package/src/ctx/types.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { Skipped } from "../core/handlers.ts";
|
|
2
|
+
import type { FileRef } from "../core/models.ts";
|
|
3
|
+
import type { DeclaredSecrets } from "../core/secrets.ts";
|
|
4
|
+
|
|
5
|
+
/** Facts about the current execution, from the service (see README "Environment"). */
|
|
6
|
+
export interface RunInfo {
|
|
7
|
+
/** Pipeline id (`OLVE_PIPELINE`). */
|
|
8
|
+
readonly pipeline: string;
|
|
9
|
+
/** Run id (`OLVE_RUN_ID`). */
|
|
10
|
+
readonly id: string;
|
|
11
|
+
/** Run number, monotonic per pipeline (`OLVE_RUN`). */
|
|
12
|
+
readonly number: number;
|
|
13
|
+
/** Attempt number of this step in the run, from 1 (`OLVE_ATTEMPT`). */
|
|
14
|
+
readonly attempt: number;
|
|
15
|
+
/** Name of the step being executed. */
|
|
16
|
+
readonly step: string;
|
|
17
|
+
/** Version stamp derived from the run, e.g. `2026.10.21.412` (`OLVE_VERSION_STAMP`). */
|
|
18
|
+
readonly version: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ShOptions {
|
|
22
|
+
/** Extra environment variables (merged over the harness environment). */
|
|
23
|
+
env?: Record<string, string>;
|
|
24
|
+
/** Working directory (default: the harness's cwd). */
|
|
25
|
+
cwd?: string;
|
|
26
|
+
/** Arguments passed to an embedded script (`$1`, `$2`, …). */
|
|
27
|
+
args?: readonly string[];
|
|
28
|
+
/**
|
|
29
|
+
* Inactivity watchdog: when the command writes nothing to stdout/stderr for this many seconds it is
|
|
30
|
+
* killed (SIGTERM, then SIGKILL after 10 s) and the call rejects with a `CommandStalledError`. Output
|
|
31
|
+
* is then piped through the harness (and forwarded) instead of inherited. Off when unset or ≤ 0.
|
|
32
|
+
*/
|
|
33
|
+
idleTimeoutSeconds?: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Run a command string or an embedded script through the image's shell.
|
|
37
|
+
* A string is treated as an embedded script when it is a path inside the compiled binary
|
|
38
|
+
* (`/$bunfs/…`) or an absolute path to an existing `*.sh` / `*.bash` file — i.e. the value of
|
|
39
|
+
* `import s from "./x.sh" with { type: "file" }`. Pass `{ script }` to force script mode.
|
|
40
|
+
* Rejects with an error carrying the exit code when the command fails.
|
|
41
|
+
*/
|
|
42
|
+
export interface Sh {
|
|
43
|
+
(command: string | { script: string }, opts?: ShOptions): Promise<void>;
|
|
44
|
+
/** Like `sh`, but captures stdout (trimmed) instead of streaming it. */
|
|
45
|
+
text(command: string | { script: string }, opts?: ShOptions): Promise<string>;
|
|
46
|
+
}
|
|
47
|
+
export interface ExecOptions {
|
|
48
|
+
env?: Record<string, string>;
|
|
49
|
+
cwd?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Inactivity watchdog: when the command writes nothing to stdout/stderr for this many seconds it is
|
|
52
|
+
* killed (SIGTERM, then SIGKILL after 10 s) and the call rejects with a `CommandStalledError`. Output
|
|
53
|
+
* is then piped through the harness (and forwarded) instead of inherited. Off when unset or ≤ 0.
|
|
54
|
+
*/
|
|
55
|
+
idleTimeoutSeconds?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface HttpInit {
|
|
59
|
+
headers?: Record<string, string>;
|
|
60
|
+
}
|
|
61
|
+
export interface Http {
|
|
62
|
+
/** `fetch` bound to `ctx.signal`. */
|
|
63
|
+
fetch(url: string, init?: RequestInit): Promise<Response>;
|
|
64
|
+
/** GET and parse JSON; rejects on a non-2xx status. */
|
|
65
|
+
getJson<T = unknown>(url: string, init?: HttpInit): Promise<T>;
|
|
66
|
+
/** GET (or `init.method`) and reject unless the status is 2xx. */
|
|
67
|
+
expectOk(url: string, init?: HttpInit & { method?: string }): Promise<Response>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface RetryOptions {
|
|
71
|
+
attempts: number;
|
|
72
|
+
delaySeconds: number;
|
|
73
|
+
/** Multiplier applied to the delay after every failure (default 1 = constant). */
|
|
74
|
+
backoff?: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface OutputFileOptions {
|
|
78
|
+
/** Name inside the step's files (default: the source's base name). May contain `/`. */
|
|
79
|
+
name?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface Ctx<Sec extends string = string> {
|
|
83
|
+
/** Exactly the secrets the step declares, read from the environment. */
|
|
84
|
+
readonly secrets: DeclaredSecrets<Sec>;
|
|
85
|
+
readonly sh: Sh;
|
|
86
|
+
/** Run a binary directly, without a shell (works in shell-less images). Rejects on non-zero exit. */
|
|
87
|
+
exec(argv: readonly string[], opts?: ExecOptions): Promise<void>;
|
|
88
|
+
readText(path: string): Promise<string>;
|
|
89
|
+
readJson<T = unknown>(path: string): Promise<T>;
|
|
90
|
+
readonly files: {
|
|
91
|
+
/** Download (and verify) a referenced file; directory refs are extracted. Returns the local path. */
|
|
92
|
+
fetch(ref: FileRef): Promise<string>;
|
|
93
|
+
};
|
|
94
|
+
readonly output: {
|
|
95
|
+
/** Add an existing file to the step's output files. */
|
|
96
|
+
file(path: string, opts?: OutputFileOptions): Promise<FileRef>;
|
|
97
|
+
/** Add an existing directory (archived as a deterministic tar) to the step's output files. */
|
|
98
|
+
dir(path: string, opts?: OutputFileOptions): Promise<FileRef>;
|
|
99
|
+
/** Write content as an output file. */
|
|
100
|
+
write(name: string, content: string | Uint8Array): Promise<FileRef>;
|
|
101
|
+
};
|
|
102
|
+
readonly http: Http;
|
|
103
|
+
retry<T>(opts: RetryOptions, fn: (attempt: number) => Promise<T>): Promise<T>;
|
|
104
|
+
/** Structured log line on stderr. */
|
|
105
|
+
log(message: string, fields?: Record<string, unknown>): void;
|
|
106
|
+
/** Aborted on SIGTERM/SIGINT (cancellation, `activeDeadlineSeconds`). */
|
|
107
|
+
readonly signal: AbortSignal;
|
|
108
|
+
readonly run: RunInfo;
|
|
109
|
+
/** A writable scratch directory (`$OLVE_DIR/tmp`; images like kaniko have no /tmp). */
|
|
110
|
+
readonly tmpDir: string;
|
|
111
|
+
/** Runtime escape hatch: produce no output. Prefer `when` blocks. */
|
|
112
|
+
skip(reason: string): Skipped;
|
|
113
|
+
}
|