@olivervea/pipelines 2.0.0-beta.1 → 2.0.0-beta.10

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.
@@ -1,13 +1,18 @@
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.
1
+ // steps.imageBuild — build a container image from a `Source` with kaniko: to an image tarball that
2
+ // travels as a file ref (a deploy imports it on the host), or pushed to a registry (`push: true`).
4
3
  // 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";
4
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
6
5
  import { join } from "node:path";
7
6
  import type { Ctx, RunInfo } from "../ctx/types.ts";
8
7
  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";
8
+ import type { Secret } from "../core/secrets.ts";
9
+ import { isConfigRef, type ConfigRef, type Configurable } from "../core/config.ts";
10
+ import { isLiteral, resolveConfigRefs } from "../core/config-refs.ts";
11
+ import { implement, type Implementation } from "../core/handlers.ts";
12
+ import { step, type LiteralName, type Step } from "../core/steps.ts";
13
+ import { sha256File } from "../util/hash.ts";
14
+ import { dirEntries, writeTar } from "../util/tar.ts";
15
+ import { BuiltImage, PushedImage, Source } from "./models.ts";
11
16
 
12
17
  /** kaniko's debug image (it has a busybox shell for debugging; the executor itself needs none). */
13
18
  export const KANIKO_IMAGE = "gcr.io/kaniko-project/executor:debug";
@@ -22,20 +27,35 @@ export const KANIKO_CONTEXT = "/kaniko/build-context";
22
27
  const input = { source: Source };
23
28
  export type ImageBuildInput = typeof input;
24
29
 
