@olivervea/pipelines 2.0.0-beta.2 → 2.0.0-beta.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +125 -62
  2. package/manifest.schema.json +208 -11
  3. package/package.json +6 -4
  4. package/src/assets.d.ts +2 -2
  5. package/src/build/build.ts +121 -51
  6. package/src/build/cli.ts +29 -22
  7. package/src/build/elf.ts +8 -7
  8. package/src/build/hashes.ts +82 -20
  9. package/src/build/project.ts +95 -28
  10. package/src/builder/pipeline.ts +344 -88
  11. package/src/builder/refs.ts +15 -5
  12. package/src/core/brands.ts +7 -5
  13. package/src/core/config-refs.ts +103 -0
  14. package/src/core/config.ts +224 -0
  15. package/src/core/external.ts +86 -0
  16. package/src/core/handlers.ts +61 -15
  17. package/src/core/models.ts +32 -15
  18. package/src/core/origin.ts +23 -0
  19. package/src/core/secrets.ts +2 -2
  20. package/src/core/steps.ts +89 -38
  21. package/src/core/types.ts +5 -1
  22. package/src/core/webhooks.ts +21 -7
  23. package/src/ctx/files.ts +111 -40
  24. package/src/ctx/index.ts +84 -31
  25. package/src/ctx/sh.ts +105 -44
  26. package/src/ctx/types.ts +12 -4
  27. package/src/describe/describe.ts +701 -133
  28. package/src/describe/manifest-schema.ts +258 -61
  29. package/src/describe/mapper-source.ts +249 -104
  30. package/src/describe/record.ts +67 -37
  31. package/src/dev/cli.ts +72 -35
  32. package/src/harness/env.ts +45 -25
  33. package/src/harness/execute.ts +58 -29
  34. package/src/harness/index.ts +22 -9
  35. package/src/harness/main.ts +239 -78
  36. package/src/harness/result.ts +38 -22
  37. package/src/harness/validate.ts +77 -15
  38. package/src/index.ts +124 -11
  39. package/src/library/checks.ts +28 -0
  40. package/src/library/compose-deploy.ts +113 -0
  41. package/src/library/github-source.ts +100 -47
  42. package/src/library/helm-deploy-ssh.ts +452 -108
  43. package/src/library/helm-deploy.ts +449 -0
  44. package/src/library/http-health.ts +7 -5
  45. package/src/library/image-build.ts +499 -105
  46. package/src/library/index.ts +48 -12
  47. package/src/library/models.ts +33 -9
  48. package/src/library/ssh.ts +67 -0
  49. package/src/testing/index.ts +74 -36
  50. package/src/util/canonical.ts +7 -4
  51. package/src/util/handler-hash.ts +17 -7
  52. package/src/util/hash.ts +5 -5
  53. package/src/util/tar.ts +86 -36
  54. package/src/library/script.ts +0 -85
@@ -1,45 +1,69 @@
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";
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";
4
+ import {
5
+ existsSync,
6
+ mkdirSync,
7
+ mkdtempSync,
8
+ readFileSync,
9
+ rmSync,
10
+ statSync,
11
+ writeFileSync,
12
+ } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import type { Ctx, RunInfo } from '../ctx/types.ts';
15
+ import type { FileRef } from '../core/models.ts';
16
+ import type { Secret } from '../core/secrets.ts';
17
+ import { isConfigRef, type ConfigRef, type Configurable } from '../core/config.ts';
18
+ import { isLiteral, resolveConfigRefs } from '../core/config-refs.ts';
19
+ import { implement, type Implementation } from '../core/handlers.ts';
20
+ import { step, type LiteralName, type Step } from '../core/steps.ts';
21
+ import { sha256File } from '../util/hash.ts';
22
+ import { dirEntries, writeTar } from '../util/tar.ts';
23
+ import { BuiltImage, PushedImage, Source } from './models.ts';
11
24
 
