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

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