@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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +409 -2
  3. package/manifest.schema.json +596 -0
  4. package/package.json +60 -4
  5. package/src/assets.d.ts +10 -0
  6. package/src/build/build.ts +125 -0
  7. package/src/build/cli.ts +52 -0
  8. package/src/build/elf.ts +35 -0
  9. package/src/build/hashes.ts +67 -0
  10. package/src/build/project.ts +79 -0
  11. package/src/builder/pipeline.ts +305 -0
  12. package/src/builder/refs.ts +26 -0
  13. package/src/core/brands.ts +13 -0
  14. package/src/core/handlers.ts +44 -0
  15. package/src/core/models.ts +69 -0
  16. package/src/core/secrets.ts +13 -0
  17. package/src/core/steps.ts +107 -0
  18. package/src/core/types.ts +6 -0
  19. package/src/core/webhooks.ts +30 -0
  20. package/src/ctx/files.ts +161 -0
  21. package/src/ctx/index.ts +119 -0
  22. package/src/ctx/sh.ts +196 -0
  23. package/src/ctx/types.ts +113 -0
  24. package/src/describe/describe.ts +386 -0
  25. package/src/describe/manifest-schema.ts +146 -0
  26. package/src/describe/mapper-source.ts +238 -0
  27. package/src/describe/record.ts +107 -0
  28. package/src/dev/cli.ts +80 -0
  29. package/src/harness/env.ts +58 -0
  30. package/src/harness/execute.ts +94 -0
  31. package/src/harness/exit.ts +19 -0
  32. package/src/harness/index.ts +9 -0
  33. package/src/harness/main.ts +232 -0
  34. package/src/harness/result.ts +75 -0
  35. package/src/harness/validate.ts +74 -0
  36. package/src/index.ts +12 -0
  37. package/src/library/github-source.ts +77 -0
  38. package/src/library/helm-deploy-ssh.ts +151 -0
  39. package/src/library/http-health.ts +34 -0
  40. package/src/library/image-build.ts +194 -0
  41. package/src/library/index.ts +14 -0
  42. package/src/library/models.ts +63 -0
  43. package/src/library/script.ts +85 -0
  44. package/src/testing/index.ts +86 -0
  45. package/src/util/canonical.ts +19 -0
  46. package/src/util/handler-hash.ts +22 -0
  47. package/src/util/hash.ts +22 -0
  48. package/src/util/tar.ts +154 -0