12
25
  /** 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";
26
+ export const KANIKO_IMAGE = 'gcr.io/kaniko-project/executor:debug';
14
27
 
15
28
  /**
16
29
  * Footgun: the build context must live under /kaniko, NOT /workspace (or another plain directory in
17
30
  * the container): kaniko wipes the filesystem root between the stages of a multi-stage build, which
18
31
  * breaks a later stage's COPY from the context. /kaniko survives stage transitions.
19
32
  */
20
- export const KANIKO_CONTEXT = "/kaniko/build-context";
33
+ export const KANIKO_CONTEXT = '/kaniko/build-context';
21
34
 
22
35
  const input = { source: Source };
23
36
  export type ImageBuildInput = typeof input;
24
37
 
25
- export interface ImageBuildDef<N extends string> {
38
+ /** Credentials for the registry of `repository`: a user name and a secret holding the password or token. */
39
+ export interface RegistryAuth<K extends string> {
40
+ username: string;
41
+ password: Secret<K>;
42
+ }
43
+
44
+ /** Registry credentials as config: the password is a `secretRef` (`{ secret: NAME }`) the step must declare in `secrets`. */
45
+ export interface RegistryAuthConfig {
46
+ username: string;
47
+ password: { secret: string };
48
+ }
49
+
50
+ export interface ImageBuildDef<N extends string, K extends string = never> {
26
51
  name: LiteralName<N>;
27
52
  /** What the step does, shown in the UI. Required; may be empty. */
28
53
  description: string;
29
- /** Image name, e.g. `olve-pipelines-v2`. The tag is the run's version (see `imageTag`). */
30
- repository: string;
31
54
  /**
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.
55
+ * Image name, e.g. `olve-pipelines-v2`; with `push`, the registry repository to push to
56
+ * (`registry.ovea.pro/app/api`). The tag is the run's version (see `imageTag`) unless `contentTag`.
35
57
  */
36
- cacheRepo?: string;
58
+ repository: Configurable<string>;
59
+ /** Build context: a directory of the source tree with a `Dockerfile` (default: the root). */
60
+ context?: string;
37
61
  /**
38
62
  * kaniko `--registry-map` (registry → mirror prefix); kaniko falls back to the original registry if
39
63
  * the mirror lacks the image. Defaults to the homelab pull-through caches (`DEFAULT_REGISTRY_MAP`);
40
64
  * a map replaces the default entirely, `{}` turns mapping off.
41
65
  */
42
- registryMap?: Record<string, string>;
66
+ registryMap?: Configurable<Record<string, string> | undefined>;
43
67
  timeoutSeconds?: number;
44
68
  /**
45
69
  * Kill kaniko when it writes no output for this long (default 300 s; 0 disables), failing the
@@ -54,30 +78,68 @@ export interface ImageBuildDef<N extends string> {
54
78
  * A `--no-push` build is idempotent, so the default is 2 (three tries).
55
79
  */
56
80
  retries?: number;
81
+ /**
82
+ * Log in to the registry of `repository` (e.g. `ghcr.io` with a token that may write packages).
83
+ * Used for kaniko's push, base image and cache pulls from that registry, and the `contentTag` lookup.
84
+ */
85
+ registryAuth?: RegistryAuth<K> | ConfigRef<RegistryAuthConfig | undefined>;
86
+ /** The registry of `repository` speaks plain HTTP (a local `registry:2`). */
87
+ insecureRegistry?: Configurable<boolean | undefined>;
88
+ /** Secrets a config-referenced `registryAuth` may name (the step declares them). */
89
+ secrets?: readonly Secret<K>[];
90
+ /** `false` skips the step (`ctx.skip`), e.g. `enabled: PUBLISH.harness.push`. Default true. */
91
+ enabled?: Configurable<boolean | undefined>;
92
+ }
93
+
94
+ /** Tarball mode (default): output `BuiltImage` with the image tar as a file. */
95
+ export interface ImageBuildTarballDef<
96
+ N extends string,
97
+ K extends string = never,
98
+ > extends ImageBuildDef<N, K> {
99
+ push?: false;
100
+ contentTag?: never;
57
101
  }
58
102
 
59
103
  /**
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.
104
+ * Registry mode: push to `repository` and output `PushedImage`, for images too big to travel as a
105
+ * tarball (the step's scratch space is ~1 GiB).
62
106
  */
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
- };
107
+ export interface ImageBuildPushDef<
108
+ N extends string,
109
+ K extends string = never,
110
+ > extends ImageBuildDef<N, K> {
111
+ push: true;
112
+ /**
113
+ * Tag the image `src-<hash>`, the hash of the build context's content, and skip the build when the
114
+ * registry already has that tag: the image is rebuilt only when its context changes.
115
+ */
116
+ contentTag?: boolean;
117
+ }
118
+
119
+ /** Default `registryMap`: none, kaniko pulls from the registries themselves. */
120
+ export const DEFAULT_REGISTRY_MAP: Readonly<Record<string, string>> = {};
67
121
 
