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

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,14 @@
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 } 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
8
  import { makeStep, type LibraryStep, type LiteralName, type Step } from "../core/steps.ts";
10
- import { BuiltImage, Source } from "./models.ts";
9
+ import { sha256File } from "../util/hash.ts";
10
+ import { dirEntries, writeTar } from "../util/tar.ts";
11
+ import { BuiltImage, PushedImage, Source } from "./models.ts";
11
12
 
12
13
  /** kaniko's debug image (it has a busybox shell for debugging; the executor itself needs none). */
13
14
  export const KANIKO_IMAGE = "gcr.io/kaniko-project/executor:debug";
@@ -26,15 +27,26 @@ export interface ImageBuildDef<N extends string> {
26
27
  name: LiteralName<N>;
27
28
  /** What the step does, shown in the UI. Required; may be empty. */
28
29
  description: string;
29
- /** Image name, e.g. `olve-pipelines-v2`. The tag is the run's version (see `imageTag`). */
30
+ /**
31
+ * Image name, e.g. `olve-pipelines-v2`; with `push`, the registry repository to push to
32
+ * (`registry.ovea.pro/app/api`). The tag is the run's version (see `imageTag`) unless `contentTag`.
33
+ */
30
34
  repository: string;
35
+ /** Build context: a directory of the source tree (default: the root). */
36
+ context?: string;
37
+ /** Dockerfile path, relative to the context (default `Dockerfile`). */
38
+ dockerfile?: string;
31
39
  /**
32
40
  * Registry repository for kaniko's layer cache (`--cache=true --cache-repo=…`), so unchanged
33
41
  * layers (restore, apt, npm, …) are reused. kaniko pushes cache layers even with `--no-push`.
34
42
  * A registry without GC grows with every changed layer — prune it when needed.
35
43
  */
36
44
  cacheRepo?: string;
37
- /** kaniko `--registry-map` (registry → mirror prefix); kaniko falls back to the original registry if the mirror lacks the image. */
45
+ /**
46
+ * kaniko `--registry-map` (registry → mirror prefix); kaniko falls back to the original registry if
47
+ * the mirror lacks the image. Defaults to the homelab pull-through caches (`DEFAULT_REGISTRY_MAP`);
48
+ * a map replaces the default entirely, `{}` turns mapping off.
49
+ */
38
50
  registryMap?: Record<string, string>;
39
51
  timeoutSeconds?: number;
40
52
  /**
@@ -52,6 +64,76 @@ export interface ImageBuildDef<N extends string> {
52
64
  retries?: number;
53
65
  }
54
66
 
67
+ /** Tarball mode (default): output `BuiltImage` with the image tar as a file. */
68
+ export interface ImageBuildTarballDef<N extends string> extends ImageBuildDef<N> {
69
+ push?: false;
70
+ contentTag?: never;
71
+ }
72
+
73
+ /**
74
+ * Registry mode: push to `repository` and output `PushedImage`, for images too big to travel as a
75
+ * tarball (the step's scratch space is ~1 GiB).
76
+ */
77
+ export interface ImageBuildPushDef<N extends string> extends ImageBuildDef<N> {
78
+ push: true;
79
+ /**
80
+ * Tag the image `src-<hash>`, the hash of the build context's content, and skip the build when the
81
+ * registry already has that tag: the image is rebuilt only when its context changes.
82
+ */
83
+ contentTag?: boolean;
84
+ }
85
+
86
+ /**
87
+ * Default `registryMap`: pull-through caches for mcr.microsoft.com and Docker Hub. kaniko under gVisor
88
+ * can stall forever pulling from mcr.microsoft.com directly.
89
+ */
90
+ export const DEFAULT_REGISTRY_MAP: Readonly<Record<string, string>> = {
91
+ "mcr.microsoft.com": "mcr-cache.ovea.pro",
92
+ "index.docker.io": "hub-cache.ovea.pro",
93
+ };
94
+
95
+ /** How long a cache registry gets to answer `GET /v2/` before the build goes without it. */
96
+ export const CACHE_PROBE_TIMEOUT_MS = 5000;
97
+
98
+ /** The registry host of an image reference or mirror prefix (`registry.ovea.pro/a/b` → `registry.ovea.pro`). */
99
+ export function registryHost(ref: string): string {
100
+ return ref.split("/")[0]!;
101
+ }
102
+
103
+ /** Whether a registry answers its API root: 200, or 401 (it wants a token). Never throws. */
104
+ export async function registryUp(http: Ctx["http"], host: string, signal?: AbortSignal, timeoutMs = CACHE_PROBE_TIMEOUT_MS): Promise<boolean> {
105
+ try {
106
+ const timeout = AbortSignal.timeout(timeoutMs);
107
+ const res = await http.fetch(`https://${host}/v2/`, { signal: signal ? AbortSignal.any([signal, timeout]) : timeout });
108
+ await res.body?.cancel().catch(() => {});
109
+ return res.status === 200 || res.status === 401;
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Drops the caches that don't answer, so a cache outage (GC, maintenance) never blocks a build: an
117
+ * unreachable mirror is left out of the registry map, an unreachable `cacheRepo` means no layer cache.
118
+ */
119
+ export async function reachableCaches(
120
+ ctx: Pick<Ctx, "http" | "log" | "signal">,
121
+ caches: { registryMap: Record<string, string>; cacheRepo?: string },
122
+ ): Promise<{ registryMap: Record<string, string>; cacheRepo?: string }> {
123
+ const hosts = new Set([...Object.values(caches.registryMap), ...(caches.cacheRepo ? [caches.cacheRepo] : [])].map(registryHost));
124
+ const up = new Map(await Promise.all([...hosts].map(async (h) => [h, await registryUp(ctx.http, h, ctx.signal)] as const)));
125
+ const registryMap: Record<string, string> = {};
126
+ for (const [from, to] of Object.entries(caches.registryMap)) {
127
+ if (up.get(registryHost(to))) registryMap[from] = to;
128
+ else ctx.log(`${registryHost(to)} unreachable; pulling from ${from} directly`);
129
+ }
130
+ if (caches.cacheRepo && !up.get(registryHost(caches.cacheRepo))) {
131
+ ctx.log(`${registryHost(caches.cacheRepo)} unreachable; building without the layer cache`);
132
+ return { registryMap };
133
+ }
134
+ return { registryMap, ...(caches.cacheRepo ? { cacheRepo: caches.cacheRepo } : {}) };
135
+ }
136
+
55
137
  /** Default `retries`: a stalled or flaky build gets two fresh attempts before the run fails. */
56
138
  export const DEFAULT_RETRIES = 2;
57
139
 
@@ -77,25 +159,91 @@ export function qualifyRepository(repository: string): string {
77
159
  return `docker.io/${repository}`;
78
160
  }
79
161
 
162
+ /** A relative path inside the source tree (no `..`, not absolute). */
163
+ function checkRelative(what: string, path: string): string {
164
+ const clean = path.replace(/^\.\/+/, "").replace(/\/+$/, "");
165
+ if (clean.startsWith("/") || clean.split("/").includes("..")) throw new Error(`imageBuild ${what} '${path}' must be a relative path inside the source tree`);
166
+ return clean === "." ? "" : clean;
167
+ }
168
+
169
+ /** The `src-<12 hex>` tag of a directory: the sha256 of its deterministic tar (as `ctx.output.dir` writes it). */
170
+ export function contentTagOf(dir: string, tmpDir: string): string {
171
+ mkdirSync(tmpDir, { recursive: true });
172
+ const work = mkdtempSync(join(tmpDir, "content-tag-"));
173
+ try {
174
+ const tar = join(work, "context.tar");
175
+ writeTar(tar, dirEntries(dir));
176
+ return `src-${sha256File(tar).slice(0, 12)}`;
177
+ } finally {
178
+ rmSync(work, { recursive: true, force: true });
179
+ }
180
+ }
181
+
182
+ const MANIFEST_TYPES = [
183
+ "application/vnd.oci.image.index.v1+json",
184
+ "application/vnd.oci.image.manifest.v1+json",
185
+ "application/vnd.docker.distribution.manifest.list.v2+json",
186
+ "application/vnd.docker.distribution.manifest.v2+json",
187
+ ].join(", ");
188
+
189
+ /** The digest of `repository:tag` in its registry (anonymous `HEAD …/manifests/<tag>`), or undefined if it has none. */
190
+ export async function registryDigest(http: Ctx["http"], repository: string, tag: string): Promise<string | undefined> {
191
+ const [host, ...path] = repository.split("/");
192
+ const res = await http.fetch(`https://${host}/v2/${path.join("/")}/manifests/${tag}`, { method: "HEAD", headers: { Accept: MANIFEST_TYPES } });
193
+ await res.body?.cancel().catch(() => {});
194
+ if (res.status === 404) return undefined;
195
+ if (!res.ok) throw new Error(`registry lookup of ${repository}:${tag} answered HTTP ${res.status}`);
196
+ const digest = res.headers.get("docker-content-digest") ?? "";
197
+ return /^sha256:[0-9a-f]{64}$/.test(digest) ? digest : undefined;
198
+ }
199
+
80
200
  /**
81
- * Builds `<source>/Dockerfile` with the source root as context, using kaniko, into an image tarball.
82
- * Output: `BuiltImage { repository, tag, digest, tar }`.
201
+ * Builds an image with kaniko from `<context>/<dockerfile>` in the source tree (default: `Dockerfile`
202
+ * at the root). Default: into an image tarball, output `BuiltImage { repository, tag, digest, tar }`.
203
+ * With `push: true`: pushed to `repository`, output `PushedImage { repository, tag, digest }`.
83
204
  */
84
- export function imageBuild<const N extends string>(def: ImageBuildDef<N>): Step<N, ImageBuildInput, typeof BuiltImage, never> & LibraryStep {
205
+ export function imageBuild<const N extends string>(def: ImageBuildTarballDef<N>): Step<N, ImageBuildInput, typeof BuiltImage, never> & LibraryStep;
206
+ export function imageBuild<const N extends string>(def: ImageBuildPushDef<N>): Step<N, ImageBuildInput, typeof PushedImage, never> & LibraryStep;
207
+ export function imageBuild<const N extends string>(def: ImageBuildTarballDef<N> | ImageBuildPushDef<N>): AnyImageBuild {
208
+ const registryMap = def.registryMap ?? DEFAULT_REGISTRY_MAP;
209
+ const mapped = Object.keys(registryMap).length > 0;
210
+ const push = def.push === true;
211
+ const contentTag = push && def.contentTag === true;
212
+ const context = checkRelative("context", def.context ?? "");
213
+ const dockerfile = checkRelative("dockerfile", def.dockerfile ?? "Dockerfile");
214
+ if (push && !/[.:]|^localhost$/.test(def.repository.split("/")[0]!)) throw new Error(`imageBuild '${def.name}': push needs a repository with a registry host, got '${def.repository}'`);
85
215
  return makeStep(
86
216
  "imageBuild",
87
- { name: def.name, description: def.description, image: KANIKO_IMAGE, input, output: BuiltImage, secrets: [], timeoutSeconds: def.timeoutSeconds, retries: def.retries ?? DEFAULT_RETRIES },
217
+ { name: def.name, description: def.description, image: KANIKO_IMAGE, input, output: push ? PushedImage : BuiltImage, secrets: [], timeoutSeconds: def.timeoutSeconds, retries: def.retries ?? DEFAULT_RETRIES },
88
218
  {
89
219
  manifestConfig: () => ({
90
220
  repository: def.repository,
221
+ ...(context ? { context } : {}),
222
+ ...(dockerfile !== "Dockerfile" ? { dockerfile } : {}),
223
+ ...(push ? { push: true } : {}),
224
+ ...(contentTag ? { contentTag: true } : {}),
91
225
  ...(def.cacheRepo ? { cacheRepo: def.cacheRepo } : {}),
92
- ...(def.registryMap ? { registryMap: def.registryMap } : {}),
226
+ ...(mapped ? { registryMap: { ...registryMap } } : {}),
93
227
  }),
94
228
  async run({ source }: { source: { tree: FileRef } }, ctx: Ctx) {
95
- const tag = imageTag(ctx.run);
96
229
  const tree = await ctx.files.fetch(source.tree);
230
+ const contextDir = context ? join(tree, context) : tree;
231
+ if (!existsSync(contextDir) || !statSync(contextDir).isDirectory()) throw new Error(`build context '${context}' not found in the source tree`);
232
+ if (!existsSync(join(contextDir, dockerfile))) throw new Error(`${dockerfile} not found in the build context${context ? ` '${context}'` : ""}`);
233
+
234
+ const tag = contentTag ? contentTagOf(contextDir, ctx.tmpDir) : imageTag(ctx.run);
235
+ if (contentTag) {
236
+ const digest = await registryDigest(ctx.http, def.repository, tag);
237
+ if (digest) {
238
+ ctx.log("image already in the registry", { image: `${def.repository}:${tag}`, digest });
239
+ return { repository: def.repository, tag, digest };
240
+ }
241
+ }
242
+
243
+ const caches = await reachableCaches(ctx, { registryMap, cacheRepo: def.cacheRepo });
97
244
  await ctx.exec(["mkdir", "-p", KANIKO_CONTEXT]);
98
245
  await ctx.exec(["cp", "-r", `${tree}/.`, KANIKO_CONTEXT]);
246
+ const kanikoContext = context ? `${KANIKO_CONTEXT}/${context}` : KANIKO_CONTEXT;
99
247
 
100
248
  // Outputs go to the harness's scratch dir (/olve/tmp, an emptyDir mount kaniko leaves alone;
101
249
  // kaniko images have no /tmp), then into the step's files.
@@ -103,18 +251,20 @@ export function imageBuild<const N extends string>(def: ImageBuildDef<N>): Step<
103
251
  const out = mkdtempSync(join(ctx.tmpDir, "image-build-"));
104
252
  const tarPath = join(out, "image.tar");
105
253
  const digestPath = join(out, "digest");
254
+ const output = push ? [] : ["--no-push", `--tar-path=${tarPath}`];
106
255
  // No --single-snapshot: it is incompatible with multi-stage Dockerfiles whose later stages need
107
- // the build context intact.
256
+ // the build context intact. --skip-push-permission-check: with --no-push kaniko otherwise checks
257
+ // the cache repo at startup and exits if it is down; later cache lookup and push errors are
258
+ // only warnings.
108
259
  await ctx.exec([
109
260
  "/kaniko/executor",
110
- `--context=${KANIKO_CONTEXT}`,
111
- `--dockerfile=${KANIKO_CONTEXT}/Dockerfile`,
112
- "--no-push",
113
- `--tar-path=${tarPath}`,
261
+ `--context=${kanikoContext}`,
262
+ `--dockerfile=${kanikoContext}/${dockerfile}`,
263
+ ...output,
114
264
  `--digest-file=${digestPath}`,
115
265
  `--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}`),
266
+ ...(caches.cacheRepo ? ["--cache=true", `--cache-repo=${caches.cacheRepo}`, "--skip-push-permission-check"] : []),
267
+ ...Object.entries(caches.registryMap).map(([from, to]) => `--registry-map=${from}=${to}`),
118
268
  ], { idleTimeoutSeconds: def.stallTimeoutSeconds ?? DEFAULT_STALL_TIMEOUT_SECONDS });
119
269
 
120
270
  let digest: string;
@@ -124,6 +274,10 @@ export function imageBuild<const N extends string>(def: ImageBuildDef<N>): Step<
124
274
  throw new Error(`kaniko did not write the image digest to ${digestPath}`);
125
275
  }
126
276
  if (!/^sha256:[0-9a-f]{64}$/.test(digest)) throw new Error(`kaniko wrote an unexpected digest: '${digest}'`);
277
+ if (push) {
278
+ ctx.log("image pushed", { image: `${def.repository}:${tag}`, digest });
279
+ return { repository: def.repository, tag, digest };
280
+ }
127
281
  const tar = await ctx.output.file(tarPath, { name: "image.tar" });
128
282
  rmSync(tarPath); // the copy in the step's files is the one that counts; don't hold the image twice in /olve
129
283
  ctx.log("image built", { image: `${def.repository}:${tag}`, digest });
@@ -132,3 +286,5 @@ export function imageBuild<const N extends string>(def: ImageBuildDef<N>): Step<
132
286
  },
133
287
  ) as never;
134
288
  }
289
+
290
+ type AnyImageBuild = Step<string, ImageBuildInput, typeof BuiltImage | typeof PushedImage, never> & LibraryStep;
@@ -3,11 +3,13 @@ import { helmDeployOverSsh } from "./helm-deploy-ssh.ts";
3
3
  import { imageBuild } from "./image-build.ts";
4
4
  import { script } from "./script.ts";
5
5
 
6
- export { Source, BuiltImage, HelmRelease, FailureContext } from "./models.ts";
6
+ export { Source, BuiltImage, PushedImage, HelmRelease, FailureContext } from "./models.ts";
7
7
  export { inputEnvName, type RunValue, type ScriptDef } from "./script.ts";
8
8
  export type { GitHubSourceDef } from "./github-source.ts";
9
- export type { ImageBuildDef } from "./image-build.ts";
9
+ export type { ImageBuildDef, ImageBuildTarballDef, ImageBuildPushDef } from "./image-build.ts";
10
10
  export type { HelmDeployOverSshDef } from "./helm-deploy-ssh.ts";
11
+ export { sshSession, shq, type SshSession } from "./ssh.ts";
12
+ export { serviceUrl, checkSpa } from "./checks.ts";
11
13
  export { httpHealthCheck, HTTP_HEALTH_DEFAULTS, type HttpHealthCheckOptions } from "./http-health.ts";
12
14
 
13
15
  /** SDK-provided steps (step + handler pairs with typed config). */
@@ -14,6 +14,15 @@ 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",
@@ -0,0 +1,45 @@
1
+ // sshSession — run commands on a host over SSH from a handler (alpine image: installs openssh-client).
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
+ export interface SshSession {
13
+ readonly host: string;
14
+ /** Runs a remote command; a non-zero exit throws. */
15
+ run(remote: string): Promise<void>;
16
+ /** Runs a remote command and returns its stdout. */
17
+ text(remote: string): Promise<string>;
18
+ /** `scp -r` a local file or directory to a remote path. */
19
+ copy(local: string, remote: string): Promise<void>;
20
+ /** The ssh invocation as a shell command, for pipes and redirects: `ctx.sh(`${s.shell("cat > f")} < ${file}`)`. */
21
+ shell(remote: string): string;
22
+ }
23
+
24
+ /**
25
+ * Opens an SSH "session" to `host` (`user@host`) with a private key: installs openssh-client (apk),
26
+ * writes the key to `<tmpDir>/ssh/id`. The host key is not verified: hosts are reached over the tailnet.
27
+ */
28
+ export async function sshSession(ctx: Pick<Ctx, "exec" | "sh" | "tmpDir">, opts: { host: string; key: string }): Promise<SshSession> {
29
+ await ctx.exec(["apk", "add", "--no-cache", "openssh-client"]);
30
+ const dir = join(ctx.tmpDir, "ssh");
31
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
32
+ const keyFile = join(dir, "id");
33
+ // OpenSSH rejects a key without a trailing newline.
34
+ writeFileSync(keyFile, opts.key.endsWith("\n") ? opts.key : opts.key + "\n", { mode: 0o600 });
35
+ chmodSync(keyFile, 0o600);
36
+ const sshOpts = ["-i", keyFile, "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR", "-o", "BatchMode=yes"];
37
+ const argv = (remote: string) => ["ssh", ...sshOpts, opts.host, remote];
38
+ return {
39
+ host: opts.host,
40
+ run: (remote) => ctx.exec(argv(remote)),
41
+ text: (remote) => ctx.sh.text(argv(remote).map(shq).join(" ")),
42
+ copy: (local, remote) => ctx.exec(["scp", ...sshOpts, "-r", local, `${opts.host}:${remote}`]),
43
+ shell: (remote) => argv(remote).map(shq).join(" "),
44
+ };
45
+ }
@@ -1,4 +1,4 @@
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,7 +5,7 @@ import { sha256Hex } from "./hash.ts";
5
5
  export interface HarnessInfo {
6
6
  /** Bun version (runtime compiled into the binary). */
7
7
  bun: string;
8
- /** @olve/pipelines package version. */
8
+ /** @olivervea/pipelines package version. */
9
9
  sdk: string;
10
10
  /** `sha256:` hash over the SDK runtime sources and the zod version. */
11
11
  sdkHash: string;