@@ -0,0 +1,232 @@
1
+ // The harness: one executable, three modes.
2
+ // describe [--sentinels] → manifest (canonical JSON) on stdout
3
+ // run <step> → $OLVE_DIR/input.json → handler → $OLVE_DIR/work/output.json (+ result upload)
4
+ // map-trigger <trigger> → $OLVE_DIR/input.json (event) → pipeline input → $OLVE_DIR/work/output.json
5
+ import { mkdirSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { isImplementation } from "../core/handlers.ts";
8
+ import type { AnyStep } from "../core/steps.ts";
9
+ import type { WebhookEvent } from "../core/webhooks.ts";
10
+ import { pipelineData, type PipelineData, type StageNode } from "../builder/pipeline.ts";
11
+ import { createCtx, formatLog } from "../ctx/index.ts";
12
+ import { DescribeError, describePipeline, manifestText } from "../describe/describe.ts";
13
+ import type { HarnessInfo } from "../util/handler-hash.ts";
14
+ import { ENV, paths, runInfoFromEnv } from "./env.ts";
15
+ import { EXIT } from "./exit.ts";
16
+ import { executeStep, type Envelope, type Execution, type StepFunction } from "./execute.ts";
17
+ import { MANIFEST_BEGIN, MANIFEST_END, packResult, removeStale, sentinelBlock, uploadFile, writeEnvelope } from "./result.ts";
18
+ import { formatIssues, parseStrict } from "./validate.ts";
19
+
20
+ /** step name → lazy import of `handlers/<name>.ts`. Literal import() paths so Bun bundles them. */
21
+ export type HandlerRegistry = Readonly<Record<string, () => Promise<{ default: unknown }>>>;
22
+
23
+ export interface HarnessConfig {
24
+ /** Default export of pipeline.ts. */
25
+ pipeline: unknown;
26
+ handlers: HandlerRegistry;
27
+ handlerHashes: Readonly<Record<string, string>>;
28
+ harness: HarnessInfo;
29
+ }
30
+
31
+ export interface HarnessIO {
32
+ stdout(text: string): Promise<void>;
33
+ stderr(line: string): void;
34
+ }
35
+
36
+ export const defaultIO: HarnessIO = {
37
+ stdout: async (text) => {
38
+ await Bun.write(Bun.stdout, text);
39
+ },
40
+ stderr: (line) => {
41
+ process.stderr.write(line.endsWith("\n") ? line : line + "\n");
42
+ },
43
+ };
44
+
45
+ const USAGE = "usage: step describe [--sentinels] | step run <step> | step map-trigger <trigger>";
46
+
47
+ export function allSteps(data: PipelineData): AnyStep[] {
48
+ const out: AnyStep[] = [];
49
+ const walk = (nodes: readonly StageNode[]) => {
50
+ for (const n of nodes) n.kind === "stage" ? out.push(...n.steps.map((s) => s.step)) : walk(n.nodes);
51
+ };
52
+ walk(data.nodes);
53
+ for (const fh of data.failureHandlers) if (!out.includes(fh.step)) out.push(fh.step);
54
+ return out;
55
+ }
56
+
57
+ /** Installs SIGTERM/SIGINT handlers that abort the returned signal (PID 1 ignores signals without them). */
58
+ export function cancellation(): { signal: AbortSignal; dispose(): void } {
59
+ const ac = new AbortController();
60
+ const onTerm = () => ac.abort("SIGTERM");
61
+ const onInt = () => (ac.signal.aborted ? process.exit(EXIT.sigint) : ac.abort("SIGINT"));
62
+ process.on("SIGTERM", onTerm);
63
+ process.on("SIGINT", onInt);
64
+ return {
65
+ signal: ac.signal,
66
+ dispose() {
67
+ process.off("SIGTERM", onTerm);
68
+ process.off("SIGINT", onInt);
69
+ },
70
+ };
71
+ }
72
+
73
+ /** Resolves the function that implements `step` (library or handler module). */
74
+ export async function resolveStepFunction(step: AnyStep, handlers: HandlerRegistry): Promise<StepFunction | string> {
75
+ if (step.kind !== "custom") {
76
+ if (!step.builtin) return `library step '${step.name}' (${step.kind}) has no implementation`;
77
+ return step.builtin.run as StepFunction;
78
+ }
79
+ const load = handlers[step.name];
80
+ if (!load) return `step '${step.name}' has no handler (expected handlers/${step.name}.ts)`;
81
+ let mod: { default: unknown };
82
+ try {
83
+ mod = await load();
84
+ } catch (e) {
85
+ return `handlers/${step.name}.ts failed to load: ${(e as Error)?.stack ?? String(e)}`;
86
+ }
87
+ const impl = mod.default;
88
+ if (!isImplementation(impl)) return `handlers/${step.name}.ts must default-export implement(<step '${step.name}'>, …)`;
89
+ if (impl.step !== step)
90
+ return `handlers/${step.name}.ts implements step '${(impl.step as AnyStep)?.name}', not '${step.name}' (the default export must be implement(<the '${step.name}' step from pipeline.ts>, …))`;
91
+ return impl.handler as unknown as StepFunction;
92
+ }
93
+
94
+ /** Writes output.json, prints the sentinel block and uploads result.tar.gz when OLVE_RESULT_URL is set. */
95
+ export async function finish(exec: Execution, io: HarnessIO): Promise<number> {
96
+ const p = paths();
97
+ writeEnvelope(p.work, exec.envelope);
98
+ await io.stdout(sentinelBlock(exec.envelope));
99
+ const url = process.env[ENV.resultUrl];
100
+ if (!url) return exec.exitCode;
101
+ try {
102
+ mkdirSync(p.tmp, { recursive: true });
103
+ const archive = join(p.tmp, "result.tar.gz");
104
+ await packResult(p.work, archive);
105
+ await uploadFile(url, archive, io.stderr);
106
+ } catch (e) {
107
+ io.stderr(`result upload failed: ${(e as Error).message}`);
108
+ return exec.exitCode === EXIT.ok ? EXIT.uploadFailed : exec.exitCode;
109
+ }
110
+ return exec.exitCode;
111
+ }
112
+
113
+ function readJson(path: string): { ok: true; value: unknown } | { ok: false; message: string } {
114
+ try {
115
+ return { ok: true, value: JSON.parse(readFileSync(path, "utf8")) };
116
+ } catch (e) {
117
+ return { ok: false, message: `cannot read ${path}: ${(e as Error).message}` };
118
+ }
119
+ }
120
+
121
+ async function describeMode(cfg: HarnessConfig, args: string[], io: HarnessIO): Promise<number> {
122
+ const sentinels = args.includes("--sentinels");
123
+ try {
124
+ const text = manifestText(describePipeline(cfg.pipeline, { handlerNames: Object.keys(cfg.handlers), handlerHashes: cfg.handlerHashes, harness: cfg.harness }));
125
+ await io.stdout(sentinels ? `${MANIFEST_BEGIN}\n${text}${MANIFEST_END}\n` : text);
126
+ return EXIT.ok;
127
+ } catch (e) {
128
+ if (e instanceof DescribeError) {
129
+ io.stderr(e.message);
130
+ return EXIT.configInvalid;
131
+ }
132
+ throw e;
133
+ }
134
+ }
135
+
136
+ async function runMode(cfg: HarnessConfig, name: string | undefined, io: HarnessIO): Promise<number> {
137
+ if (!name) return io.stderr(USAGE), EXIT.usage;
138
+ const data = pipelineData(cfg.pipeline);
139
+ const step = allSteps(data).find((s) => s.name === name);
140
+ if (!step) return io.stderr(`unknown step '${name}'`), EXIT.usage;
141
+ const fn = await resolveStepFunction(step, cfg.handlers);
142
+ if (typeof fn === "string") return io.stderr(fn), EXIT.configInvalid;
143
+
144
+ const p = paths();
145
+ removeStale(p.work);
146
+ mkdirSync(p.files, { recursive: true });
147
+ mkdirSync(p.tmp, { recursive: true });
148
+ const raw = readJson(p.input);
149
+ const cancel = cancellation();
150
+ try {
151
+ let exec: Execution;
152
+ if (!raw.ok) {
153
+ io.stderr(raw.message);
154
+ exec = { envelope: { status: "failed", error: { kind: "input", message: raw.message } }, exitCode: EXIT.inputInvalid };
155
+ } else {
156
+ const run = runInfoFromEnv(step.name);
157
+ const ctx = createCtx({ run, secrets: step.secrets, paths: { root: p.root, workFiles: p.files, tmp: p.tmp }, signal: cancel.signal, logSink: io.stderr });
158
+ exec = await executeStep({ step, fn, rawInput: raw.value, ctx, workFiles: p.files, log: io.stderr });
159
+ }
160
+ return await finish(exec, io);
161
+ } finally {
162
+ cancel.dispose();
163
+ }
164
+ }
165
+
166
+ function isWebhookEvent(v: unknown): boolean {
167
+ if (typeof v !== "object" || v === null || Array.isArray(v) || !("body" in v)) return false;
168
+ const headers = (v as { headers?: unknown }).headers;
169
+ return typeof headers === "object" && headers !== null && !Array.isArray(headers);
170
+ }
171
+
172
+ async function mapTriggerMode(cfg: HarnessConfig, name: string | undefined, io: HarnessIO): Promise<number> {
173
+ if (!name) return io.stderr(USAGE), EXIT.usage;
174
+ const data = pipelineData(cfg.pipeline);
175
+ const trigger = data.triggers.find((t) => t.name === name);
176
+ if (!trigger) return io.stderr(`unknown trigger '${name}' (known: ${data.triggers.map((t) => t.name).join(", ") || "none"})`), EXIT.usage;
177
+ const p = paths();
178
+ removeStale(p.work);
179
+ mkdirSync(p.work, { recursive: true });
180
+ const raw = readJson(p.input);
181
+ const fail = (kind: "input" | "output" | "handler", message: string, exitCode: number): Execution => {
182
+ io.stderr(message);
183
+ return { envelope: { status: "failed", error: { kind, message } }, exitCode };
184
+ };
185
+ let exec: Execution;
186
+ if (!raw.ok) exec = fail("input", raw.message, EXIT.inputInvalid);
187
+ else if (trigger.kind === "poll" && (typeof raw.value !== "object" || raw.value === null || typeof (raw.value as { value?: unknown }).value !== "string"))
188
+ exec = fail("input", `poll trigger '${name}': the event must be {"value": "<string>"}`, EXIT.inputInvalid);
189
+ else if (trigger.kind === "push" && (typeof raw.value !== "object" || raw.value === null || Array.isArray(raw.value)))
190
+ exec = fail("input", `push trigger: the event must be the webhook payload object`, EXIT.inputInvalid);
191
+ else if (trigger.kind === "webhook" && !isWebhookEvent(raw.value))
192
+ exec = fail("input", `webhook trigger '${name}': the event must be {"headers": {...}, "body": ...}`, EXIT.inputInvalid);
193
+ else {
194
+ let mapped: unknown;
195
+ let threw: unknown = undefined;
196
+ try {
197
+ mapped =
198
+ trigger.kind === "poll" ? await trigger.map((raw.value as { value: string }).value)
199
+ : trigger.kind === "webhook" ? await trigger.map(raw.value as WebhookEvent)
200
+ : await trigger.map(raw.value);
201
+ } catch (e) {
202
+ threw = e ?? new Error("undefined");
203
+ }
204
+ if (threw !== undefined) exec = fail("handler", `trigger '${name}' mapper threw: ${(threw as Error)?.message ?? String(threw)}`, EXIT.handlerFailed);
205
+ else if (mapped === null && trigger.kind === "webhook") exec = { envelope: { status: "skipped", reason: `trigger '${name}' ignored the delivery` }, exitCode: EXIT.ok };
206
+ else {
207
+ const r = parseStrict(data.input.schema, mapped, "input");
208
+ exec = r.ok
209
+ ? { envelope: { status: "succeeded", output: r.value } satisfies Envelope, exitCode: EXIT.ok }
210
+ : fail("output", `trigger '${name}': mapped input does not match ${data.input.name}:\n ${r.issues.join("\n ")}`, EXIT.outputInvalid);
211
+ }
212
+ }
213
+ return finish(exec, io);
214
+ }
215
+
216
+ /** Entry point of the compiled binary (and the dev runner). Returns the exit code. */
217
+ export async function runHarness(cfg: HarnessConfig, argv: readonly string[], io: HarnessIO = defaultIO): Promise<number> {
218
+ const [mode, ...rest] = argv;
219
+ switch (mode) {
220
+ case "describe":
221
+ return describeMode(cfg, rest, io);
222
+ case "run":
223
+ return runMode(cfg, rest[0], io);
224
+ case "map-trigger":
225
+ return mapTriggerMode(cfg, rest[0], io);
226
+ default:
227
+ io.stderr(USAGE);
228
+ return EXIT.usage;
229
+ }
230
+ }
231
+
232
+ export { formatIssues, formatLog };
@@ -0,0 +1,75 @@
1
+ // Writing, printing and uploading the result of `run` / `map-trigger`.
2
+ import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { canonicalJson } from "../util/canonical.ts";
5
+ import { dirEntries, writeTarGz, type TarEntry } from "../util/tar.ts";
6
+ import { redactUrl } from "../ctx/files.ts";
7
+ import type { Envelope } from "./execute.ts";
8
+
9
+ export const RESULT_BEGIN = "---OLVE-RESULT-BEGIN---";
10
+ export const RESULT_END = "---OLVE-RESULT-END---";
11
+ export const MANIFEST_BEGIN = "---OLVE-MANIFEST-BEGIN---";
12
+ export const MANIFEST_END = "---OLVE-MANIFEST-END---";
13
+
14
+ /** Atomically writes the envelope to `<work>/output.json`. */
15
+ export function writeEnvelope(work: string, envelope: Envelope): string {
16
+ mkdirSync(work, { recursive: true });
17
+ const path = join(work, "output.json");
18
+ writeFileSync(path + ".partial", canonicalJson(envelope));
19
+ renameSync(path + ".partial", path);
20
+ return path;
21
+ }
22
+
23
+ /** The envelope as one compact line between sentinels (fallback channel via pod logs). */
24
+ export function sentinelBlock(envelope: Envelope): string {
25
+ return `${RESULT_BEGIN}\n${JSON.stringify(JSON.parse(canonicalJson(envelope)))}\n${RESULT_END}\n`;
26
+ }
27
+
28
+ /** Packs output.json + files/ into result.tar.gz (deterministic). */
29
+ export async function packResult(work: string, out: string): Promise<void> {
30
+ const entries: TarEntry[] = [{ path: "output.json", type: "file", mode: 0o644, source: join(work, "output.json") }];
31
+ const files = join(work, "files");
32
+ if (existsSync(files)) entries.push({ path: "files", type: "dir", mode: 0o755 }, ...dirEntries(files, "files"));
33
+ await writeTarGz(out, entries);
34
+ }
35
+
36
+ const TRANSIENT = (status: number) => status >= 500 || status === 408 || status === 429;
37
+
38
+ /** PUTs `file` to a pre-signed URL; retries network errors / 5xx / 408 / 429 with backoff, fails fast on other 4xx. */
39
+ export async function uploadFile(
40
+ url: string,
41
+ file: string,
42
+ log: (line: string) => void,
43
+ opts: { attempts?: number; baseDelayMs?: number } = {},
44
+ ): Promise<void> {
45
+ const attempts = opts.attempts ?? 5;
46
+ const base = opts.baseDelayMs ?? 1000;
47
+ for (let i = 1; ; i++) {
48
+ let status = 0;
49
+ let detail: string;
50
+ try {
51
+ const res = await fetch(url, { method: "PUT", body: Bun.file(file) });
52
+ status = res.status;
53
+ if (res.ok) return;
54
+ detail = `HTTP ${res.status} ${res.statusText}`;
55
+ await res.body?.cancel();
56
+ } catch (e) {
57
+ detail = (e as Error).message;
58
+ }
59
+ const transient = status === 0 || TRANSIENT(status);
60
+ if (!transient || i >= attempts) throw new Error(`upload to ${redactUrl(url)} failed: ${detail}`);
61
+ log(`upload to ${redactUrl(url)} failed (${detail}); retry ${i}/${attempts - 1}`);
62
+ await Bun.sleep(base * 2 ** (i - 1));
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Clears results of a previous run in the same $OLVE_DIR (dev loops reuse it): output.json and the
68
+ * *contents* of files/. The directories themselves are kept (olve-init creates them 1777).
69
+ */
70
+ export function removeStale(work: string): void {
71
+ rmSync(join(work, "output.json"), { force: true });
72
+ rmSync(join(work, "output.json.partial"), { force: true });
73
+ const files = join(work, "files");
74
+ if (existsSync(files)) for (const e of readdirSync(files)) rmSync(join(files, e), { recursive: true, force: true });
75
+ }
@@ -0,0 +1,74 @@
1
+ import { z } from "zod";
2
+ import { isModel, isOptional } from "../core/models.ts";
3
+ import type { AnyStep } from "../core/steps.ts";
4
+
5
+ export type Validation<T> = { ok: true; value: T } | { ok: false; issues: string[] };
6
+
7
+ export function formatIssues(err: z.ZodError, prefix: string): string[] {
8
+ return err.issues.map((i) => `${prefix}${i.path.length ? "." + i.path.map(String).join(".") : ""}: ${i.message}`);
9
+ }
10
+
11
+ const isPlainObject = (v: unknown): v is Record<string, unknown> =>
12
+ typeof v === "object" && v !== null && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
13
+
14
+ /**
15
+ * zod strips unknown keys silently (at every depth). Compares the raw value with the parsed one:
16
+ * any key present in a raw object but absent from the parsed object at the same place was stripped.
17
+ */
18
+ export function strippedKeys(raw: unknown, parsed: unknown, path = ""): string[] {
19
+ if (Array.isArray(raw) && Array.isArray(parsed)) return raw.flatMap((x, i) => strippedKeys(x, parsed[i], `${path}[${i}]`));
20
+ if (isPlainObject(raw) && isPlainObject(parsed)) {
21
+ const out: string[] = [];
22
+ for (const k of Object.keys(raw)) {
23
+ const p = path ? `${path}.${k}` : k;
24
+ if (!Object.hasOwn(parsed, k)) out.push(p);
25
+ else out.push(...strippedKeys(raw[k], parsed[k], p));
26
+ }
27
+ return out;
28
+ }
29
+ return [];
30
+ }
31
+
32
+ /** Strict parse: zod validation plus rejection of unknown keys at any depth. */
33
+ export function parseStrict(schema: z.ZodType, value: unknown, prefix: string): Validation<unknown> {
34
+ const r = schema.safeParse(value);
35
+ if (!r.success) return { ok: false, issues: formatIssues(r.error, prefix) };
36
+ const extra = strippedKeys(value, r.data);
37
+ if (extra.length) return { ok: false, issues: extra.map((k) => `${prefix}.${k}: unknown field (outputs are validated strictly)`) };
38
+ return { ok: true, value: r.data };
39
+ }
40
+
41
+ function entrySchema(e: unknown): z.ZodType {
42
+ if (isOptional(e)) return e.model.schema;
43
+ if (isModel(e)) return e.schema;
44
+ return e as z.ZodType;
45
+ }
46
+
47
+ /** Validates input.json against the step's input entries. Unknown top-level keys are rejected; nested values are parsed (unknown nested keys stripped). */
48
+ export function validateInput(step: AnyStep, raw: unknown): Validation<Record<string, unknown>> {
49
+ if (!isPlainObject(raw)) return { ok: false, issues: ["input.json must be a JSON object keyed by the step's input names"] };
50
+ const issues: string[] = [];
51
+ const out: Record<string, unknown> = {};
52
+ const declared = step.input as Record<string, unknown>;
53
+ for (const k of Object.keys(raw)) if (!Object.hasOwn(declared, k)) issues.push(`input.${k}: not an input of step '${step.name}'`);
54
+ for (const [k, e] of Object.entries(declared)) {
55
+ if (!Object.hasOwn(raw, k)) {
56
+ if (isOptional(e)) continue;
57
+ issues.push(`input.${k}: missing`);
58
+ continue;
59
+ }
60
+ const r = entrySchema(e).safeParse(raw[k]);
61
+ if (r.success) out[k] = r.data;
62
+ else issues.push(...formatIssues(r.error, `input.${k}`));
63
+ }
64
+ return issues.length ? { ok: false, issues } : { ok: true, value: out };
65
+ }
66
+
67
+ /** Validates a handler's return value strictly against the step's output model. */
68
+ export function validateOutput(step: AnyStep, value: unknown): Validation<unknown> {
69
+ if (!step.output) {
70
+ if (value === undefined || value === null) return { ok: true, value: null };
71
+ return { ok: false, issues: [`step '${step.name}' declares no output model but the handler returned a value`] };
72
+ }
73
+ return parseStrict(step.output.schema, value, "output");
74
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ /// <reference path="./assets.d.ts" />
2
+ // @olve/pipelines — authoring API (contracts, handlers, pipeline builder).
3
+ export { z } from "zod";
4
+ export { model, fileRef, optional, type Model, type AnyModel, type Infer, type InferInput, type Plain, type FileRef, type Optional } from "./core/models.ts";
5
+ export { secret, type Secret, type DeclaredSecrets } from "./core/secrets.ts";
6
+ export { hmacSha256, githubSignature, bearer, type WebhookVerify, type WebhookEvent } from "./core/webhooks.ts";
7
+ export { step, type Step, type AnyStep, type StepDef, type StepInput, type StepKind, type InputEntry, type InputSpec, type LibraryStep, type OutputValue } from "./core/steps.ts";
8
+ export { implement, type Implementation, type Handler, type HandlerResult, type Skipped } from "./core/handlers.ts";
9
+ export type { Ctx, RunInfo, Sh, ShOptions, ExecOptions, Http, HttpInit, RetryOptions, OutputFileOptions } from "./ctx/types.ts";
10
+ export type { Ref, Mappable, MapperResult } from "./builder/refs.ts";
11
+ export { pipeline, Pipeline, type PipelineDef, type InRefs, type InitialAvail, type WhenCond, type PushEvent, type StageEntry, type CheckStage, type MapperFn } from "./builder/pipeline.ts";
12
+ export { steps, Source, BuiltImage, HelmRelease, FailureContext, type RunValue, type ScriptDef, type GitHubSourceDef, type ImageBuildDef, type HelmDeployOverSshDef, httpHealthCheck, type HttpHealthCheckOptions } from "./library/index.ts";
@@ -0,0 +1,77 @@
1
+ // steps.gitHubSource — a GitHub repository at a commit, as a `Source`.
2
+ //
3
+ // Runs entirely inside the harness (HTTP + tar extraction in Bun): no git, wget or tar is needed
4
+ // in the image, so v1's busybox-wget TLS footgun (`--no-check-certificate`) is gone.
5
+ import { mkdirSync, mkdtempSync, readdirSync, statSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { z } from "zod";
8
+ import type { Ctx } from "../ctx/types.ts";
9
+ import type { Secret } from "../core/secrets.ts";
10
+ import { makeStep, type LibraryStep, type LiteralName, type Step } from "../core/steps.ts";
11
+ import { extractTar } from "../util/tar.ts";
12
+ import { Source } from "./models.ts";
13
+
14
+ /** Image the step runs in. The work happens in the harness, so any image would do. */
15
+ export const GITHUB_SOURCE_IMAGE = "alpine:3.22";
16
+
17
+ const input = { repo: z.string().regex(/^[\w.-]+\/[\w.-]+$/, "expected owner/name"), sha: z.string().min(1) };
18
+ export type GitHubSourceInput = typeof input;
19
+
20
+ export interface GitHubSourceDef<N extends string, T extends string> {
21
+ name: LiteralName<N>;
22
+ /** What the step does, shown in the UI. Required; may be empty. */
23
+ description: string;
24
+ /** Token sent to the GitHub API (needed for private repositories; raises the rate limit). */
25
+ token?: Secret<T>;
26
+ timeoutSeconds?: number;
27
+ }
28
+
29
+ /** Tarball URL of `repo` at `sha` (the API redirects to codeload.github.com). */
30
+ export const gitHubTarballUrl = (repo: string, sha: string): string => `https://api.github.com/repos/${repo}/tarball/${encodeURIComponent(sha)}`;
31
+
32
+ /**
33
+ * Downloads `repo` at `sha` from GitHub as a tarball and outputs it as a `Source` whose `tree` is the
34
+ * repository root (the tarball's single `<owner>-<repo>-<sha>/` directory, stripped).
35
+ * Input `{ repo: "owner/name", sha }` is a plain schema, so the stage needs a mapper.
36
+ */
37
+ export function gitHubSource<const N extends string, const T extends string = never>(
38
+ def: GitHubSourceDef<N, T>,
39
+ ): Step<N, GitHubSourceInput, typeof Source, T> & LibraryStep {
40
+ return makeStep(
41
+ "gitHubSource",
42
+ { name: def.name, description: def.description, image: GITHUB_SOURCE_IMAGE, input, output: Source, secrets: def.token ? [def.token] : [], timeoutSeconds: def.timeoutSeconds },
43
+ {
44
+ manifestConfig: () => ({ ...(def.token ? { token: def.token.name } : {}) }),
45
+ async run({ repo, sha }: { repo: string; sha: string }, ctx: Ctx) {
46
+ const headers: Record<string, string> = { accept: "application/vnd.github+json", "user-agent": "olve-pipelines" };
47
+ if (def.token) headers.authorization = `Bearer ${ctx.secrets[def.token.name]}`;
48
+ const url = gitHubTarballUrl(repo, sha);
49
+ mkdirSync(ctx.tmpDir, { recursive: true });
50
+ const dir = mkdtempSync(join(ctx.tmpDir, "github-source-"));
51
+ const archive = join(dir, "repo.tar.gz");
52
+
53
+ ctx.log("fetching source", { repo, sha });
54
+ // Retries network errors and 5xx; a 4xx (bad sha, no access) fails at once.
55
+ const status = await ctx.retry({ attempts: 4, delaySeconds: 2, backoff: 2 }, async () => {
56
+ const res = await ctx.http.fetch(url, { headers });
57
+ if (res.status >= 500) throw new Error(`HTTP ${res.status} from ${url}`);
58
+ if (res.ok) await Bun.write(archive, res);
59
+ return res.status;
60
+ });
61
+ if (status < 200 || status >= 300) {
62
+ throw new Error(`cannot fetch ${repo}@${sha}: HTTP ${status} from ${url}${def.token ? "" : " (no token configured: a private repository needs one)"}`);
63
+ }
64
+
65
+ const extracted = join(dir, "x");
66
+ mkdirSync(extracted);
67
+ await extractTar(archive, extracted);
68
+ // GitHub tarballs hold one top-level directory, plus a pax_global_header entry (the commit id)
69
+ // that some extractors materialise as a file.
70
+ const roots = readdirSync(extracted).filter((e) => e !== "pax_global_header" && statSync(join(extracted, e)).isDirectory());
71
+ if (roots.length !== 1) throw new Error(`unexpected tarball layout for ${repo}@${sha}: top-level entries ${JSON.stringify(readdirSync(extracted))}`);
72
+ const tree = await ctx.output.dir(join(extracted, roots[0]!), { name: "tree" });
73
+ return { repo, sha, tree };
74
+ },
75
+ },
76
+ ) as never;
77
+ }
@@ -0,0 +1,151 @@
1
+ // steps.helmDeployOverSsh — import a built image into a k3s host's containerd and helm-upgrade a
2
+ // release there, over SSH; optionally verify an HTTP health endpoint afterwards.
3
+ //
4
+ // Ported from v1's olve-lib.sh (olve_ssh_host, olve_image_import, olve_helm_deploy) and
5
+ // deploy-beta.sh (rollout wait + health loop); the footguns are kept as comments below.
6
+ import { chmodSync, existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import type { Ctx } from "../ctx/types.ts";
9
+ import type { FileRef } from "../core/models.ts";
10
+ import type { Secret } from "../core/secrets.ts";
11
+ import { makeStep, type LibraryStep, type LiteralName, type Step } from "../core/steps.ts";
12
+ import { httpHealthCheck } from "./http-health.ts";
13
+ import { BuiltImage, HelmRelease, Source } from "./models.ts";
14
+
15
+ /** Alpine: `apk add openssh-client` at run time gives ssh/scp (the health check runs in the harness). */
16
+ export const HELM_DEPLOY_IMAGE = "alpine:3.22";
17
+
18
+ /**
19
+ * Footgun: import via the k3s containerd socket, NOT the default one — kubelet only sees images in
20
+ * the k3s instance's `k8s.io` namespace (otherwise pods fail with ErrImageNeverPull).
21
+ */
22
+ export const K3S_IMAGE_IMPORT = "sudo nerdctl --address /run/k3s/containerd/containerd.sock --namespace k8s.io load";
23
+
24
+ const ROLLOUT_TIMEOUT = "120s";
25
+
26
+ const input = { source: Source, image: BuiltImage };
27
+ export type HelmDeployOverSshInput = typeof input;
28
+
29
+ export interface HelmDeployOverSshDef<N extends string, K extends string> {
30
+ name: LiteralName<N>;
31
+ /** What the step does, shown in the UI. Required; may be empty. */
32
+ description: string;
33
+ /** SSH target, `user@host`. The user needs passwordless sudo for nerdctl, and helm/kubectl with a kubeconfig. */
34
+ host: string;
35
+ /** Private key for `host`. */
36
+ sshKey: Secret<K>;
37
+ /** Helm release name; the chart's Deployment is expected to be named the same (rollout wait). */
38
+ release: string;
39
+ namespace: string;
40
+ /** Chart directory inside the source tree, e.g. `helm`. */
41
+ chart: string;
42
+ /** Values files, relative to the chart directory (`-f`, in order). */
43
+ values?: readonly string[];
44
+ /**
45
+ * Chart-specific `--set` flags (e.g. `{ "slo.enabled": "false" }`). The image flags are added by
46
+ * the step and always win: `image.repository`, `image.tag`, `image.pullPolicy=Never`.
47
+ */
48
+ set?: Readonly<Record<string, string>>;
49
+ /** After the rollout, GET this URL until it returns 2xx, 5 × 5 s (`httpHealthCheck`), or fail the step. */
50
+ healthUrl?: string;
51
+ timeoutSeconds?: number;
52
+ }
53
+
54
+ const SAFE = /^[A-Za-z0-9_\/.:=@%+,-]+$/;
55
+ /** POSIX shell quoting (safe words are left bare, so logs stay readable). */
56
+ export function shq(s: string): string {
57
+ return SAFE.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
58
+ }
59
+
60
+ /** Remote `helm upgrade --install`, run from inside the copied chart directory. */
61
+ export function helmUpgradeCommand(o: {
62
+ remoteChart: string;
63
+ release: string;
64
+ namespace: string;
65
+ values: readonly string[];
66
+ set: Readonly<Record<string, string>>;
67
+ repository: string;
68
+ tag: string;
69
+ }): string {
70
+ // Chart-relative values paths (`-f values-beta.yaml`) resolve because helm runs from inside the
71
+ // copied chart dir. Extra flags come before the image flags, so the image overrides win. The image
72
+ // is local to the node (imported above), never pulled: pullPolicy=Never.
73
+ const flags = [
74
+ ...o.values.flatMap((v) => ["-f", v]),
75
+ ...Object.entries(o.set).flatMap(([k, v]) => ["--set", `${k}=${v}`]),
76
+ "--set", `image.repository=${o.repository}`,
77
+ "--set", `image.tag=${o.tag}`,
78
+ "--set", "image.pullPolicy=Never",
79
+ ];
80
+ return [
81
+ `cd ${shq(o.remoteChart)}`,
82
+ ["helm", "upgrade", "--install", o.release, ".", "-n", o.namespace, ...flags].map(shq).join(" "),
83
+ `cd / && rm -rf ${shq(o.remoteChart)}`,
84
+ ].join(" && ");
85
+ }
86
+
87
+ /**
88
+ * Deploys `image` with the Helm chart at `<source>/<chart>` on a k3s host over SSH:
89
+ * stream the image tar into the host's k3s containerd, copy the chart, `helm upgrade --install`,
90
+ * wait for `deploy/<release>` to roll out, then (with `healthUrl`) poll the health endpoint.
91
+ */
92
+ export function helmDeployOverSsh<const N extends string, const K extends string>(
93
+ def: HelmDeployOverSshDef<N, K>,
94
+ ): Step<N, HelmDeployOverSshInput, typeof HelmRelease, K> & LibraryStep {
95
+ const values = def.values ?? [];
96
+ const set = def.set ?? {};
97
+ return makeStep(
98
+ "helmDeployOverSsh",
99
+ { name: def.name, description: def.description, image: HELM_DEPLOY_IMAGE, input, output: HelmRelease, secrets: [def.sshKey], timeoutSeconds: def.timeoutSeconds },
100
+ {
101
+ manifestConfig: () => ({
102
+ host: def.host,
103
+ sshKey: def.sshKey.name,
104
+ release: def.release,
105
+ namespace: def.namespace,
106
+ chart: def.chart,
107
+ values: [...values],
108
+ set: { ...set },
109
+ ...(def.healthUrl ? { healthUrl: def.healthUrl } : {}),
110
+ }),
111
+ async run(
112
+ { source, image }: { source: { tree: FileRef }; image: { repository: string; tag: string; tar: FileRef } },
113
+ ctx: Ctx,
114
+ ) {
115
+ const tree = await ctx.files.fetch(source.tree);
116
+ const chartDir = join(tree, def.chart);
117
+ if (!existsSync(chartDir) || !statSync(chartDir).isDirectory()) throw new Error(`chart directory '${def.chart}' not found in the source tree`);
118
+ const imageTar = await ctx.files.fetch(image.tar);
119
+
120
+ // SSH setup. The key file needs a trailing newline (OpenSSH rejects the key otherwise) and
121
+ // mode 600. The host key is not verified (as in v1): the host is reached over the tailnet.
122
+ await ctx.exec(["apk", "add", "--no-cache", "openssh-client"]);
123
+ const sshDir = join(ctx.tmpDir, "ssh");
124
+ mkdirSync(sshDir, { recursive: true, mode: 0o700 });
125
+ const keyFile = join(sshDir, "id");
126
+ const key = ctx.secrets[def.sshKey.name]!;
127
+ writeFileSync(keyFile, key.endsWith("\n") ? key : key + "\n", { mode: 0o600 });
128
+ chmodSync(keyFile, 0o600);
129
+ const opts = ["-i", keyFile, "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR", "-o", "BatchMode=yes"];
130
+ const ssh = (remote: string) => ctx.exec(["ssh", ...opts, def.host, remote]);
131
+
132
+ const ref = `${image.repository}:${image.tag}`;
133
+ ctx.log("importing image", { image: ref, host: def.host });
134
+ // Streamed over ssh's stdin (ctx.exec has none, hence the shell redirect).
135
+ await ctx.sh(["ssh", ...opts, def.host, K3S_IMAGE_IMPORT].map(shq).join(" ") + ` < ${shq(imageTar)}`);
136
+
137
+ const remoteChart = `/tmp/${def.release}-helm-${def.namespace}`;
138
+ ctx.log("helm upgrade", { release: def.release, namespace: def.namespace, image: ref });
139
+ await ssh(`rm -rf ${shq(remoteChart)}`);
140
+ await ctx.exec(["scp", ...opts, "-r", chartDir, `${def.host}:${remoteChart}`]);
141
+ await ssh(helmUpgradeCommand({ remoteChart, release: def.release, namespace: def.namespace, values, set, repository: image.repository, tag: image.tag }));
142
+
143
+ ctx.log("waiting for rollout", { deployment: def.release });
144
+ await ssh(`kubectl -n ${shq(def.namespace)} rollout status ${shq(`deploy/${def.release}`)} --timeout=${ROLLOUT_TIMEOUT}`);
145
+
146
+ if (def.healthUrl) await httpHealthCheck(ctx, def.healthUrl);
147
+ return { release: def.release, namespace: def.namespace, image: ref };
148
+ },
149
+ },
150
+ ) as never;
151
+ }
@@ -0,0 +1,34 @@
1
+ // httpHealthCheck — GET a URL until it answers 2xx, or fail.
2
+ //
3
+ // The general HTTP health check (ported from v1's deploy-beta.sh curl loop). Library steps use it
4
+ // (helmDeployOverSsh's `healthUrl`), and it is exported for custom handlers, which get it with
5
+ // their own retry policy. Runs in the harness (Bun fetch), so the image needs no curl.
6
+ import type { Ctx } from "../ctx/types.ts";
7
+
8
+ export interface HttpHealthCheckOptions {
9
+ /** Total attempts (v1: 5). */
10
+ attempts?: number;
11
+ /** Delay between attempts, in seconds (v1: 5). */
12
+ delaySeconds?: number;
13
+ /** Request headers (e.g. an Authorization header built from a secret). */
14
+ headers?: Record<string, string>;
15
+ }
16
+
17
+ /** Defaults match v1's deploy-beta.sh: 5 attempts, 5 s apart. */
18
+ export const HTTP_HEALTH_DEFAULTS = { attempts: 5, delaySeconds: 5 } as const;
19
+
20
+ /**
21
+ * GETs `url` until it returns a 2xx status (redirects are followed), retrying network errors and
22
+ * non-2xx statuses. Throws the last error (status + URL, query redacted) once the attempts run out.
23
+ */
24
+ export async function httpHealthCheck(
25
+ ctx: Pick<Ctx, "http" | "retry" | "log">,
26
+ url: string,
27
+ opts: HttpHealthCheckOptions = {},
28
+ ): Promise<void> {
29
+ const attempts = opts.attempts ?? HTTP_HEALTH_DEFAULTS.attempts;
30
+ const delaySeconds = opts.delaySeconds ?? HTTP_HEALTH_DEFAULTS.delaySeconds;
31
+ ctx.log("checking health", { url: url.split("?")[0]!, attempts });
32
+ await ctx.retry({ attempts, delaySeconds }, () => ctx.http.expectOk(url, opts.headers ? { headers: opts.headers } : undefined));
33
+ ctx.log("health OK", { url: url.split("?")[0]! });
34
+ }