68
122
  /** How long a cache registry gets to answer `GET /v2/` before the build goes without it. */
69
123
  export const CACHE_PROBE_TIMEOUT_MS = 5000;
70
124
 
71
125
  /** The registry host of an image reference or mirror prefix (`registry.ovea.pro/a/b` → `registry.ovea.pro`). */
72
126
  export function registryHost(ref: string): string {
73
- return ref.split("/")[0]!;
127
+ return ref.split('/')[0]!;
74
128
  }
75
129
 
76
130
  /** 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> {
131
+ export async function registryUp(
132
+ http: Ctx['http'],
133
+ host: string,
134
+ signal?: AbortSignal,
135
+ timeoutMs = CACHE_PROBE_TIMEOUT_MS,
136
+ insecure = false,
137
+ ): Promise<boolean> {
78
138
  try {
79
139
  const timeout = AbortSignal.timeout(timeoutMs);
80
- const res = await http.fetch(`https://${host}/v2/`, { signal: signal ? AbortSignal.any([signal, timeout]) : timeout });
140
+ const res = await http.fetch(`${insecure ? 'http' : 'https'}://${host}/v2/`, {
141
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
142
+ });
81
143
  await res.body?.cancel().catch(() => {});
82
144
  return res.status === 200 || res.status === 401;
83
145
  } catch {
@@ -85,26 +147,36 @@ export async function registryUp(http: Ctx["http"], host: string, signal?: Abort
85
147
  }
86
148
  }
87
149
 
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;
150
+ /** The registry map without the mirrors that don't answer, so a mirror outage (GC, maintenance) never blocks a build. */
151
+ export async function reachableMirrors(
152
+ ctx: Pick<Ctx, 'http' | 'log' | 'signal'>,
153
+ registryMap: Record<string, string>,
154
+ insecureHosts: readonly string[] = [],
155
+ ): Promise<Record<string, string>> {
156
+ const hosts = new Set(Object.values(registryMap).map(registryHost));
157
+ const up = new Map(
158
+ await Promise.all(
159
+ [...hosts].map(
160
+ async (h) =>
161
+ [
162
+ h,
163
+ await registryUp(
164
+ ctx.http,
165
+ h,
166
+ ctx.signal,
167
+ undefined,
168
+ insecureHosts.includes(h),
169
+ ),
170
+ ] as const,
171
+ ),
172
+ ),
173
+ );
174
+ const reachable: Record<string, string> = {};
175
+ for (const [from, to] of Object.entries(registryMap)) {
176
+ if (up.get(registryHost(to))) reachable[from] = to;
101
177
  else ctx.log(`${registryHost(to)} unreachable; pulling from ${from} directly`);
102
178
  }
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 } : {}) };
179
+ return reachable;
108
180
  }
109
181
 
110
182
  /** Default `retries`: a stalled or flaky build gets two fresh attempts before the run fails. */
