@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
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// steps.imageBuild — build a container image from a `Source` with kaniko, to an image tarball.
|
|
2
|
+
//
|
|
3
|
+
// No registry push: the tarball travels as a file ref and a deploy step imports it on the host.
|
|
4
|
+
// Ported from v1's olve-lib.sh (olve_kaniko_build + build.sh's layer cache); footguns kept below.
|
|
5
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import type { Ctx, RunInfo } from "../ctx/types.ts";
|
|
8
|
+
import type { FileRef } from "../core/models.ts";
|
|
9
|
+
import { makeStep, type LibraryStep, type LiteralName, type Step } from "../core/steps.ts";
|
|
10
|
+
import { BuiltImage, Source } from "./models.ts";
|
|
11
|
+
|
|
12
|
+
/** kaniko's debug image (it has a busybox shell for debugging; the executor itself needs none). */
|
|
13
|
+
export const KANIKO_IMAGE = "gcr.io/kaniko-project/executor:debug";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Footgun: the build context must live under /kaniko, NOT /workspace (or another plain directory in
|
|
17
|
+
* the container): kaniko wipes the filesystem root between the stages of a multi-stage build, which
|
|
18
|
+
* breaks a later stage's COPY from the context. /kaniko survives stage transitions.
|
|
19
|
+
*/
|
|
20
|
+
export const KANIKO_CONTEXT = "/kaniko/build-context";
|
|
21
|
+
|
|
22
|
+
const input = { source: Source };
|
|
23
|
+
export type ImageBuildInput = typeof input;
|
|
24
|
+
|
|
25
|
+
export interface ImageBuildDef<N extends string> {
|
|
26
|
+
name: LiteralName<N>;
|
|
27
|
+
/** What the step does, shown in the UI. Required; may be empty. */
|
|
28
|
+
description: string;
|
|
29
|
+
/** Image name, e.g. `olve-pipelines-v2`. The tag is the run's version (see `imageTag`). */
|
|
30
|
+
repository: string;
|
|
31
|
+
/**
|
|
32
|
+
* Registry repository for kaniko's layer cache (`--cache=true --cache-repo=…`), so unchanged
|
|
33
|
+
* layers (restore, apt, npm, …) are reused. kaniko pushes cache layers even with `--no-push`.
|
|
34
|
+
* A registry without GC grows with every changed layer — prune it when needed.
|
|
35
|
+
*/
|
|
36
|
+
cacheRepo?: string;
|
|
37
|
+
/**
|
|
38
|
+
* kaniko `--registry-map` (registry → mirror prefix); kaniko falls back to the original registry if
|
|
39
|
+
* the mirror lacks the image. Defaults to the homelab pull-through caches (`DEFAULT_REGISTRY_MAP`);
|
|
40
|
+
* a map replaces the default entirely, `{}` turns mapping off.
|
|
41
|
+
*/
|
|
42
|
+
registryMap?: Record<string, string>;
|
|
43
|
+
timeoutSeconds?: number;
|
|
44
|
+
/**
|
|
45
|
+
* Kill kaniko when it writes no output for this long (default 300 s; 0 disables), failing the
|
|
46
|
+
* attempt so `retries` can start a fresh one. Catches base-image pulls that stall on an idle
|
|
47
|
+
* connection (seen with mcr.microsoft.com), which kaniko itself never times out. Keep it well above
|
|
48
|
+
* the longest silent build phase (an AOT "Generating native code" runs ~2 min with no output).
|
|
49
|
+
*/
|
|
50
|
+
stallTimeoutSeconds?: number;
|
|
51
|
+
/**
|
|
52
|
+
* Automatic retries of a failed attempt (a fresh pod: kaniko unpacks the base image over the
|
|
53
|
+
* container root and does not clean up when killed, so retrying inside the same pod is unsafe).
|
|
54
|
+
* A `--no-push` build is idempotent, so the default is 2 (three tries).
|
|
55
|
+
*/
|
|
56
|
+
retries?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Default `registryMap`: pull-through caches for mcr.microsoft.com and Docker Hub. kaniko under gVisor
|
|
61
|
+
* can stall forever pulling from mcr.microsoft.com directly.
|
|
62
|
+
*/
|
|
63
|
+
export const DEFAULT_REGISTRY_MAP: Readonly<Record<string, string>> = {
|
|
64
|
+
"mcr.microsoft.com": "mcr-cache.ovea.pro",
|
|
65
|
+
"index.docker.io": "hub-cache.ovea.pro",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** How long a cache registry gets to answer `GET /v2/` before the build goes without it. */
|
|
69
|
+
export const CACHE_PROBE_TIMEOUT_MS = 5000;
|
|
70
|
+
|
|
71
|
+
/** The registry host of an image reference or mirror prefix (`registry.ovea.pro/a/b` → `registry.ovea.pro`). */
|
|
72
|
+
export function registryHost(ref: string): string {
|
|
73
|
+
return ref.split("/")[0]!;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Whether a registry answers its API root: 200, or 401 (it wants a token). Never throws. */
|
|
77
|
+
export async function registryUp(http: Ctx["http"], host: string, signal?: AbortSignal, timeoutMs = CACHE_PROBE_TIMEOUT_MS): Promise<boolean> {
|
|
78
|
+
try {
|
|
79
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
80
|
+
const res = await http.fetch(`https://${host}/v2/`, { signal: signal ? AbortSignal.any([signal, timeout]) : timeout });
|
|
81
|
+
await res.body?.cancel().catch(() => {});
|
|
82
|
+
return res.status === 200 || res.status === 401;
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Drops the caches that don't answer, so a cache outage (GC, maintenance) never blocks a build: an
|
|
90
|
+
* unreachable mirror is left out of the registry map, an unreachable `cacheRepo` means no layer cache.
|
|
91
|
+
*/
|
|
92
|
+
export async function reachableCaches(
|
|
93
|
+
ctx: Pick<Ctx, "http" | "log" | "signal">,
|
|
94
|
+
caches: { registryMap: Record<string, string>; cacheRepo?: string },
|
|
95
|
+
): Promise<{ registryMap: Record<string, string>; cacheRepo?: string }> {
|
|
96
|
+
const hosts = new Set([...Object.values(caches.registryMap), ...(caches.cacheRepo ? [caches.cacheRepo] : [])].map(registryHost));
|
|
97
|
+
const up = new Map(await Promise.all([...hosts].map(async (h) => [h, await registryUp(ctx.http, h, ctx.signal)] as const)));
|
|
98
|
+
const registryMap: Record<string, string> = {};
|
|
99
|
+
for (const [from, to] of Object.entries(caches.registryMap)) {
|
|
100
|
+
if (up.get(registryHost(to))) registryMap[from] = to;
|
|
101
|
+
else ctx.log(`${registryHost(to)} unreachable; pulling from ${from} directly`);
|
|
102
|
+
}
|
|
103
|
+
if (caches.cacheRepo && !up.get(registryHost(caches.cacheRepo))) {
|
|
104
|
+
ctx.log(`${registryHost(caches.cacheRepo)} unreachable; building without the layer cache`);
|
|
105
|
+
return { registryMap };
|
|
106
|
+
}
|
|
107
|
+
return { registryMap, ...(caches.cacheRepo ? { cacheRepo: caches.cacheRepo } : {}) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Default `retries`: a stalled or flaky build gets two fresh attempts before the run fails. */
|
|
111
|
+
export const DEFAULT_RETRIES = 2;
|
|
112
|
+
|
|
113
|
+
/** Default `stallTimeoutSeconds`: well above the longest silent phase of a normal build. */
|
|
114
|
+
export const DEFAULT_STALL_TIMEOUT_SECONDS = 300;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The image tag for this run: the run's version stamp, plus `-attempt<n>` on reruns. A rebuilt image
|
|
118
|
+
* must not reuse the tag: deploys import it with `pullPolicy=Never`, so an unchanged tag would leave
|
|
119
|
+
* the old pods running.
|
|
120
|
+
*/
|
|
121
|
+
export function imageTag(run: Pick<RunInfo, "version" | "attempt">): string {
|
|
122
|
+
const tag = run.attempt > 1 ? `${run.version}-attempt${run.attempt}` : run.version;
|
|
123
|
+
if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(tag)) throw new Error(`'${tag}' (from the run's version stamp) is not a valid image tag`);
|
|
124
|
+
return tag;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The name a container runtime gives an image after loading it (`x` → `docker.io/library/x`). */
|
|
128
|
+
export function qualifyRepository(repository: string): string {
|
|
129
|
+
const first = repository.split("/")[0]!;
|
|
130
|
+
if (!repository.includes("/")) return `docker.io/library/${repository}`;
|
|
131
|
+
if (first.includes(".") || first.includes(":") || first === "localhost") return repository;
|
|
132
|
+
return `docker.io/${repository}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Builds `<source>/Dockerfile` with the source root as context, using kaniko, into an image tarball.
|
|
137
|
+
* Output: `BuiltImage { repository, tag, digest, tar }`.
|
|
138
|
+
*/
|
|
139
|
+
export function imageBuild<const N extends string>(def: ImageBuildDef<N>): Step<N, ImageBuildInput, typeof BuiltImage, never> & LibraryStep {
|
|
140
|
+
const registryMap = def.registryMap ?? DEFAULT_REGISTRY_MAP;
|
|
141
|
+
const mapped = Object.keys(registryMap).length > 0;
|
|
142
|
+
return makeStep(
|
|
143
|
+
"imageBuild",
|
|
144
|
+
{ name: def.name, description: def.description, image: KANIKO_IMAGE, input, output: BuiltImage, secrets: [], timeoutSeconds: def.timeoutSeconds, retries: def.retries ?? DEFAULT_RETRIES },
|
|
145
|
+
{
|
|
146
|
+
manifestConfig: () => ({
|
|
147
|
+
repository: def.repository,
|
|
148
|
+
...(def.cacheRepo ? { cacheRepo: def.cacheRepo } : {}),
|
|
149
|
+
...(mapped ? { registryMap: { ...registryMap } } : {}),
|
|
150
|
+
}),
|
|
151
|
+
async run({ source }: { source: { tree: FileRef } }, ctx: Ctx) {
|
|
152
|
+
const tag = imageTag(ctx.run);
|
|
153
|
+
const caches = await reachableCaches(ctx, { registryMap, cacheRepo: def.cacheRepo });
|
|
154
|
+
const tree = await ctx.files.fetch(source.tree);
|
|
155
|
+
await ctx.exec(["mkdir", "-p", KANIKO_CONTEXT]);
|
|
156
|
+
await ctx.exec(["cp", "-r", `${tree}/.`, KANIKO_CONTEXT]);
|
|
157
|
+
|
|
158
|
+
// Outputs go to the harness's scratch dir (/olve/tmp, an emptyDir mount kaniko leaves alone;
|
|
159
|
+
// kaniko images have no /tmp), then into the step's files.
|
|
160
|
+
mkdirSync(ctx.tmpDir, { recursive: true });
|
|
161
|
+
const out = mkdtempSync(join(ctx.tmpDir, "image-build-"));
|
|
162
|
+
const tarPath = join(out, "image.tar");
|
|
163
|
+
const digestPath = join(out, "digest");
|
|
164
|
+
// No --single-snapshot: it is incompatible with multi-stage Dockerfiles whose later stages need
|
|
165
|
+
// the build context intact. --skip-push-permission-check: with --no-push kaniko otherwise checks
|
|
166
|
+
// the cache repo at startup and exits if it is down; later cache lookup and push errors are
|
|
167
|
+
// only warnings.
|
|
168
|
+
await ctx.exec([
|
|
169
|
+
"/kaniko/executor",
|
|
170
|
+
`--context=${KANIKO_CONTEXT}`,
|
|
171
|
+
`--dockerfile=${KANIKO_CONTEXT}/Dockerfile`,
|
|
172
|
+
"--no-push",
|
|
173
|
+
`--tar-path=${tarPath}`,
|
|
174
|
+
`--digest-file=${digestPath}`,
|
|
175
|
+
`--destination=${def.repository}:${tag}`,
|
|
176
|
+
...(caches.cacheRepo ? ["--cache=true", `--cache-repo=${caches.cacheRepo}`, "--skip-push-permission-check"] : []),
|
|
177
|
+
...Object.entries(caches.registryMap).map(([from, to]) => `--registry-map=${from}=${to}`),
|
|
178
|
+
], { idleTimeoutSeconds: def.stallTimeoutSeconds ?? DEFAULT_STALL_TIMEOUT_SECONDS });
|
|
179
|
+
|
|
180
|
+
let digest: string;
|
|
181
|
+
try {
|
|
182
|
+
digest = readFileSync(digestPath, "utf8").trim();
|
|
183
|
+
} catch {
|
|
184
|
+
throw new Error(`kaniko did not write the image digest to ${digestPath}`);
|
|
185
|
+
}
|
|
186
|
+
if (!/^sha256:[0-9a-f]{64}$/.test(digest)) throw new Error(`kaniko wrote an unexpected digest: '${digest}'`);
|
|
187
|
+
const tar = await ctx.output.file(tarPath, { name: "image.tar" });
|
|
188
|
+
rmSync(tarPath); // the copy in the step's files is the one that counts; don't hold the image twice in /olve
|
|
189
|
+
ctx.log("image built", { image: `${def.repository}:${tag}`, digest });
|
|
190
|
+
return { repository: qualifyRepository(def.repository), tag, digest, tar };
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
) as never;
|
|
194
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { gitHubSource } from "./github-source.ts";
|
|
2
|
+
import { helmDeployOverSsh } from "./helm-deploy-ssh.ts";
|
|
3
|
+
import { imageBuild } from "./image-build.ts";
|
|
4
|
+
import { script } from "./script.ts";
|
|
5
|
+
|
|
6
|
+
export { Source, BuiltImage, HelmRelease, FailureContext } from "./models.ts";
|
|
7
|
+
export { inputEnvName, type RunValue, type ScriptDef } from "./script.ts";
|
|
8
|
+
export type { GitHubSourceDef } from "./github-source.ts";
|
|
9
|
+
export type { ImageBuildDef } from "./image-build.ts";
|
|
10
|
+
export type { HelmDeployOverSshDef } from "./helm-deploy-ssh.ts";
|
|
11
|
+
export { httpHealthCheck, HTTP_HEALTH_DEFAULTS, type HttpHealthCheckOptions } from "./http-health.ts";
|
|
12
|
+
|
|
13
|
+
/** SDK-provided steps (step + handler pairs with typed config). */
|
|
14
|
+
export const steps = { script, gitHubSource, imageBuild, helmDeployOverSsh };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { fileRef, model } from "../core/models.ts";
|
|
3
|
+
|
|
4
|
+
/** A source tree at a commit. `tree` references a directory (`path` ends with `/`). */
|
|
5
|
+
export const Source = model("Source", z.object({ repo: z.string(), sha: z.string(), tree: fileRef() }));
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A container image built by a step, as an image tarball (`docker save` format) in the step's files.
|
|
9
|
+
* `repository` is fully qualified the way a container runtime names it after loading the tar
|
|
10
|
+
* (`olve-pipelines-v2` → `docker.io/library/olve-pipelines-v2`).
|
|
11
|
+
*/
|
|
12
|
+
export const BuiltImage = model(
|
|
13
|
+
"BuiltImage",
|
|
14
|
+
z.object({ repository: z.string(), tag: z.string(), digest: z.string(), tar: fileRef() }),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
/** A Helm release that was installed or upgraded. */
|
|
18
|
+
export const HelmRelease = model(
|
|
19
|
+
"HelmRelease",
|
|
20
|
+
z.object({ release: z.string(), namespace: z.string(), image: z.string() }),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
/** One failed step of a run, as seen by a failure handler. */
|
|
24
|
+
export const FailedStep = z.object({
|
|
25
|
+
step: z.string(),
|
|
26
|
+
attempt: z.number().int(),
|
|
27
|
+
/** `<pipeline id>#<run>/<step>@<attempt>` (spec §5.5). */
|
|
28
|
+
address: z.string(),
|
|
29
|
+
/** The attempt's failure reason (handler error, exit code, timeout, …). */
|
|
30
|
+
reason: z.string(),
|
|
31
|
+
/** The attempt's log (`GET …/runs/<n>/steps/<step>/logs?attempt=<a>`). */
|
|
32
|
+
logUrl: z.string(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What a failure handler receives (`.onFailure(handler, …)`). The service builds it when a run fails and
|
|
37
|
+
* runs the handler once per failure, untracked and best-effort (not a run attempt). `failedSteps` lists
|
|
38
|
+
* the run's failed steps the handler is scoped to (all of them for an unscoped handler); never empty.
|
|
39
|
+
*/
|
|
40
|
+
export const FailureContext = model(
|
|
41
|
+
"FailureContext",
|
|
42
|
+
z.object({
|
|
43
|
+
pipeline: z.object({ id: z.string(), name: z.string() }),
|
|
44
|
+
run: z.object({
|
|
45
|
+
id: z.string(),
|
|
46
|
+
number: z.number().int(),
|
|
47
|
+
/** `<pipeline id>#<run>` (spec §5.5). */
|
|
48
|
+
address: z.string(),
|
|
49
|
+
/** The run's derived version, `yyyy.MM.dd.<run>`. */
|
|
50
|
+
versionStamp: z.string(),
|
|
51
|
+
trigger: z.enum(["push", "poll", "manual"]),
|
|
52
|
+
/** The run (`GET …/api/pipelines/<id>/runs/<n>`). */
|
|
53
|
+
url: z.string(),
|
|
54
|
+
}),
|
|
55
|
+
/** The pipeline version the run is pinned to, and the commit it was built from. */
|
|
56
|
+
version: z.object({ number: z.number().int(), commit: z.string().nullable() }),
|
|
57
|
+
/** The run's pipeline input (the pipeline input model's output view). */
|
|
58
|
+
input: z.unknown(),
|
|
59
|
+
/** One line summarising why the run failed. */
|
|
60
|
+
reason: z.string(),
|
|
61
|
+
failedSteps: z.array(FailedStep),
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { Ctx, RunInfo } from "../ctx/types.ts";
|
|
4
|
+
import type { AnyModel } from "../core/models.ts";
|
|
5
|
+
import type { Secret } from "../core/secrets.ts";
|
|
6
|
+
import { makeStep, type InputSpec, type LibraryStep, type Step, type StepDef, type StepInput } from "../core/steps.ts";
|
|
7
|
+
import { sha256Hex } from "../util/hash.ts";
|
|
8
|
+
|
|
9
|
+
/** A config value that may depend on the run: a constant or a function of the step's inputs and run info. */
|
|
10
|
+
export type RunValue<I, T = string> = T | ((input: StepInput<I>, run: RunInfo) => T);
|
|
11
|
+
|
|
12
|
+
export interface ScriptDef<N extends string, I extends InputSpec, O extends AnyModel | null, Sec extends readonly Secret[]>
|
|
13
|
+
extends StepDef<N, I, O, Sec> {
|
|
14
|
+
/** An embedded script: `import s from "./scripts/x.sh" with { type: "file" }`. */
|
|
15
|
+
script: string;
|
|
16
|
+
/** Extra environment; values may be functions of the inputs (evaluated in the step, not at describe). */
|
|
17
|
+
env?: Record<string, RunValue<I>>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `deployTo` → `INPUT_DEPLOY_TO`. */
|
|
21
|
+
export function inputEnvName(key: string): string {
|
|
22
|
+
return "INPUT_" + key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^A-Za-z0-9]+/g, "_").toUpperCase();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Runs a shell script (the zero-TypeScript path). Contract for the script:
|
|
27
|
+
* - `$OLVE_INPUT`: path of the validated input as JSON; each input key is also `INPUT_<KEY>`
|
|
28
|
+
* (strings raw, everything else JSON).
|
|
29
|
+
* - `$OLVE_OUTPUT`: if the step has an output model, write the output JSON there. Objects of the
|
|
30
|
+
* form `{"$file": "<path>"}` / `{"$dir": "<path>"}` are turned into file references.
|
|
31
|
+
* - Declared secrets are in the environment under their names.
|
|
32
|
+
*/
|
|
33
|
+
export function script<
|
|
34
|
+
const N extends string,
|
|
35
|
+
const I extends InputSpec = {},
|
|
36
|
+
O extends AnyModel | null = null,
|
|
37
|
+
const Sec extends readonly Secret[] = [],
|
|
38
|
+
>(def: ScriptDef<N, I, O, Sec>): Step<N, I, O, Sec[number]["name"]> & LibraryStep {
|
|
39
|
+
const env = def.env ?? {};
|
|
40
|
+
return makeStep("script", def as never, {
|
|
41
|
+
manifestConfig: () => ({
|
|
42
|
+
scriptSha256: "sha256:" + sha256Hex(readFileSync(def.script)),
|
|
43
|
+
env: Object.fromEntries(Object.entries(env).filter(([, v]) => typeof v === "string")),
|
|
44
|
+
computedEnv: Object.keys(env).filter((k) => typeof env[k] === "function").sort(),
|
|
45
|
+
}),
|
|
46
|
+
async run(input: Record<string, unknown>, ctx: Ctx) {
|
|
47
|
+
mkdirSync(ctx.tmpDir, { recursive: true });
|
|
48
|
+
const dir = mkdtempSync(join(ctx.tmpDir, "script-"));
|
|
49
|
+
const inputPath = join(dir, "input.json");
|
|
50
|
+
const outputPath = join(dir, "output.json");
|
|
51
|
+
writeFileSync(inputPath, JSON.stringify(input));
|
|
52
|
+
const vars: Record<string, string> = { OLVE_INPUT: inputPath };
|
|
53
|
+
for (const [k, v] of Object.entries(input)) {
|
|
54
|
+
if (v !== undefined) vars[inputEnvName(k)] = typeof v === "string" ? v : JSON.stringify(v);
|
|
55
|
+
}
|
|
56
|
+
for (const [k, v] of Object.entries(env)) {
|
|
57
|
+
vars[k] = typeof v === "function" ? (v as (i: unknown, r: RunInfo) => string)(input, ctx.run) : v;
|
|
58
|
+
}
|
|
59
|
+
if (def.output) vars.OLVE_OUTPUT = outputPath;
|
|
60
|
+
await ctx.sh({ script: def.script }, { env: vars });
|
|
61
|
+
if (!def.output) return undefined;
|
|
62
|
+
let raw: unknown;
|
|
63
|
+
try {
|
|
64
|
+
raw = JSON.parse(readFileSync(outputPath, "utf8"));
|
|
65
|
+
} catch (e) {
|
|
66
|
+
throw new Error(`script did not write valid JSON to $OLVE_OUTPUT: ${(e as Error).message}`);
|
|
67
|
+
}
|
|
68
|
+
return resolveFilePlaceholders(raw, ctx);
|
|
69
|
+
},
|
|
70
|
+
}) as never;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function resolveFilePlaceholders(v: unknown, ctx: Ctx): Promise<unknown> {
|
|
74
|
+
if (Array.isArray(v)) return Promise.all(v.map((x) => resolveFilePlaceholders(x, ctx)));
|
|
75
|
+
if (typeof v === "object" && v !== null) {
|
|
76
|
+
const o = v as Record<string, unknown>;
|
|
77
|
+
const keys = Object.keys(o);
|
|
78
|
+
if (keys.length === 1 && typeof o.$file === "string") return ctx.output.file(o.$file);
|
|
79
|
+
if (keys.length === 1 && typeof o.$dir === "string") return ctx.output.dir(o.$dir);
|
|
80
|
+
const out: Record<string, unknown> = {};
|
|
81
|
+
for (const k of keys) out[k] = await resolveFilePlaceholders(o[k], ctx);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
return v;
|
|
85
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// @olve/pipelines/testing — run a handler under `bun test` through the real harness path
|
|
2
|
+
// (validate input → handler → strict output validation), with a real ctx you can override.
|
|
3
|
+
import { mkdtempSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { isImplementation, type Implementation } from "../core/handlers.ts";
|
|
7
|
+
import type { FileRef } from "../core/models.ts";
|
|
8
|
+
import { isStep, type AnyStep, type LibraryStep, type OutputValue, type SecretsOf, type StepInput } from "../core/steps.ts";
|
|
9
|
+
import { createCtx } from "../ctx/index.ts";
|
|
10
|
+
import type { Ctx, RunInfo } from "../ctx/types.ts";
|
|
11
|
+
import { executeStep, type FailureKind, type StepFunction } from "../harness/execute.ts";
|
|
12
|
+
import { sha256Hex } from "../util/hash.ts";
|
|
13
|
+
|
|
14
|
+
export interface TestHandlerOptions<S extends AnyStep> {
|
|
15
|
+
/** Replace parts of ctx (e.g. `sh`, `http`, `files`). The rest is the real implementation. */
|
|
16
|
+
ctx?: Partial<Ctx<SecretsOf<S>>>;
|
|
17
|
+
/** Secret values (instead of reading the environment). */
|
|
18
|
+
secrets?: Partial<Record<SecretsOf<S>, string>>;
|
|
19
|
+
run?: Partial<Omit<RunInfo, "step">>;
|
|
20
|
+
/** Directory used as $OLVE_DIR (default: a fresh temp dir). */
|
|
21
|
+
dir?: string;
|
|
22
|
+
/** Abort signal handed to ctx.signal. */
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type TestResult<S extends AnyStep> = {
|
|
27
|
+
/** $OLVE_DIR used for the run; output files are under `<dir>/work/files`. */
|
|
28
|
+
dir: string;
|
|
29
|
+
filesDir: string;
|
|
30
|
+
logs: string[];
|
|
31
|
+
exitCode: number;
|
|
32
|
+
} & (
|
|
33
|
+
| { status: "succeeded"; output: OutputValue<S> }
|
|
34
|
+
| { status: "skipped"; reason: string }
|
|
35
|
+
| { status: "failed"; error: { kind: FailureKind; message: string } }
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Runs a handler (the default export of `handlers/<step>.ts`) or a library step with `input`.
|
|
40
|
+
* Input is validated like `input.json`, the result like a real run: an invalid output gives
|
|
41
|
+
* `{ status: "failed", error: { kind: "output" } }`, a throwing handler `kind: "handler"`.
|
|
42
|
+
*/
|
|
43
|
+
export async function testHandler<S extends AnyStep>(
|
|
44
|
+
target: Implementation<S> | (S & LibraryStep),
|
|
45
|
+
input: StepInput<S["input"]>,
|
|
46
|
+
opts: TestHandlerOptions<S> = {},
|
|
47
|
+
): Promise<TestResult<S>> {
|
|
48
|
+
let step: AnyStep;
|
|
49
|
+
let fn: StepFunction;
|
|
50
|
+
if (isImplementation(target)) {
|
|
51
|
+
step = target.step;
|
|
52
|
+
fn = target.handler as unknown as StepFunction;
|
|
53
|
+
} else if (isStep(target) && target.builtin) {
|
|
54
|
+
step = target;
|
|
55
|
+
fn = target.builtin.run as StepFunction;
|
|
56
|
+
} else throw new TypeError("testHandler expects implement(...) (a handler module's default export) or a library step");
|
|
57
|
+
|
|
58
|
+
const dir = opts.dir ?? mkdtempSync(join(tmpdir(), `olve-test-${step.name}-`));
|
|
59
|
+
const filesDir = join(dir, "work", "files");
|
|
60
|
+
const logs: string[] = [];
|
|
61
|
+
const run: RunInfo = { pipeline: "test", id: "test", number: 1, attempt: 1, version: "0.0.0-test", ...opts.run, step: step.name };
|
|
62
|
+
const real = createCtx({
|
|
63
|
+
run,
|
|
64
|
+
secrets: step.secrets,
|
|
65
|
+
secretValues: opts.secrets as Record<string, string> | undefined,
|
|
66
|
+
paths: { root: dir, workFiles: filesDir, tmp: join(dir, "tmp") },
|
|
67
|
+
signal: opts.signal ?? new AbortController().signal,
|
|
68
|
+
logSink: (l) => logs.push(l),
|
|
69
|
+
});
|
|
70
|
+
const ctx = { ...real, ...opts.ctx } as Ctx;
|
|
71
|
+
// Round-trip through JSON like input.json does (drops undefined, rejects non-JSON values early).
|
|
72
|
+
const raw = JSON.parse(JSON.stringify(input ?? {}));
|
|
73
|
+
const exec = await executeStep({ step, fn, rawInput: raw, ctx, workFiles: filesDir, log: (l) => logs.push(l) });
|
|
74
|
+
return { dir, filesDir, logs, exitCode: exec.exitCode, ...exec.envelope } as TestResult<S>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A file reference for test inputs (`sha256` defaults to the hash of `content`). */
|
|
78
|
+
export function testFileRef(ref: { step: string; path: string; run?: number; attempt?: number; sha256?: string; content?: string | Uint8Array }): FileRef {
|
|
79
|
+
return {
|
|
80
|
+
run: ref.run ?? 1,
|
|
81
|
+
step: ref.step,
|
|
82
|
+
attempt: ref.attempt ?? 1,
|
|
83
|
+
path: ref.path,
|
|
84
|
+
sha256: ref.sha256 ?? sha256Hex(ref.content ?? ""),
|
|
85
|
+
} as FileRef;
|
|
86
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Canonical JSON: object keys sorted recursively (by UTF-16 code units), arrays kept in order
|
|
2
|
+
// (order is meaningful: stages, steps, enum values), `undefined` members dropped. Stable for hashing.
|
|
3
|
+
export function canonicalize(v: unknown): unknown {
|
|
4
|
+
if (Array.isArray(v)) return v.map(canonicalize);
|
|
5
|
+
if (v !== null && typeof v === "object") {
|
|
6
|
+
const out: Record<string, unknown> = {};
|
|
7
|
+
for (const k of Object.keys(v).sort()) {
|
|
8
|
+
const x = (v as Record<string, unknown>)[k];
|
|
9
|
+
if (x !== undefined) out[k] = canonicalize(x);
|
|
10
|
+
}
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
if (typeof v === "number" && !Number.isFinite(v)) throw new Error(`non-finite number in canonical JSON: ${v}`);
|
|
14
|
+
if (typeof v === "bigint" || typeof v === "function" || typeof v === "symbol") throw new Error(`${typeof v} is not JSON`);
|
|
15
|
+
return v;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Canonical JSON text, 2-space indented, with a trailing newline. */
|
|
19
|
+
export const canonicalJson = (v: unknown): string => JSON.stringify(canonicalize(v), null, 2) + "\n";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { canonicalJson } from "./canonical.ts";
|
|
2
|
+
import { sha256Hex } from "./hash.ts";
|
|
3
|
+
|
|
4
|
+
/** Identity of the harness compiled into a binary. A Bun or SDK bump legitimately bumps every handler. */
|
|
5
|
+
export interface HarnessInfo {
|
|
6
|
+
/** Bun version (runtime compiled into the binary). */
|
|
7
|
+
bun: string;
|
|
8
|
+
/** @olve/pipelines package version. */
|
|
9
|
+
sdk: string;
|
|
10
|
+
/** `sha256:` hash over the SDK runtime sources and the zod version. */
|
|
11
|
+
sdkHash: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Custom step: hash of the handler bundle (contract files + SDK external) combined with the harness. */
|
|
15
|
+
export function composeHandlerHash(bundleHash: string, harness: HarnessInfo): string {
|
|
16
|
+
return "sha256:" + sha256Hex(canonicalJson({ bundle: bundleHash, harness }));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Library step: kind + canonical config + harness. */
|
|
20
|
+
export function libraryHandlerHash(kind: string, config: unknown, harness: HarnessInfo): string {
|
|
21
|
+
return "sha256:" + sha256Hex(canonicalJson({ library: kind, config, harness }));
|
|
22
|
+
}
|
package/src/util/hash.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { openSync, readSync, closeSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export function sha256Hex(data: string | Uint8Array | ArrayBuffer): string {
|
|
4
|
+
return new Bun.CryptoHasher("sha256").update(data).digest("hex");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Streaming SHA-256 of a file (constant memory). */
|
|
8
|
+
export function sha256File(path: string): string {
|
|
9
|
+
const h = new Bun.CryptoHasher("sha256");
|
|
10
|
+
const fd = openSync(path, "r");
|
|
11
|
+
try {
|
|
12
|
+
const buf = new Uint8Array(1 << 20);
|
|
13
|
+
for (;;) {
|
|
14
|
+
const n = readSync(fd, buf, 0, buf.length, null);
|
|
15
|
+
if (n <= 0) break;
|
|
16
|
+
h.update(buf.subarray(0, n));
|
|
17
|
+
}
|
|
18
|
+
} finally {
|
|
19
|
+
closeSync(fd);
|
|
20
|
+
}
|
|
21
|
+
return h.digest("hex");
|
|
22
|
+
}
|
package/src/util/tar.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Deterministic tar writer (ustar + PAX long names), streaming from disk. Entries are sorted,
|
|
2
|
+
// with mtime 0, uid/gid 0, no user names — so equal content gives byte-identical archives (and
|
|
3
|
+
// sha256s). Extraction uses Bun.Archive.
|
|
4
|
+
import { closeSync, createReadStream, createWriteStream, lstatSync, openSync, readdirSync, readlinkSync, readSync, renameSync, rmSync, writeSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import { createGzip } from "node:zlib";
|
|
8
|
+
|
|
9
|
+
export type TarEntry =
|
|
10
|
+
| { path: string; type: "file"; mode: number; source: string }
|
|
11
|
+
| { path: string; type: "bytes"; mode: number; data: Uint8Array }
|
|
12
|
+
| { path: string; type: "dir"; mode: number }
|
|
13
|
+
| { path: string; type: "symlink"; target: string };
|
|
14
|
+
|
|
15
|
+
const enc = new TextEncoder();
|
|
16
|
+
|
|
17
|
+
function octal(n: number, len: number): string {
|
|
18
|
+
return n.toString(8).padStart(len - 1, "0") + "\0";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function header(name: string, size: number, mode: number, typeflag: string, linkname = ""): Uint8Array {
|
|
22
|
+
const h = new Uint8Array(512);
|
|
23
|
+
const put = (s: string | Uint8Array, off: number, len: number) => {
|
|
24
|
+
const b = typeof s === "string" ? enc.encode(s) : s;
|
|
25
|
+
h.set(b.subarray(0, len), off);
|
|
26
|
+
};
|
|
27
|
+
put(name, 0, 100);
|
|
28
|
+
put(octal(mode & 0o7777, 8), 100, 8);
|
|
29
|
+
put(octal(0, 8), 108, 8);
|
|
30
|
+
put(octal(0, 8), 116, 8);
|
|
31
|
+
put(octal(size, 12), 124, 12);
|
|
32
|
+
put(octal(0, 12), 136, 12);
|
|
33
|
+
put(" ", 148, 8); // checksum placeholder
|
|
34
|
+
put(typeflag, 156, 1);
|
|
35
|
+
put(linkname, 157, 100);
|
|
36
|
+
put("ustar\0", 257, 6);
|
|
37
|
+
put("00", 263, 2);
|
|
38
|
+
let sum = 0;
|
|
39
|
+
for (const b of h) sum += b;
|
|
40
|
+
put(octal(sum, 7) + " ", 148, 8);
|
|
41
|
+
return h;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function paxRecord(key: string, value: string): string {
|
|
45
|
+
const body = ` ${key}=${value}\n`;
|
|
46
|
+
let len = enc.encode(body).length;
|
|
47
|
+
let total = len + String(len).length;
|
|
48
|
+
if (String(total).length !== String(len).length) total = len + String(total).length;
|
|
49
|
+
return `${total}${body}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Writes `entries` (sorted by path here) as an uncompressed tar to `out`. */
|
|
53
|
+
export function writeTar(out: string, entries: readonly TarEntry[]): void {
|
|
54
|
+
const sorted = [...entries].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
55
|
+
const tmp = `${out}.partial`;
|
|
56
|
+
const fd = openSync(tmp, "w");
|
|
57
|
+
const write = (b: Uint8Array) => {
|
|
58
|
+
let off = 0;
|
|
59
|
+
while (off < b.length) off += writeSync(fd, b, off, b.length - off);
|
|
60
|
+
};
|
|
61
|
+
const pad = (size: number) => {
|
|
62
|
+
const r = size % 512;
|
|
63
|
+
if (r) write(new Uint8Array(512 - r));
|
|
64
|
+
};
|
|
65
|
+
try {
|
|
66
|
+
for (const e of sorted) {
|
|
67
|
+
const name = e.type === "dir" && !e.path.endsWith("/") ? e.path + "/" : e.path;
|
|
68
|
+
const link = e.type === "symlink" ? e.target : "";
|
|
69
|
+
let size = 0;
|
|
70
|
+
if (e.type === "file") size = lstatSync(e.source).size;
|
|
71
|
+
else if (e.type === "bytes") size = e.data.length;
|
|
72
|
+
const pax: string[] = [];
|
|
73
|
+
if (enc.encode(name).length > 100) pax.push(paxRecord("path", name));
|
|
74
|
+
if (enc.encode(link).length > 100) pax.push(paxRecord("linkpath", link));
|
|
75
|
+
if (pax.length) {
|
|
76
|
+
const body = enc.encode(pax.join(""));
|
|
77
|
+
write(header("././@PaxHeader", body.length, 0o644, "x"));
|
|
78
|
+
write(body);
|
|
79
|
+
pad(body.length);
|
|
80
|
+
}
|
|
81
|
+
const shortName = enc.encode(name).length > 100 ? name.slice(0, 99) : name;
|
|
82
|
+
const mode = e.type === "symlink" ? 0o777 : e.mode;
|
|
83
|
+
const flag = e.type === "dir" ? "5" : e.type === "symlink" ? "2" : "0";
|
|
84
|
+
write(header(shortName, size, mode, flag, link.length > 100 ? link.slice(0, 99) : link));
|
|
85
|
+
if (e.type === "bytes") {
|
|
86
|
+
write(e.data);
|
|
87
|
+
pad(size);
|
|
88
|
+
} else if (e.type === "file") {
|
|
89
|
+
const src = openSync(e.source, "r");
|
|
90
|
+
try {
|
|
91
|
+
const buf = new Uint8Array(1 << 20);
|
|
92
|
+
let left = size;
|
|
93
|
+
while (left > 0) {
|
|
94
|
+
const n = readSync(src, buf, 0, Math.min(buf.length, left), null);
|
|
95
|
+
if (n <= 0) throw new Error(`${e.source} shrank while archiving`);
|
|
96
|
+
write(buf.subarray(0, n));
|
|
97
|
+
left -= n;
|
|
98
|
+
}
|
|
99
|
+
} finally {
|
|
100
|
+
closeSync(src);
|
|
101
|
+
}
|
|
102
|
+
pad(size);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
write(new Uint8Array(1024));
|
|
106
|
+
} finally {
|
|
107
|
+
closeSync(fd);
|
|
108
|
+
}
|
|
109
|
+
renameSync(tmp, out);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Entries for the contents of `dir` (recursively), with paths prefixed by `prefix` ("" = none). */
|
|
113
|
+
export function dirEntries(dir: string, prefix = ""): TarEntry[] {
|
|
114
|
+
const out: TarEntry[] = [];
|
|
115
|
+
const walk = (abs: string, rel: string) => {
|
|
116
|
+
for (const name of readdirSync(abs).sort()) {
|
|
117
|
+
const a = join(abs, name);
|
|
118
|
+
const r = rel ? `${rel}/${name}` : name;
|
|
119
|
+
const st = lstatSync(a);
|
|
120
|
+
if (st.isSymbolicLink()) out.push({ path: r, type: "symlink", target: readlinkSync(a) });
|
|
121
|
+
else if (st.isDirectory()) {
|
|
122
|
+
out.push({ path: r, type: "dir", mode: 0o755 });
|
|
123
|
+
walk(a, r);
|
|
124
|
+
} else if (st.isFile()) out.push({ path: r, type: "file", mode: st.mode & 0o111 ? 0o755 : 0o644, source: a });
|
|
125
|
+
// sockets, fifos, devices are skipped
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
walk(dir, prefix);
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** gzip `src` into `out` (streaming; zlib writes mtime 0, so output is deterministic). */
|
|
133
|
+
export async function gzipFile(src: string, out: string, level = 6): Promise<void> {
|
|
134
|
+
const tmp = `${out}.partial`;
|
|
135
|
+
await pipeline(createReadStream(src), createGzip({ level }), createWriteStream(tmp));
|
|
136
|
+
renameSync(tmp, out);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Writes a deterministic `.tar.gz`. */
|
|
140
|
+
export async function writeTarGz(out: string, entries: readonly TarEntry[], level = 6): Promise<void> {
|
|
141
|
+
const tar = `${out}.tar-tmp`;
|
|
142
|
+
try {
|
|
143
|
+
writeTar(tar, entries);
|
|
144
|
+
await gzipFile(tar, out, level);
|
|
145
|
+
} finally {
|
|
146
|
+
rmSync(tar, { force: true });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Extracts a tar or tar.gz file into `dir`. */
|
|
151
|
+
export async function extractTar(archive: string, dir: string): Promise<void> {
|
|
152
|
+
const a = new Bun.Archive(await Bun.file(archive).bytes());
|
|
153
|
+
await a.extract(dir);
|
|
154
|
+
}
|