25
- export interface ImageBuildDef<N extends string> {
30
+ /** Credentials for the registry of `repository`: a user name and a secret holding the password or token. */
31
+ export interface RegistryAuth<K extends string> {
32
+ username: string;
33
+ password: Secret<K>;
34
+ }
35
+
36
+ /** Registry credentials as config: the password is a `secretRef` (`{ secret: NAME }`) the step must declare in `secrets`. */
37
+ export interface RegistryAuthConfig {
38
+ username: string;
39
+ password: { secret: string };
40
+ }
41
+
42
+ export interface ImageBuildDef<N extends string, K extends string = never> {
26
43
  name: LiteralName<N>;
27
44
  /** What the step does, shown in the UI. Required; may be empty. */
28
45
  description: string;
29
- /** Image name, e.g. `olve-pipelines-v2`. The tag is the run's version (see `imageTag`). */
30
- repository: string;
31
46
  /**
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.
47
+ * Image name, e.g. `olve-pipelines-v2`; with `push`, the registry repository to push to
48
+ * (`registry.ovea.pro/app/api`). The tag is the run's version (see `imageTag`) unless `contentTag`.
35
49
  */
36
- cacheRepo?: string;
37
- /** kaniko `--registry-map` (registry → mirror prefix); kaniko falls back to the original registry if the mirror lacks the image. */
38
- registryMap?: Record<string, string>;
50
+ repository: Configurable<string>;
51
+ /** Build context: a directory of the source tree with a `Dockerfile` (default: the root). */
52
+ context?: string;
53
+ /**
54
+ * kaniko `--registry-map` (registry → mirror prefix); kaniko falls back to the original registry if
55
+ * the mirror lacks the image. Defaults to the homelab pull-through caches (`DEFAULT_REGISTRY_MAP`);
56
+ * a map replaces the default entirely, `{}` turns mapping off.
57
+ */
58
+ registryMap?: Configurable<Record<string, string> | undefined>;
39
59
  timeoutSeconds?: number;
40
60
  /**
41
61
  * Kill kaniko when it writes no output for this long (default 300 s; 0 disables), failing the
@@ -50,6 +70,81 @@ export interface ImageBuildDef<N extends string> {
50
70
  * A `--no-push` build is idempotent, so the default is 2 (three tries).
51
71
  */
52
72
  retries?: number;
73
+ /**
74
+ * Log in to the registry of `repository` (e.g. `ghcr.io` with a token that may write packages).
75
+ * Used for kaniko's push, base image and cache pulls from that registry, and the `contentTag` lookup.
76
+ */
77
+ registryAuth?: RegistryAuth<K> | ConfigRef<RegistryAuthConfig | undefined>;
78
+ /** The registry of `repository` speaks plain HTTP (a local `registry:2`). */
79
+ insecureRegistry?: Configurable<boolean | undefined>;
80
+ /** Secrets a config-referenced `registryAuth` may name (the step declares them). */
81
+ secrets?: readonly Secret<K>[];
82
+ /** `false` skips the step (`ctx.skip`), e.g. `enabled: PUBLISH.harness.push`. Default true. */
83
+ enabled?: Configurable<boolean | undefined>;
84
+ }
85
+
86
+ /** Tarball mode (default): output `BuiltImage` with the image tar as a file. */
87
+ export interface ImageBuildTarballDef<N extends string, K extends string = never> extends ImageBuildDef<N, K> {
88
+ push?: false;
89
+ contentTag?: never;
90
+ }
91
+
92
+ /**
93
+ * Registry mode: push to `repository` and output `PushedImage`, for images too big to travel as a
94
+ * tarball (the step's scratch space is ~1 GiB).
95
+ */
96
+ export interface ImageBuildPushDef<N extends string, K extends string = never> extends ImageBuildDef<N, K> {
97
+ push: true;
98
+ /**
99
+ * Tag the image `src-<hash>`, the hash of the build context's content, and skip the build when the
100
+ * registry already has that tag: the image is rebuilt only when its context changes.
101
+ */
102
+ contentTag?: boolean;
103
+ }
104
+
105
+ /**
106
+ * Default `registryMap`: pull-through caches for mcr.microsoft.com and Docker Hub. kaniko under gVisor
107
+ * can stall forever pulling from mcr.microsoft.com directly.
108
+ */
109
+ export const DEFAULT_REGISTRY_MAP: Readonly<Record<string, string>> = {
110
+ "mcr.microsoft.com": "mcr-cache.ovea.pro",
111
+ "index.docker.io": "hub-cache.ovea.pro",
112
+ };
113
+
114
+ /** How long a cache registry gets to answer `GET /v2/` before the build goes without it. */
115
+ export const CACHE_PROBE_TIMEOUT_MS = 5000;
116
+
117
+ /** The registry host of an image reference or mirror prefix (`registry.ovea.pro/a/b` → `registry.ovea.pro`). */
118
+ export function registryHost(ref: string): string {
119
+ return ref.split("/")[0]!;
120
+ }
121
+
122
+ /** Whether a registry answers its API root: 200, or 401 (it wants a token). Never throws. */
123
+ export async function registryUp(http: Ctx["http"], host: string, signal?: AbortSignal, timeoutMs = CACHE_PROBE_TIMEOUT_MS, insecure = false): Promise<boolean> {
124
+ try {
125
+ const timeout = AbortSignal.timeout(timeoutMs);
126
+ const res = await http.fetch(`${insecure ? "http" : "https"}://${host}/v2/`, { signal: signal ? AbortSignal.any([signal, timeout]) : timeout });
127
+ await res.body?.cancel().catch(() => {});
128
+ return res.status === 200 || res.status === 401;
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ /** The registry map without the mirrors that don't answer, so a mirror outage (GC, maintenance) never blocks a build. */
135
+ export async function reachableMirrors(
136
+ ctx: Pick<Ctx, "http" | "log" | "signal">,
137
+ registryMap: Record<string, string>,
138
+ insecureHosts: readonly string[] = [],
139
+ ): Promise<Record<string, string>> {
140
+ const hosts = new Set(Object.values(registryMap).map(registryHost));
141
+ const up = new Map(await Promise.all([...hosts].map(async (h) => [h, await registryUp(ctx.http, h, ctx.signal, undefined, insecureHosts.includes(h))] as const)));
142
+ const reachable: Record<string, string> = {};
143
+ for (const [from, to] of Object.entries(registryMap)) {
144
+ if (up.get(registryHost(to))) reachable[from] = to;
145
+ else ctx.log(`${registryHost(to)} unreachable; pulling from ${from} directly`);
146
+ }
147
+ return reachable;
53
148
  }
54
149
 
55
150
  /** Default `retries`: a stalled or flaky build gets two fresh attempts before the run fails. */
@@ -77,58 +172,247 @@ export function qualifyRepository(repository: string): string {
77
172
  return `docker.io/${repository}`;
78
173
  }
79
174
 
175
+ /** A relative path inside the source tree (no `..`, not absolute). */
176
+ function checkRelative(what: string, path: string): string {
177
+ const clean = path.replace(/^\.\/+/, "").replace(/\/+$/, "");
178
+ if (clean.startsWith("/") || clean.split("/").includes("..")) throw new Error(`imageBuild ${what} '${path}' must be a relative path inside the source tree`);
179
+ return clean === "." ? "" : clean;
180
+ }
181
+
182
+ /** The `src-<12 hex>` tag of a directory: the sha256 of its deterministic tar (as `ctx.output.dir` writes it). */
183
+ export function contentTagOf(dir: string, tmpDir: string): string {
184
+ mkdirSync(tmpDir, { recursive: true });
185
+ const work = mkdtempSync(join(tmpDir, "content-tag-"));
186
+ try {
187
+ const tar = join(work, "context.tar");
188
+ writeTar(tar, dirEntries(dir));
189
+ return `src-${sha256File(tar).slice(0, 12)}`;
190
+ } finally {
191
+ rmSync(work, { recursive: true, force: true });
192
+ }
193
+ }
194
+
195
+ const MANIFEST_TYPES = [
196
+ "application/vnd.oci.image.index.v1+json",
197
+ "application/vnd.oci.image.manifest.v1+json",
198
+ "application/vnd.docker.distribution.manifest.list.v2+json",
199
+ "application/vnd.docker.distribution.manifest.v2+json",
200
+ ].join(", ");
201
+
202
+ /** Options of a registry API call: login (`username`/`password`) and plain HTTP. */
203
+ export interface RegistryAccess {
204
+ username?: string;
205
+ password?: string;
206
+ insecure?: boolean;
207
+ }
208
+
209
+ const basic = (username: string, password: string) => `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
210
+
211
+ /** `Bearer realm="…",service="…",scope="…"` → its parameters. */
212
+ export function parseChallenge(header: string): { scheme: string; params: Record<string, string> } {
213
+ const scheme = header.trim().split(/\s+/, 1)[0]!.toLowerCase();
214
+ const params: Record<string, string> = {};
215
+ for (const m of header.slice(scheme.length).matchAll(/([A-Za-z]+)="([^"]*)"/g)) params[m[1]!.toLowerCase()] = m[2]!;
216
+ return { scheme, params };
217
+ }
218
+
80
219
  /**
81
- * Builds `<source>/Dockerfile` with the source root as context, using kaniko, into an image tarball.
82
- * Output: `BuiltImage { repository, tag, digest, tar }`.
220
+ * `HEAD`/`GET` against a registry API path, answering its auth challenge: Basic (a `registry:2` with
221
+ * htpasswd) or a Bearer token from the realm (ghcr.io, Docker Hub; anonymous for public images).
83
222
  */
84
- export function imageBuild<const N extends string>(def: ImageBuildDef<N>): Step<N, ImageBuildInput, typeof BuiltImage, never> & LibraryStep {
85
- return makeStep(
86
- "imageBuild",
87
- { name: def.name, description: def.description, image: KANIKO_IMAGE, input, output: BuiltImage, secrets: [], timeoutSeconds: def.timeoutSeconds, retries: def.retries ?? DEFAULT_RETRIES },
88
- {
89
- manifestConfig: () => ({
90
- repository: def.repository,
91
- ...(def.cacheRepo ? { cacheRepo: def.cacheRepo } : {}),
92
- ...(def.registryMap ? { registryMap: def.registryMap } : {}),
93
- }),
94
- async run({ source }: { source: { tree: FileRef } }, ctx: Ctx) {
95
- const tag = imageTag(ctx.run);
96
- const tree = await ctx.files.fetch(source.tree);
97
- await ctx.exec(["mkdir", "-p", KANIKO_CONTEXT]);
98
- await ctx.exec(["cp", "-r", `${tree}/.`, KANIKO_CONTEXT]);
99
-
100
- // Outputs go to the harness's scratch dir (/olve/tmp, an emptyDir mount kaniko leaves alone;
101
- // kaniko images have no /tmp), then into the step's files.
102
- mkdirSync(ctx.tmpDir, { recursive: true });
103
- const out = mkdtempSync(join(ctx.tmpDir, "image-build-"));
104
- const tarPath = join(out, "image.tar");
105
- const digestPath = join(out, "digest");
106
- // No --single-snapshot: it is incompatible with multi-stage Dockerfiles whose later stages need
107
- // the build context intact.
108
- await ctx.exec([
109
- "/kaniko/executor",
110
- `--context=${KANIKO_CONTEXT}`,
111
- `--dockerfile=${KANIKO_CONTEXT}/Dockerfile`,
112
- "--no-push",
113
- `--tar-path=${tarPath}`,
114
- `--digest-file=${digestPath}`,
115
- `--destination=${def.repository}:${tag}`,
116
- ...(def.cacheRepo ? ["--cache=true", `--cache-repo=${def.cacheRepo}`] : []),
117
- ...Object.entries(def.registryMap ?? {}).map(([from, to]) => `--registry-map=${from}=${to}`),
118
- ], { idleTimeoutSeconds: def.stallTimeoutSeconds ?? DEFAULT_STALL_TIMEOUT_SECONDS });
119
-
120
- let digest: string;
121
- try {
122
- digest = readFileSync(digestPath, "utf8").trim();
123
- } catch {
124
- throw new Error(`kaniko did not write the image digest to ${digestPath}`);
125
- }
126
- if (!/^sha256:[0-9a-f]{64}$/.test(digest)) throw new Error(`kaniko wrote an unexpected digest: '${digest}'`);
127
- const tar = await ctx.output.file(tarPath, { name: "image.tar" });
128
- rmSync(tarPath); // the copy in the step's files is the one that counts; don't hold the image twice in /olve
129
- ctx.log("image built", { image: `${def.repository}:${tag}`, digest });
130
- return { repository: qualifyRepository(def.repository), tag, digest, tar };
131
- },
223
+ async function registryFetch(http: Ctx["http"], host: string, path: string, init: RequestInit, access: RegistryAccess): Promise<Response> {
224
+ const url = `${access.insecure ? "http" : "https"}://${host}${path}`;
225
+ const res = await http.fetch(url, init);
226
+ if (res.status !== 401) return res;
227
+ await res.body?.cancel().catch(() => {});
228
+ const { scheme, params } = parseChallenge(res.headers.get("www-authenticate") ?? "");
229
+ const login = access.username !== undefined && access.password !== undefined ? basic(access.username, access.password) : undefined;
230
+ let authorization: string;
231
+ if (scheme === "basic") {
232
+ if (!login) return new Response(null, { status: 401 });
233
+ authorization = login;
234
+ } else if (scheme === "bearer" && params.realm) {
235
+ const q = new URLSearchParams();
236
+ if (params.service) q.set("service", params.service);
237
+ if (params.scope) q.set("scope", params.scope);
238
+ const tokenRes = await http.fetch(`${params.realm}?${q}`, { headers: login ? { Authorization: login } : {} });
239
+ if (!tokenRes.ok) {
240
+ await tokenRes.body?.cancel().catch(() => {});
241
+ return new Response(null, { status: tokenRes.status === 404 ? 401 : tokenRes.status });
242
+ }
243
+ const body = (await tokenRes.json()) as { token?: string; access_token?: string };
244
+ const token = body.token ?? body.access_token;
245
+ if (!token) throw new Error(`registry token endpoint ${params.realm} returned no token`);
246
+ authorization = `Bearer ${token}`;
247
+ } else {
248
+ return new Response(null, { status: 401 });
249
+ }
250
+ const headers = new Headers(init.headers);
251
+ headers.set("Authorization", authorization);
252
+ return http.fetch(url, { ...init, headers });
253
+ }
254
+
255
+ /** The digest of `repository:tag` in its registry (`HEAD …/manifests/<tag>`), or undefined if it has none. */
256
+ export async function registryDigest(http: Ctx["http"], repository: string, tag: string, access: RegistryAccess = {}): Promise<string | undefined> {
257
+ const [host, ...path] = repository.split("/");
258
+ const res = await registryFetch(http, host!, `/v2/${path.join("/")}/manifests/${tag}`, { method: "HEAD", headers: { Accept: MANIFEST_TYPES } }, access);
259
+ await res.body?.cancel().catch(() => {});
260
+ if (res.status === 404) return undefined;
261
+ // An anonymous lookup of a repository that doesn't exist yet (or is private) is denied, not 404.
262
+ if ((res.status === 401 || res.status === 403) && access.password === undefined) return undefined;
263
+ if (!res.ok) throw new Error(`registry lookup of ${repository}:${tag} answered HTTP ${res.status}`);
264
+ const digest = res.headers.get("docker-content-digest") ?? "";
265
+ return /^sha256:[0-9a-f]{64}$/.test(digest) ? digest : undefined;
266
+ }
267
+
268
+ /** kaniko reads registry logins from `$DOCKER_CONFIG/config.json` (`/kaniko/.docker` in its image). */
269
+ export const KANIKO_DOCKER_CONFIG = "/kaniko/.docker";
270
+
271
+ /** A docker `config.json` that logs in to `host`. */
272
+ export function dockerConfigJson(host: string, username: string, password: string): string {
273
+ return JSON.stringify({ auths: { [host]: { auth: Buffer.from(`${username}:${password}`).toString("base64") } } });
274
+ }
275
+
276
+ /** Adds the login for `host` to `<dir>/config.json` (owner-only), keeping what is there (kaniko's credHelpers). */
277
+ export function writeDockerConfig(dir: string, host: string, username: string, password: string): void {
278
+ mkdirSync(dir, { recursive: true });
279
+ const file = join(dir, "config.json");
280
+ let config: { auths?: Record<string, unknown> } = {};
281
+ try {
282
+ config = JSON.parse(readFileSync(file, "utf8"));
283
+ } catch {
284
+ // absent or unreadable: start fresh
285
+ }
286
+ const login = JSON.parse(dockerConfigJson(host, username, password)) as { auths: Record<string, unknown> };
287
+ writeFileSync(file, JSON.stringify({ ...config, auths: { ...config.auths, ...login.auths } }), { mode: 0o600 });
288
+ }
289
+
290
+ /**
291
+ * Builds an image with kaniko from `<context>/Dockerfile` in the source tree (default: the root). Default: into an image tarball, output `BuiltImage { repository, tag, digest, tar }`.
292
+ * With `push: true`: pushed to `repository`, output `PushedImage { repository, tag, digest }`.
293
+ */
294
+ export function imageBuild<const N extends string, const K extends string = never>(def: ImageBuildTarballDef<N, K>): Implementation<Step<N, ImageBuildInput, typeof BuiltImage, K>>;
295
+ export function imageBuild<const N extends string, const K extends string = never>(def: ImageBuildPushDef<N, K>): Implementation<Step<N, ImageBuildInput, typeof PushedImage, K>>;
296
+ export function imageBuild<const N extends string, const K extends string = never>(def: ImageBuildTarballDef<N, K> | ImageBuildPushDef<N, K>): AnyImageBuild {
297
+ const push = def.push === true;
298
+ const contentTag = push && def.contentTag === true;
299
+ const context = checkRelative("context", def.context ?? "");
300
+ const authSecret = def.registryAuth && !isConfigRef(def.registryAuth) ? def.registryAuth : undefined;
301
+ if (isLiteral([def.repository, def.registryAuth, def.insecureRegistry])) checkRegistryHost(def.name, def.repository as string, push, def.registryAuth !== undefined, def.insecureRegistry === true);
302
+ const secrets = [...(authSecret ? [authSecret.password] : []), ...(def.secrets ?? [])].filter((s, i, all) => all.findIndex((x) => x.name === s.name) === i);
303
+ const registryMap = def.registryMap ?? DEFAULT_REGISTRY_MAP;
304
+ const mapped = isConfigRef(registryMap) || Object.keys(registryMap).length > 0;
305
+ const contract = step({
306
+ name: def.name,
307
+ description: def.description,
308
+ kind: "imageBuild",
309
+ image: KANIKO_IMAGE,
310
+ input,
311
+ output: push ? PushedImage : BuiltImage,
312
+ secrets,
313
+ timeoutSeconds: def.timeoutSeconds,
314
+ retries: def.retries ?? DEFAULT_RETRIES,
315
+ settings: {
316
+ repository: def.repository,
317
+ ...(context ? { context } : {}),
318
+ ...(push ? { push: true } : {}),
319
+ ...(contentTag ? { contentTag: true } : {}),
320
+ ...(mapped ? { registryMap } : {}),
321
+ ...(authSecret ? { registryAuth: { username: authSecret.username, password: authSecret.password.name } } : {}),
322
+ ...(def.registryAuth && isConfigRef(def.registryAuth) ? { registryAuth: def.registryAuth } : {}),
323
+ ...(def.insecureRegistry !== undefined && def.insecureRegistry !== false ? { insecureRegistry: def.insecureRegistry } : {}),
324
+ ...(def.enabled !== undefined ? { enabled: def.enabled } : {}),
132
325
  },
133
- ) as never;
326
+ });
327
+ return implement(contract, async ({ source }, ctx: Ctx) => {
328
+ const d = resolveConfigRefs({
329
+ repository: def.repository, registryMap: def.registryMap, insecureRegistry: def.insecureRegistry,
330
+ registryAuth: def.registryAuth, enabled: def.enabled,
331
+ }, ctx.config ?? {});
332
+ const repository = d.repository as string;
333
+ if (d.enabled === false) return ctx.skip(`publishing ${repository} is turned off`);
334
+ const insecure = d.insecureRegistry === true;
335
+ const auth = registryLogin(d.registryAuth as RegistryAuth<string> | RegistryAuthConfig | undefined, ctx, secrets.map((s) => s.name));
336
+ checkRegistryHost(def.name, repository, push, auth !== undefined, insecure);
337
+ const host = registryHost(repository);
338
+
339
+ const tree = await ctx.files.fetch(source.tree);
340
+ const contextDir = context ? join(tree, context) : tree;
341
+ if (!existsSync(contextDir) || !statSync(contextDir).isDirectory()) throw new Error(`build context '${context}' not found in the source tree`);
342
+ if (!existsSync(join(contextDir, "Dockerfile"))) throw new Error(`Dockerfile not found in the build context${context ? ` '${context}'` : ""}`);
343
+
344
+ const access: RegistryAccess = { insecure, ...(auth ?? {}) };
345
+ if (auth) writeDockerConfig(process.env.DOCKER_CONFIG || KANIKO_DOCKER_CONFIG, host, auth.username, auth.password);
346
+
347
+ const tag = contentTag ? contentTagOf(contextDir, ctx.tmpDir) : imageTag(ctx.run);
348
+ if (contentTag) {
349
+ const digest = await registryDigest(ctx.http, repository, tag, access);
350
+ if (digest) {
351
+ ctx.log("image already in the registry", { image: `${repository}:${tag}`, digest });
352
+ return { repository, tag, digest };
353
+ }
354
+ }
355
+
356
+ const mirrors = await reachableMirrors(ctx, (d.registryMap as Record<string, string> | undefined) ?? DEFAULT_REGISTRY_MAP, insecure ? [host] : []);
357
+ await ctx.exec(["mkdir", "-p", KANIKO_CONTEXT]);
358
+ await ctx.exec(["cp", "-r", `${tree}/.`, KANIKO_CONTEXT]);
359
+ const kanikoContext = context ? `${KANIKO_CONTEXT}/${context}` : KANIKO_CONTEXT;
360
+
361
+ // Outputs go to the harness's scratch dir (/olve/tmp, an emptyDir mount kaniko leaves alone;
362
+ // kaniko images have no /tmp), then into the step's files.
363
+ mkdirSync(ctx.tmpDir, { recursive: true });
364
+ const out = mkdtempSync(join(ctx.tmpDir, "image-build-"));
365
+ const tarPath = join(out, "image.tar");
366
+ const digestPath = join(out, "digest");
367
+ const output = push ? [] : ["--no-push", `--tar-path=${tarPath}`];
368
+ // No --single-snapshot: it is incompatible with multi-stage Dockerfiles whose later stages need
369
+ // the build context intact.
370
+ await ctx.exec([
371
+ "/kaniko/executor",
372
+ `--context=${kanikoContext}`,
373
+ `--dockerfile=${kanikoContext}/Dockerfile`,
374
+ ...output,
375
+ `--digest-file=${digestPath}`,
376
+ `--destination=${repository}:${tag}`,
377
+ ...(insecure ? ["--insecure", `--insecure-registry=${host}`] : []),
378
+ "--snapshot-mode=redo",
379
+ "--use-new-run",
380
+ "--compressed-caching=false",
381
+ ...Object.entries(mirrors).map(([from, to]) => `--registry-map=${from}=${to}`),
382
+ ], { idleTimeoutSeconds: def.stallTimeoutSeconds ?? DEFAULT_STALL_TIMEOUT_SECONDS });
383
+
384
+ let digest: string;
385
+ try {
386
+ digest = readFileSync(digestPath, "utf8").trim();
387
+ } catch {
388
+ throw new Error(`kaniko did not write the image digest to ${digestPath}`);
389
+ }
390
+ if (!/^sha256:[0-9a-f]{64}$/.test(digest)) throw new Error(`kaniko wrote an unexpected digest: '${digest}'`);
391
+ if (push) {
392
+ ctx.log("image pushed", { image: `${repository}:${tag}`, digest });
393
+ return { repository, tag, digest };
394
+ }
395
+ const tar = await ctx.output.file(tarPath, { name: "image.tar" });
396
+ rmSync(tarPath); // the copy in the step's files is the one that counts; don't hold the image twice in /olve
397
+ ctx.log("image built", { image: `${repository}:${tag}`, digest });
398
+ return { repository: qualifyRepository(repository), tag, digest, tar };
399
+ }) as never;
400
+ }
401
+
402
+ function checkRegistryHost(name: string, repository: string, push: boolean, auth: boolean, insecure: boolean): void {
403
+ const host = registryHost(repository);
404
+ if (push && !/[.:]|^localhost$/.test(host)) throw new Error(`imageBuild '${name}': push needs a repository with a registry host, got '${repository}'`);
405
+ if ((auth || insecure) && !/[.:]|^localhost$/.test(host)) throw new Error(`imageBuild '${name}': registryAuth and insecureRegistry need a repository with a registry host, got '${repository}'`);
134
406
  }
407
+
408
+ /** The login for `registryAuth` (a literal secret or a config `secretRef`), or undefined without one. */
409
+ function registryLogin(auth: RegistryAuth<string> | RegistryAuthConfig | undefined, ctx: Pick<Ctx, "secrets">, declared: readonly string[]): { username: string; password: string } | undefined {
410
+ if (!auth) return undefined;
411
+ const name = "name" in auth.password ? auth.password.name : auth.password.secret;
412
+ if (!declared.includes(name)) throw new Error(`registryAuth names secret ${name}, which the step does not declare (add it to secrets)`);
413
+ const password = (ctx.secrets as Record<string, string | undefined>)[name];
414
+ if (!password) throw new Error(`secret ${name} (registryAuth) is not set`);
415
+ return { username: auth.username, password };
416
+ }
417
+
418
+ type AnyImageBuild = Implementation<Step<string, ImageBuildInput, typeof BuiltImage | typeof PushedImage, any>>;
@@ -1,14 +1,18 @@
1
1
  import { gitHubSource } from "./github-source.ts";
2
+ import { composeDeploy } from "./compose-deploy.ts";
3
+ import { helmDeployInCluster } from "./helm-deploy.ts";
2
4
  import { helmDeployOverSsh } from "./helm-deploy-ssh.ts";
3
5
  import { imageBuild } from "./image-build.ts";
4
- import { script } from "./script.ts";
5
6
 
6
- export { Source, BuiltImage, HelmRelease, FailureContext } from "./models.ts";
7
- export { inputEnvName, type RunValue, type ScriptDef } from "./script.ts";
7
+ export { Source, BuiltImage, PushedImage, HelmRelease, FailureContext } from "./models.ts";
8
8
  export type { GitHubSourceDef } from "./github-source.ts";
9
- export type { ImageBuildDef } from "./image-build.ts";
9
+ export type { ImageBuildDef, ImageBuildTarballDef, ImageBuildPushDef, RegistryAuth, RegistryAuthConfig } from "./image-build.ts";
10
10
  export type { HelmDeployOverSshDef } from "./helm-deploy-ssh.ts";
11
- export { httpHealthCheck, HTTP_HEALTH_DEFAULTS, type HttpHealthCheckOptions } from "./http-health.ts";
11
+ export { type ComposeDeployDef, type ComposeTargetValue, type ComposeDeployResult } from "./compose-deploy.ts";
12
+ export { helmTarget, deployTarget, type DeployTargetOptions, type HelmDeployInClusterDef, type HelmDeploySshTargetDef, type HelmTargetDeployDef, type HelmTargetValue, type InClusterTargetValue, type SshTargetValue } from "./helm-deploy.ts";
13
+ export { sshSession, type SshSession } from "./ssh.ts";
14
+ export { serviceUrl, checkSpa } from "./checks.ts";
15
+ export { httpHealthCheck, type HttpHealthCheckOptions } from "./http-health.ts";
12
16
 
13
- /** SDK-provided steps (step + handler pairs with typed config). */
14
- export const steps = { script, gitHubSource, imageBuild, helmDeployOverSsh };
17
+ /** SDK-provided steps: each is `implement(step(…), …)` with typed settings, staged directly. */
18
+ export const steps = { gitHubSource, imageBuild, helmDeployOverSsh, helmDeployInCluster, composeDeploy };
@@ -14,10 +14,26 @@ export const BuiltImage = model(
14
14
  z.object({ repository: z.string(), tag: z.string(), digest: z.string(), tar: fileRef() }),
15
15
  );
16
16
 
17
+ /**
18
+ * A container image pushed to a registry by a step (`imageBuild` with `push: true`), pulled from there
19
+ * by the deploy. `repository` includes the registry host.
20
+ */
21
+ export const PushedImage = model(
22
+ "PushedImage",
23
+ z.object({ repository: z.string(), tag: z.string(), digest: z.string() }),
24
+ );
25
+
17
26
  /** A Helm release that was installed or upgraded. */
18
27
  export const HelmRelease = model(
19
28
  "HelmRelease",
20
- z.object({ release: z.string(), namespace: z.string(), image: z.string() }),
29
+ z.object({
30
+ release: z.string(),
31
+ namespace: z.string(),
32
+ /** The release's main image; absent for a chart deployed without images. */
33
+ image: z.string().optional(),
34
+ /** Where step pods reach the release (the target's `url`); absent = its in-cluster Service URL (`serviceUrl`). */
35
+ url: z.string().optional(),
36
+ }),
21
37
  );
22
38
 
23
39
  /** One failed step of a run, as seen by a failure handler. */
@@ -0,0 +1,50 @@
1
+ // sshSession — run commands on a host over SSH from a handler (installs openssh-client when the image lacks ssh).
2
+ import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import type { Ctx } from "../ctx/types.ts";
5
+
6
+ const SAFE = /^[A-Za-z0-9_\/.:=@%+,-]+$/;
7
+ /** POSIX shell quoting (safe words are left bare, so logs stay readable). */
8
+ export function shq(s: string): string {
9
+ return SAFE.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
10
+ }
11
+
12
+ /** Installs the ssh client unless the image has one: apk (Alpine) or apt-get (Debian, Ubuntu). */
13
+ export const SSH_CLIENT_INSTALL =
14
+ "command -v ssh >/dev/null 2>&1 || if command -v apk >/dev/null 2>&1; then apk add --no-cache openssh-client; " +
15
+ "else apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends openssh-client; fi";
16
+
17
+ export interface SshSession {
18
+ readonly host: string;
19
+ /** Runs a remote command; a non-zero exit throws. */
20
+ run(remote: string): Promise<void>;
21
+ /** Runs a remote command and returns its stdout. */
22
+ text(remote: string): Promise<string>;
23
+ /** `scp -r` a local file or directory to a remote path. */
24
+ copy(local: string, remote: string): Promise<void>;
25
+ /** The ssh invocation as a shell command, for pipes and redirects: `ctx.sh(`${s.shell("cat > f")} < ${file}`)`. */
26
+ shell(remote: string): string;
27
+ }
28
+
29
+ /**
30
+ * Opens an SSH "session" to `host` (`user@host`) with a private key: installs openssh-client if needed,
31
+ * writes the key to `<tmpDir>/ssh/id`. The host key is not verified: hosts are reached over the tailnet.
32
+ */
33
+ export async function sshSession(ctx: Pick<Ctx, "exec" | "sh" | "tmpDir">, opts: { host: string; key: string }): Promise<SshSession> {
34
+ await ctx.sh(SSH_CLIENT_INSTALL);
35
+ const dir = join(ctx.tmpDir, "ssh");
36
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
37
+ const keyFile = join(dir, "id");
38
+ // OpenSSH rejects a key without a trailing newline.
39
+ writeFileSync(keyFile, opts.key.endsWith("\n") ? opts.key : opts.key + "\n", { mode: 0o600 });
40
+ chmodSync(keyFile, 0o600);
41
+ const sshOpts = ["-i", keyFile, "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR", "-o", "BatchMode=yes"];
42
+ const argv = (remote: string) => ["ssh", ...sshOpts, opts.host, remote];
43
+ return {
44
+ host: opts.host,
45
+ run: (remote) => ctx.exec(argv(remote)),
46
+ text: (remote) => ctx.sh.text(argv(remote).map(shq).join(" ")),
47
+ copy: (local, remote) => ctx.exec(["scp", ...sshOpts, "-r", local, `${opts.host}:${remote}`]),
48
+ shell: (remote) => argv(remote).map(shq).join(" "),
49
+ };
50
+ }
@@ -1,11 +1,11 @@
1
- // @olve/pipelines/testing — run a handler under `bun test` through the real harness path
1
+ // @olivervea/pipelines/testing — run a handler under `bun test` through the real harness path
2
2
  // (validate input → handler → strict output validation), with a real ctx you can override.
3
3
  import { mkdtempSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { isImplementation, type Implementation } from "../core/handlers.ts";
7
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";
8
+ import type { AnyStep, ConfigOf, OutputValue, SecretsOf, StepInput } from "../core/steps.ts";
9
9
  import { createCtx } from "../ctx/index.ts";
10
10
  import type { Ctx, RunInfo } from "../ctx/types.ts";
11
11
  import { executeStep, type FailureKind, type StepFunction } from "../harness/execute.ts";
@@ -13,7 +13,9 @@ import { sha256Hex } from "../util/hash.ts";
13
13
 
14
14
  export interface TestHandlerOptions<S extends AnyStep> {
15
15
  /** Replace parts of ctx (e.g. `sh`, `http`, `files`). The rest is the real implementation. */
16
- ctx?: Partial<Ctx<SecretsOf<S>>>;
16
+ ctx?: Partial<Ctx<SecretsOf<S>, ConfigOf<S>>>;
17
+ /** The step's config entries (z.input view: defaults are applied), like `$config` in input.json. */
18
+ config?: Record<string, unknown>;
17
19
  /** Secret values (instead of reading the environment). */
18
20
  secrets?: Partial<Record<SecretsOf<S>, string>>;
19
21
  run?: Partial<Omit<RunInfo, "step">>;
@@ -36,24 +38,18 @@ export type TestResult<S extends AnyStep> = {
36
38
  );
37
39
 
38
40
  /**
39
- * Runs a handler (the default export of `handlers/<step>.ts`) or a library step with `input`.
41
+ * Runs an implementation (a handler module's default export, or a step that carries its own) with `input`.
40
42
  * Input is validated like `input.json`, the result like a real run: an invalid output gives
41
43
  * `{ status: "failed", error: { kind: "output" } }`, a throwing handler `kind: "handler"`.
42
44
  */
43
45
  export async function testHandler<S extends AnyStep>(
44
- target: Implementation<S> | (S & LibraryStep),
46
+ target: Implementation<S>,
45
47
  input: StepInput<S["input"]>,
46
48
  opts: TestHandlerOptions<S> = {},
47
49
  ): 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");
50
+ if (!isImplementation(target)) throw new TypeError("testHandler expects implement(…): a handler module's default export or a step that carries its implementation");
51
+ const step: AnyStep = target.step;
52
+ const fn = target.handler as unknown as StepFunction;
57
53
 
58
54
  const dir = opts.dir ?? mkdtempSync(join(tmpdir(), `olve-test-${step.name}-`));
59
55
  const filesDir = join(dir, "work", "files");
@@ -69,7 +65,7 @@ export async function testHandler<S extends AnyStep>(
69
65
  });
70
66
  const ctx = { ...real, ...opts.ctx } as Ctx;
71
67
  // Round-trip through JSON like input.json does (drops undefined, rejects non-JSON values early).
72
- const raw = JSON.parse(JSON.stringify(input ?? {}));
68
+ const raw = JSON.parse(JSON.stringify({ ...input, ...(opts.config ? { $config: opts.config } : {}) }));
73
69
  const exec = await executeStep({ step, fn, rawInput: raw, ctx, workFiles: filesDir, log: (l) => logs.push(l) });
74
70
  return { dir, filesDir, logs, exitCode: exec.exitCode, ...exec.envelope } as TestResult<S>;
75
71
  }