@@ -118,77 +190,399 @@ export const DEFAULT_STALL_TIMEOUT_SECONDS = 300;
118
190
  * must not reuse the tag: deploys import it with `pullPolicy=Never`, so an unchanged tag would leave
119
191
  * the old pods running.
120
192
  */
121
- export function imageTag(run: Pick<RunInfo, "version" | "attempt">): string {
193
+ export function imageTag(run: Pick<RunInfo, 'version' | 'attempt'>): string {
122
194
  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`);
195
+ if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(tag))
196
+ throw new Error(`'${tag}' (from the run's version stamp) is not a valid image tag`);
124
197
  return tag;
125
198
  }
126
199
 
127
200
  /** The name a container runtime gives an image after loading it (`x` → `docker.io/library/x`). */
128
201
  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;
202
+ const first = repository.split('/')[0]!;
203
+ if (!repository.includes('/')) return `docker.io/library/${repository}`;
204
+ if (first.includes('.') || first.includes(':') || first === 'localhost')
205
+ return repository;
132
206
  return `docker.io/${repository}`;
133
207
  }
134
208
 
209
+ /** A relative path inside the source tree (no `..`, not absolute). */
210
+ function checkRelative(what: string, path: string): string {
211
+ const clean = path.replace(/^\.\/+/, '').replace(/\/+$/, '');
212
+ if (clean.startsWith('/') || clean.split('/').includes('..'))
213
+ throw new Error(
214
+ `imageBuild ${what} '${path}' must be a relative path inside the source tree`,
215
+ );
216
+ return clean === '.' ? '' : clean;
217
+ }
218
+
219
+ /** The `src-<12 hex>` tag of a directory: the sha256 of its deterministic tar (as `ctx.output.dir` writes it). */
220
+ export function contentTagOf(dir: string, tmpDir: string): string {
221
+ mkdirSync(tmpDir, { recursive: true });
222
+ const work = mkdtempSync(join(tmpDir, 'content-tag-'));
223
+ try {
224
+ const tar = join(work, 'context.tar');
225
+ writeTar(tar, dirEntries(dir));
226
+ return `src-${sha256File(tar).slice(0, 12)}`;
227
+ } finally {
228
+ rmSync(work, { recursive: true, force: true });
229
+ }
230
+ }
231
+
232
+ const MANIFEST_TYPES = [
233
+ 'application/vnd.oci.image.index.v1+json',
234
+ 'application/vnd.oci.image.manifest.v1+json',
235
+ 'application/vnd.docker.distribution.manifest.list.v2+json',
236
+ 'application/vnd.docker.distribution.manifest.v2+json',
237
+ ].join(', ');
238
+
239
+ /** Options of a registry API call: login (`username`/`password`) and plain HTTP. */
240
+ export interface RegistryAccess {
241
+ username?: string;
242
+ password?: string;
243
+ insecure?: boolean;
244
+ }
245
+
246
+ const basic = (username: string, password: string) =>
247
+ `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
248
+
249
+ /** `Bearer realm="…",service="…",scope="…"` → its parameters. */
250
+ export function parseChallenge(header: string): {
251
+ scheme: string;
252
+ params: Record<string, string>;
253
+ } {
254
+ const scheme = header.trim().split(/\s+/, 1)[0]!.toLowerCase();
255
+ const params: Record<string, string> = {};
256
+ for (const m of header.slice(scheme.length).matchAll(/([A-Za-z]+)="([^"]*)"/g))
257
+ params[m[1]!.toLowerCase()] = m[2]!;
258
+ return { scheme, params };
259
+ }
260
+
261
+ /**
262
+ * `HEAD`/`GET` against a registry API path, answering its auth challenge: Basic (a `registry:2` with
263
+ * htpasswd) or a Bearer token from the realm (ghcr.io, Docker Hub; anonymous for public images).
264
+ */
265
+ async function registryFetch(
266
+ http: Ctx['http'],
267
+ host: string,
268
+ path: string,
269
+ init: RequestInit,
270
+ access: RegistryAccess,
271
+ ): Promise<Response> {
272
+ const url = `${access.insecure ? 'http' : 'https'}://${host}${path}`;
273
+ const res = await http.fetch(url, init);
274
+ if (res.status !== 401) return res;
275
+ await res.body?.cancel().catch(() => {});
276
+ const { scheme, params } = parseChallenge(res.headers.get('www-authenticate') ?? '');
277
+ const login =
278
+ access.username !== undefined && access.password !== undefined
279
+ ? basic(access.username, access.password)
280
+ : undefined;
281
+ let authorization: string;
282
+ if (scheme === 'basic') {
283
+ if (!login) return new Response(null, { status: 401 });
284
+ authorization = login;
285
+ } else if (scheme === 'bearer' && params.realm) {
286
+ const q = new URLSearchParams();
287
+ if (params.service) q.set('service', params.service);
288
+ if (params.scope) q.set('scope', params.scope);
289
+ const tokenRes = await http.fetch(`${params.realm}?${q}`, {
290
+ headers: login ? { Authorization: login } : {},
291
+ });
292
+ if (!tokenRes.ok) {
293
+ await tokenRes.body?.cancel().catch(() => {});
294
+ return new Response(null, {
295
+ status: tokenRes.status === 404 ? 401 : tokenRes.status,
296
+ });
297
+ }
298
+ const body = (await tokenRes.json()) as { token?: string; access_token?: string };
299
+ const token = body.token ?? body.access_token;
300
+ if (!token)
301
+ throw new Error(`registry token endpoint ${params.realm} returned no token`);
302
+ authorization = `Bearer ${token}`;
303
+ } else {
304
+ return new Response(null, { status: 401 });
305
+ }
306
+ const headers = new Headers(init.headers);
307
+ headers.set('Authorization', authorization);
308
+ return http.fetch(url, { ...init, headers });
309
+ }
310
+
311
+ /** The digest of `repository:tag` in its registry (`HEAD …/manifests/<tag>`), or undefined if it has none. */
312
+ export async function registryDigest(
313
+ http: Ctx['http'],
314
+ repository: string,
315
+ tag: string,
316
+ access: RegistryAccess = {},
317
+ ): Promise<string | undefined> {
318
+ const [host, ...path] = repository.split('/');
319
+ const res = await registryFetch(
320
+ http,
321
+ host!,
322
+ `/v2/${path.join('/')}/manifests/${tag}`,
323
+ { method: 'HEAD', headers: { Accept: MANIFEST_TYPES } },
324
+ access,
325
+ );
326
+ await res.body?.cancel().catch(() => {});
327
+ if (res.status === 404) return undefined;
328
+ // An anonymous lookup of a repository that doesn't exist yet (or is private) is denied, not 404.
329
+ if ((res.status === 401 || res.status === 403) && access.password === undefined)
330
+ return undefined;
331
+ if (!res.ok)
332
+ throw new Error(
333
+ `registry lookup of ${repository}:${tag} answered HTTP ${res.status}`,
334
+ );
335
+ const digest = res.headers.get('docker-content-digest') ?? '';
336
+ return /^sha256:[0-9a-f]{64}$/.test(digest) ? digest : undefined;
337
+ }
338
+
339
+ /** kaniko reads registry logins from `$DOCKER_CONFIG/config.json` (`/kaniko/.docker` in its image). */
340
+ export const KANIKO_DOCKER_CONFIG = '/kaniko/.docker';
341
+
342
+ /** A docker `config.json` that logs in to `host`. */
343
+ export function dockerConfigJson(
344
+ host: string,
345
+ username: string,
346
+ password: string,
347
+ ): string {
348
+ return JSON.stringify({
349
+ auths: {
350
+ [host]: { auth: Buffer.from(`${username}:${password}`).toString('base64') },
351
+ },
352
+ });
353
+ }
354
+
355
+ /** Adds the login for `host` to `<dir>/config.json` (owner-only), keeping what is there (kaniko's credHelpers). */
356
+ export function writeDockerConfig(
357
+ dir: string,
358
+ host: string,
359
+ username: string,
360
+ password: string,
361
+ ): void {
362
+ mkdirSync(dir, { recursive: true });
363
+ const file = join(dir, 'config.json');
364
+ let config: { auths?: Record<string, unknown> } = {};
365
+ try {
366
+ config = JSON.parse(readFileSync(file, 'utf8'));
367
+ } catch {
368
+ // absent or unreadable: start fresh
369
+ }
370
+ const login = JSON.parse(dockerConfigJson(host, username, password)) as {
371
+ auths: Record<string, unknown>;
372
+ };
373
+ writeFileSync(
374
+ file,
375
+ JSON.stringify({ ...config, auths: { ...config.auths, ...login.auths } }),
376
+ { mode: 0o600 },
377
+ );
378
+ }
379
+
135
380
  /**
136
- * Builds `<source>/Dockerfile` with the source root as context, using kaniko, into an image tarball.
137
- * Output: `BuiltImage { repository, tag, digest, tar }`.
381
+ * 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 }`.
382
+ * With `push: true`: pushed to `repository`, output `PushedImage { repository, tag, digest }`.
138
383
  */
139
- export function imageBuild<const N extends string>(def: ImageBuildDef<N>): Step<N, ImageBuildInput, typeof BuiltImage, never> & LibraryStep {
384
+ export function imageBuild<const N extends string, const K extends string = never>(
385
+ def: ImageBuildTarballDef<N, K>,
386
+ ): Implementation<Step<N, ImageBuildInput, typeof BuiltImage, K>>;
387
+ export function imageBuild<const N extends string, const K extends string = never>(
388
+ def: ImageBuildPushDef<N, K>,
389
+ ): Implementation<Step<N, ImageBuildInput, typeof PushedImage, K>>;
390
+ export function imageBuild<const N extends string, const K extends string = never>(
391
+ def: ImageBuildTarballDef<N, K> | ImageBuildPushDef<N, K>,
392
+ ): AnyImageBuild {
393
+ const push = def.push === true;
394
+ const contentTag = push && def.contentTag === true;
395
+ const context = checkRelative('context', def.context ?? '');
396
+ const authSecret =
397
+ def.registryAuth && !isConfigRef(def.registryAuth) ? def.registryAuth : undefined;
398
+ if (isLiteral([def.repository, def.registryAuth, def.insecureRegistry]))
399
+ checkRegistryHost(
400
+ def.name,
401
+ def.repository as string,
402
+ push,
403
+ def.registryAuth !== undefined,
404
+ def.insecureRegistry === true,
405
+ );
406
+ const secrets = [
407
+ ...(authSecret ? [authSecret.password] : []),
408
+ ...(def.secrets ?? []),
409
+ ].filter((s, i, all) => all.findIndex((x) => x.name === s.name) === i);
140
410
  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: () => ({
411
+ const mapped = isConfigRef(registryMap) || Object.keys(registryMap).length > 0;
412
+ const contract = step({
413
+ name: def.name,
414
+ description: def.description,
415
+ kind: 'imageBuild',
416
+ image: KANIKO_IMAGE,
417
+ input,
418
+ output: push ? PushedImage : BuiltImage,
419
+ secrets,
420
+ timeoutSeconds: def.timeoutSeconds,
421
+ retries: def.retries ?? DEFAULT_RETRIES,
422
+ settings: {
423
+ repository: def.repository,
424
+ ...(context ? { context } : {}),
425
+ ...(push ? { push: true } : {}),
426
+ ...(contentTag ? { contentTag: true } : {}),
427
+ ...(mapped ? { registryMap } : {}),
428
+ ...(authSecret
429
+ ? {
430
+ registryAuth: {
431
+ username: authSecret.username,
432
+ password: authSecret.password.name,
433
+ },
434
+ }
435
+ : {}),
436
+ ...(def.registryAuth && isConfigRef(def.registryAuth)
437
+ ? { registryAuth: def.registryAuth }
438
+ : {}),
439
+ ...(def.insecureRegistry !== undefined && def.insecureRegistry !== false
440
+ ? { insecureRegistry: def.insecureRegistry }
441
+ : {}),
442
+ ...(def.enabled !== undefined ? { enabled: def.enabled } : {}),
443
+ },
444
+ });
445
+ return implement(contract, async ({ source }, ctx: Ctx) => {
446
+ const d = resolveConfigRefs(
447
+ {
147
448
  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 };
449
+ registryMap: def.registryMap,
450
+ insecureRegistry: def.insecureRegistry,
451
+ registryAuth: def.registryAuth,
452
+ enabled: def.enabled,
191
453
  },
192
- },
193
- ) as never;
454
+ ctx.config ?? {},
455
+ );
456
+ const repository = d.repository as string;
457
+ if (d.enabled === false) return ctx.skip(`publishing ${repository} is turned off`);
458
+ const insecure = d.insecureRegistry === true;
459
+ const auth = registryLogin(
460
+ d.registryAuth as RegistryAuth<string> | RegistryAuthConfig | undefined,
461
+ ctx,
462
+ secrets.map((s) => s.name),
463
+ );
464
+ checkRegistryHost(def.name, repository, push, auth !== undefined, insecure);
465
+ const host = registryHost(repository);
466
+
467
+ const tree = await ctx.files.fetch(source.tree);
468
+ const contextDir = context ? join(tree, context) : tree;
469
+ if (!existsSync(contextDir) || !statSync(contextDir).isDirectory())
470
+ throw new Error(`build context '${context}' not found in the source tree`);
471
+ if (!existsSync(join(contextDir, 'Dockerfile')))
472
+ throw new Error(
473
+ `Dockerfile not found in the build context${context ? ` '${context}'` : ''}`,
474
+ );
475
+
476
+ const access: RegistryAccess = { insecure, ...(auth ?? {}) };
477
+ if (auth)
478
+ writeDockerConfig(
479
+ process.env.DOCKER_CONFIG || KANIKO_DOCKER_CONFIG,
480
+ host,
481
+ auth.username,
482
+ auth.password,
483
+ );
484
+
485
+ const tag = contentTag ? contentTagOf(contextDir, ctx.tmpDir) : imageTag(ctx.run);
486
+ if (contentTag) {
487
+ const digest = await registryDigest(ctx.http, repository, tag, access);
488
+ if (digest) {
489
+ ctx.log('image already in the registry', {
490
+ image: `${repository}:${tag}`,
491
+ digest,
492
+ });
493
+ return { repository, tag, digest };
494
+ }
495
+ }
496
+
497
+ const mirrors = await reachableMirrors(
498
+ ctx,
499
+ (d.registryMap as Record<string, string> | undefined) ?? DEFAULT_REGISTRY_MAP,
500
+ insecure ? [host] : [],
501
+ );
502
+ await ctx.exec(['mkdir', '-p', KANIKO_CONTEXT]);
503
+ await ctx.exec(['cp', '-r', `${tree}/.`, KANIKO_CONTEXT]);
504
+ const kanikoContext = context ? `${KANIKO_CONTEXT}/${context}` : KANIKO_CONTEXT;
505
+
506
+ // Outputs go to the harness's scratch dir (/olve/tmp, an emptyDir mount kaniko leaves alone;
507
+ // kaniko images have no /tmp), then into the step's files.
508
+ mkdirSync(ctx.tmpDir, { recursive: true });
509
+ const out = mkdtempSync(join(ctx.tmpDir, 'image-build-'));
510
+ const tarPath = join(out, 'image.tar');
511
+ const digestPath = join(out, 'digest');
512
+ const output = push ? [] : ['--no-push', `--tar-path=${tarPath}`];
513
+ // No --single-snapshot: it is incompatible with multi-stage Dockerfiles whose later stages need
514
+ // the build context intact.
515
+ await ctx.exec(
516
+ [
517
+ '/kaniko/executor',
518
+ `--context=${kanikoContext}`,
519
+ `--dockerfile=${kanikoContext}/Dockerfile`,
520
+ ...output,
521
+ `--digest-file=${digestPath}`,
522
+ `--destination=${repository}:${tag}`,
523
+ ...(insecure ? ['--insecure', `--insecure-registry=${host}`] : []),
524
+ '--snapshot-mode=redo',
525
+ '--use-new-run',
526
+ '--compressed-caching=false',
527
+ ...Object.entries(mirrors).map(([from, to]) => `--registry-map=${from}=${to}`),
528
+ ],
529
+ { idleTimeoutSeconds: def.stallTimeoutSeconds ?? DEFAULT_STALL_TIMEOUT_SECONDS },
530
+ );
531
+
532
+ let digest: string;
533
+ try {
534
+ digest = readFileSync(digestPath, 'utf8').trim();
535
+ } catch {
536
+ throw new Error(`kaniko did not write the image digest to ${digestPath}`);
537
+ }
538
+ if (!/^sha256:[0-9a-f]{64}$/.test(digest))
539
+ throw new Error(`kaniko wrote an unexpected digest: '${digest}'`);
540
+ if (push) {
541
+ ctx.log('image pushed', { image: `${repository}:${tag}`, digest });
542
+ return { repository, tag, digest };
543
+ }
544
+ const tar = await ctx.output.file(tarPath, { name: 'image.tar' });
545
+ rmSync(tarPath); // the copy in the step's files is the one that counts; don't hold the image twice in /olve
546
+ ctx.log('image built', { image: `${repository}:${tag}`, digest });
547
+ return { repository: qualifyRepository(repository), tag, digest, tar };
548
+ }) as never;
549
+ }
550
+
551
+ function checkRegistryHost(
552
+ name: string,
553
+ repository: string,
554
+ push: boolean,
555
+ auth: boolean,
556
+ insecure: boolean,
557
+ ): void {
558
+ const host = registryHost(repository);
559
+ if (push && !/[.:]|^localhost$/.test(host))
560
+ throw new Error(
561
+ `imageBuild '${name}': push needs a repository with a registry host, got '${repository}'`,
562
+ );
563
+ if ((auth || insecure) && !/[.:]|^localhost$/.test(host))
564
+ throw new Error(
565
+ `imageBuild '${name}': registryAuth and insecureRegistry need a repository with a registry host, got '${repository}'`,
566
+ );
194
567
  }
568
+
569
+ /** The login for `registryAuth` (a literal secret or a config `secretRef`), or undefined without one. */
570
+ function registryLogin(
571
+ auth: RegistryAuth<string> | RegistryAuthConfig | undefined,
572
+ ctx: Pick<Ctx, 'secrets'>,
573
+ declared: readonly string[],
574
+ ): { username: string; password: string } | undefined {
575
+ if (!auth) return undefined;
576
+ const name = 'name' in auth.password ? auth.password.name : auth.password.secret;
577
+ if (!declared.includes(name))
578
+ throw new Error(
579
+ `registryAuth names secret ${name}, which the step does not declare (add it to secrets)`,
580
+ );
581
+ const password = (ctx.secrets as Record<string, string | undefined>)[name];
582
+ if (!password) throw new Error(`secret ${name} (registryAuth) is not set`);
583
+ return { username: auth.username, password };
584
+ }
585
+
586
+ type AnyImageBuild = Implementation<
587
+ Step<string, ImageBuildInput, typeof BuiltImage | typeof PushedImage, any>
588
+ >;