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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,9 +56,9 @@ bunx olve-build . --out .olve/out
56
56
  ### Releases
57
57
 
58
58
  Versions are `2.0.0-beta.N` on the `beta` dist-tag (`publishConfig`) until v2 reaches prod, then
59
- `latest`. To release, bump `version` in `package.json` and push to `v2`: the self-deploy's
60
- `publish-sdk` step runs `bun run check` and `bun publish` when that version is not on npm yet. It
61
- skips when the version exists, or when the pipeline secret `NPM_TOKEN` is unset.
59
+ `latest`. To release, bump `version` in `package.json`, push to `v2`: once the run reaches prod, the
60
+ self-deploy's `publish-sdk` step runs `bun run check` and `bun publish` when that version is not on npm yet. It
61
+ skips when the version exists, or when the pipeline secret `NPM_TOKEN` is unset. Published versions: `npm view @olivervea/pipelines versions`.
62
62
 
63
63
  ## Authoring
64
64
 
@@ -129,11 +129,12 @@ Library steps carry their own implementation and image; they take typed config a
129
129
  |---|---|---|---|
130
130
  | `steps.script` | yours | yours | `script` (embedded), `env` (see above) |
131
131
  | `steps.gitHubSource` | `alpine:3.22` (work happens in the harness) | `{ repo: "owner/name", sha }` (plain schemas: needs a mapper) → `Source` | `token?: Secret` (sent as `Authorization: Bearer`; becomes the step's secret) |
132
- | `steps.imageBuild` | `gcr.io/kaniko-project/executor:debug` | `{ source: Source }` → `BuiltImage { repository, tag, digest, tar }` | `repository` (image name; tag = run version, `-attempt<n>` on reruns), `cacheRepo?` (kaniko layer cache), `registryMap?` (kaniko `--registry-map`: registry → mirror prefix, falls back upstream), `stallTimeoutSeconds?` (default 300, 0 = off), `retries?` (default 2) |
132
+ | `steps.imageBuild` | `gcr.io/kaniko-project/executor:debug` | `{ source: Source }` → `BuiltImage { repository, tag, digest, tar }` | `repository` (image name; tag = run version, `-attempt<n>` on reruns), `cacheRepo?` (kaniko layer cache), `registryMap?` (kaniko `--registry-map`: registry → mirror prefix; default: the homelab pull-through caches, `{}` = off), `stallTimeoutSeconds?` (default 300, 0 = off), `retries?` (default 2) |
133
133
  | `steps.helmDeployOverSsh` | `alpine:3.22` (+ `apk add openssh-client` at run time) | `{ source: Source, image: BuiltImage }` → `HelmRelease { release, namespace, image }` | `host` (`user@host`), `sshKey: Secret`, `release`, `namespace`, `chart` (dir in the source), `values?` (chart-relative `-f`), `set?`, `healthUrl?` |
134
134
 
135
135
  - `gitHubSource` downloads `https://api.github.com/repos/<repo>/tarball/<sha>` (retrying 5xx), strips the top-level directory and outputs it as `tree`.
136
136
  - `imageBuild` copies the tree to `/kaniko/build-context` (kaniko wipes other directories between multi-stage builds), builds `Dockerfile` at the tree root with `--no-push --tar-path --digest-file`, and outputs the tarball. `repository` in the output is fully qualified the way a runtime names a loaded image (`x` → `docker.io/library/x`). A **stall watchdog** kills kaniko when it prints nothing for `stallTimeoutSeconds` (a base-image pull can stall on an idle connection at "Unpacking rootfs", and kaniko has no read timeout), failing the attempt; `retries` then starts a fresh attempt. The retry is deliberately a new pod, never a second kaniko in the same container: kaniko unpacks the base image over the container root and does not clean up when killed.
137
+ - **Caches never block a build.** `registryMap` defaults to the homelab pull-through caches (`DEFAULT_REGISTRY_MAP`: `mcr.microsoft.com` → `mcr-cache.ovea.pro`, `index.docker.io` → `hub-cache.ovea.pro`; kaniko under gVisor stalls pulling from mcr.microsoft.com directly). A map you pass replaces the default; `{}` turns mapping off. Before kaniko starts, every mirror host and the `cacheRepo` registry gets a 5 s `GET https://<host>/v2/` (200 or 401 = up); one that doesn't answer is dropped with a log line (`mcr-cache.ovea.pro unreachable; pulling from mcr.microsoft.com directly`, or no `--cache` for the layer cache). After that, kaniko itself copes: it falls back to the original registry when a mirror can't serve an image's manifest (`RetrieveRemoteImage`; `--skip-default-registry-fallback` is never set), a layer-cache lookup error is a cache miss, a cache push error is a warning, and `--skip-push-permission-check` stops its startup check of the cache repo from exiting. A cache that dies mid-download (after the manifest came from it) fails or stalls the attempt; `retries` starts a fresh one, which probes again.
137
138
  - `helmDeployOverSsh` streams the image tar into the host's **k3s** containerd (`nerdctl --address /run/k3s/containerd/containerd.sock --namespace k8s.io load`), copies the chart to `/tmp/<release>-helm-<namespace>`, runs `helm upgrade --install` from inside it (values, then `set`, then `image.repository`/`image.tag`/`image.pullPolicy=Never`, which always win), waits for `kubectl rollout status deploy/<release>`, and with `healthUrl` polls it (5 × 5 s) from the harness.
138
139
 
139
140
  **Helpers.** General functions the library steps are built on, exported for custom handlers:
@@ -197,7 +198,7 @@ export default pipeline({ name: "Olve.Pipelines", description: "…", input: M.D
197
198
  - **Stage forms.** `.stage(a)`, `.stage(a, b)`, `.stage([a, from => ({…})])` and `.stage([a, mapA], [b, mapB])` are accepted, and tuples can be mixed with plain steps. The old `.stage(step, mapper)` form is a type error and a describe error.
198
199
  - **The `from` argument.** It is keyed by step name, plus `input` for the pipeline input. It holds `Ref<T>` values, so only property access and literals compile. Use `from["deploy-beta"].url` for names that contain hyphens.
199
200
  - **Describe checks the mapper twice.** It records the mapper with proxies, and it statically analyses the mapper's source. The source must be an object literal of member chains, literals, constant arrays and nested object literals. Ternaries, `&&`, `||`, `??`, `!`, comparisons, arithmetic, template strings, calls, spread and optional chaining are all rejected. Free identifiers such as module constants are allowed; they are recorded as `{const}`.
200
- - **Triggers.** A pipeline may call `onPush` at most once. Each `onPoll` needs a distinct `name` (default `"poll"`).
201
+ - **Triggers.** A pipeline may call `onPush` at most once. Each `onPoll` and `onWebhook` needs a distinct `name` (`onPoll` defaults to `"poll"`). `onWebhook({ name, verify }, ({ headers, body }) => input | null)` maps a delivery to `/api/hooks/<pipeline id>/<name>`; `verify` is `hmacSha256({ secret, header, prefix? })`, `githubSignature(secret)` or `bearer(secret)`, and `null` ignores the delivery.
201
202
  - **Failure handlers.** `.onFailure(handler, { steps? })` takes a step whose inputs are all `FailureContext`. When a run fails, the service runs each handler whose scope (default: any step) contains a failed step, once per failure: `run <handler>` in the handler's image with its declared secrets, from the run's pinned binary. It is untracked and best-effort — not an attempt of the run, never retried, and its own failure triggers nothing; its log is kept at `runs/<p>/<run>/_failure/<handler>/<id>/log`. `FailureContext` carries `pipeline {id, name}`, `run {id, number, address, versionStamp, trigger, url}`, `version {number, commit}`, the run's `input`, a one-line `reason`, and `failedSteps[]` (`step, attempt, address, reason, logUrl`) — only the steps in the handler's scope, never empty.
202
203
 
203
204
  ### Testing handlers
@@ -335,9 +336,10 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
335
336
  - **`stages`** is an array whose items are either `{steps: [Step…]}` (parallel, with a barrier) or `{when: {field: [values]}, stages: [...]}`.
336
337
  - **`triggers`**:
337
338
  - `{kind: "push", name: "push"}`;
338
- - `{kind: "poll", name, url, headers, secrets, valuePath, intervalSeconds}`, where header values may contain `$SECRET:NAME`.
339
+ - `{kind: "poll", name, url, headers, secrets, valuePath, intervalSeconds}`, where header values may contain `$SECRET:NAME`;
340
+ - `{kind: "webhook", name, verify: {scheme: "hmac-sha256", secret, header, prefix} | {scheme: "bearer", secret}}`. The `map-trigger` event is `{headers, body}`; a `null` mapping yields a `skipped` envelope (the delivery is ignored).
339
341
  - **`failureHandlers`** is a list of `{step: Step, steps: [names] | null}`. Each handler's inputs are all FailureContext (`{"ref": "failure"}`).
340
- - **`secrets`** lists every secret used by steps and polls, sorted, as `{name, description}`.
342
+ - **`secrets`** lists every secret used by steps, polls and webhooks, sorted, as `{name, description}`.
341
343
 
342
344
  Describe runs these checks; reconcile should re-run the manifest-level ones:
343
345
 
@@ -483,6 +483,78 @@
483
483
  "intervalSeconds"
484
484
  ],
485
485
  "type": "object"
486
+ },
487
+ {
488
+ "additionalProperties": false,
489
+ "description": "Inbound HTTP delivery to /api/hooks/<pipeline id>/<name>. Event = {headers, body}; a null mapping ignores it.",
490
+ "properties": {
491
+ "kind": {
492
+ "const": "webhook",
493
+ "type": "string"
494
+ },
495
+ "name": {
496
+ "pattern": "^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$",
497
+ "type": "string"
498
+ },
499
+ "verify": {
500
+ "oneOf": [
501
+ {
502
+ "additionalProperties": false,
503
+ "properties": {
504
+ "header": {
505
+ "minLength": 1,
506
+ "type": "string"
507
+ },
508
+ "prefix": {
509
+ "description": "Text before the hex digest in the header, e.g. 'sha256='.",
510
+ "type": "string"
511
+ },
512
+ "scheme": {
513
+ "const": "hmac-sha256",
514
+ "type": "string"
515
+ },
516
+ "secret": {
517
+ "description": "Secret name; also the env var name in the step container.",
518
+ "pattern": "^[A-Z_][A-Z0-9_]*$",
519
+ "type": "string"
520
+ }
521
+ },
522
+ "required": [
523
+ "scheme",
524
+ "secret",
525
+ "header",
526
+ "prefix"
527
+ ],
528
+ "type": "object"
529
+ },
530
+ {
531
+ "additionalProperties": false,
532
+ "properties": {
533
+ "scheme": {
534
+ "const": "bearer",
535
+ "type": "string"
536
+ },
537
+ "secret": {
538
+ "description": "Secret name; also the env var name in the step container.",
539
+ "pattern": "^[A-Z_][A-Z0-9_]*$",
540
+ "type": "string"
541
+ }
542
+ },
543
+ "required": [
544
+ "scheme",
545
+ "secret"
546
+ ],
547
+ "type": "object"
548
+ }
549
+ ]
550
+ }
551
+ },
552
+ "required": [
553
+ "kind",
554
+ "name",
555
+ "verify"
556
+ ],
557
+ "type": "object"
486
558
  }
487
559
  ]
488
560
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olivervea/pipelines",
3
- "version": "2.0.0-beta.1",
3
+ "version": "2.0.0-beta.2",
4
4
  "description": "Olve.Pipelines v2 SDK: typed pipeline authoring, the step harness, and the build tool.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -3,6 +3,7 @@ import type { AnyModel, Infer, InferInput, Plain } from "../core/models.ts";
3
3
  import type { Secret } from "../core/secrets.ts";
4
4
  import type { AnyStep, OutputValue, Step } from "../core/steps.ts";
5
5
  import { isStep } from "../core/steps.ts";
6
+ import type { WebhookEvent, WebhookVerify } from "../core/webhooks.ts";
6
7
  import type { IsUnion, OrError, Simplify } from "../core/types.ts";
7
8
  import type { Optional } from "../core/models.ts";
8
9
  import type { FailureContext } from "../library/models.ts";
@@ -70,7 +71,7 @@ type SiblingNames<E extends readonly unknown[], K> = {
70
71
  }[number];
71
72
 
72
73
  /**
73
- * Per-entry check. Inference constraints (see spikes/sdk-types):
74
+ * Per-entry check. Inference constraints:
74
75
  * - the [step, mapper] branch must be exactly `readonly [S, (from) => …]` — wrapping S or the whole
75
76
  * mapper type in a conditional/intersection breaks inference of E or contextual typing of `from`;
76
77
  * so name errors of tuple entries are reported on the mapper's return type instead;
@@ -146,7 +147,8 @@ export type TriggerNode =
146
147
  readonly valuePath: string;
147
148
  readonly intervalSeconds: number;
148
149
  readonly map: (value: string) => unknown;
149
- };
150
+ }
151
+ | { readonly kind: "webhook"; readonly name: string; readonly verify: WebhookVerify; readonly map: (event: WebhookEvent) => unknown };
150
152
 
151
153
  export interface FailureHandlerNode {
152
154
  readonly step: AnyStep;
@@ -236,6 +238,18 @@ export class Pipeline<In extends AnyModel, A extends AvailMap> {
236
238
  return this;
237
239
  }
238
240
 
241
+ /**
242
+ * Start a run from a delivery to `/api/hooks/<pipeline id>/<name>`, authenticated by `verify`. Map it to the
243
+ * pipeline input (`z.input` view), or return `null` to ignore it. Runs as code in a `map-trigger` Job per delivery.
244
+ */
245
+ onWebhook<R extends InferInput<In>>(
246
+ cfg: { name: string; verify: WebhookVerify },
247
+ map: (event: WebhookEvent) => Exact<R, InferInput<In>> | null,
248
+ ): this {
249
+ this[PIPELINE].triggers.push({ kind: "webhook", name: cfg.name, verify: cfg.verify, map: map as (event: WebhookEvent) => unknown });
250
+ return this;
251
+ }
252
+
239
253
  /**
240
254
  * Add a stage (its steps run in parallel). Entries: `step` (inputs matched by model) or
241
255
  * `[step, from => ({ … })]` (explicit mapping). Every entry sees only what was available
@@ -0,0 +1,30 @@
1
+ import type { Secret } from "./secrets.ts";
2
+
3
+ /** How a webhook trigger authenticates deliveries. Build one with `hmacSha256`, `githubSignature` or `bearer`. */
4
+ export type WebhookVerify =
5
+ | { readonly scheme: "hmac-sha256"; readonly secret: Secret; readonly header: string; readonly prefix: string }
6
+ | { readonly scheme: "bearer"; readonly secret: Secret };
7
+
8
+ /** HMAC-SHA256 of the raw body keyed by `secret`, hex, in `header` after `prefix`. */
9
+ export function hmacSha256(o: { secret: Secret; header: string; prefix?: string }): WebhookVerify {
10
+ return Object.freeze({ scheme: "hmac-sha256", secret: o.secret, header: o.header, prefix: o.prefix ?? "" });
11
+ }
12
+
13
+ /** GitHub's delivery signature: `X-Hub-Signature-256: sha256=<hex>`. */
14
+ export function githubSignature(secret: Secret): WebhookVerify {
15
+ return hmacSha256({ secret, header: "X-Hub-Signature-256", prefix: "sha256=" });
16
+ }
17
+
18
+ /** `Authorization: Bearer <secret>`. */
19
+ export function bearer(secret: Secret): WebhookVerify {
20
+ return Object.freeze({ scheme: "bearer", secret });
21
+ }
22
+
23
+ /**
24
+ * What a webhook trigger mapper receives: the request headers (lower-case names, without `authorization`
25
+ * and the signature header) and the body (parsed JSON, or the raw text when it isn't JSON).
26
+ */
27
+ export interface WebhookEvent {
28
+ readonly headers: Readonly<Record<string, string>>;
29
+ readonly body: unknown;
30
+ }
@@ -339,9 +339,16 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
339
339
  // ── triggers ──
340
340
  const triggerNames = new Set<string>();
341
341
  const triggers: TriggerT[] = data.triggers.map((t) => {
342
- if (triggerNames.has(t.name)) problems.push(`duplicate trigger '${t.name}'${t.kind === "push" ? " (onPush may be used once)" : " (give each onPoll a distinct name)"}`);
342
+ if (triggerNames.has(t.name)) problems.push(`duplicate trigger '${t.name}'${t.kind === "push" ? " (onPush may be used once)" : " (give each onPoll and onWebhook a distinct name)"}`);
343
343
  triggerNames.add(t.name);
344
344
  if (t.kind === "push") return { kind: "push", name: "push" };
345
+ if (t.kind === "webhook") {
346
+ if (!STEP_NAME.test(t.name)) problems.push(`webhook trigger name '${t.name}' must be lower-case kebab`);
347
+ const secret = useSecret(t.verify.secret, `webhook '${t.name}'`);
348
+ if (t.verify.scheme === "bearer") return { kind: "webhook", name: t.name, verify: { scheme: "bearer", secret } };
349
+ if (!t.verify.header) problems.push(`webhook '${t.name}': the signature header must not be empty`);
350
+ return { kind: "webhook", name: t.name, verify: { scheme: "hmac-sha256", secret, header: t.verify.header, prefix: t.verify.prefix } };
351
+ }
345
352
  if (!STEP_NAME.test(t.name)) problems.push(`poll trigger name '${t.name}' must be lower-case kebab`);
346
353
  if (!(Number.isInteger(t.intervalSeconds) && t.intervalSeconds > 0)) problems.push(`poll '${t.name}': intervalSeconds must be a positive integer`);
347
354
  const declared = new Set(t.secrets.map((s) => useSecret(s, `poll '${t.name}'`)));
@@ -90,6 +90,21 @@ export const Trigger = z
90
90
  intervalSeconds: z.number().int().positive(),
91
91
  })
92
92
  .describe("Background poller."),
93
+ z
94
+ .strictObject({
95
+ kind: z.literal("webhook"),
96
+ name: z.string().regex(/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/),
97
+ verify: z.discriminatedUnion("scheme", [
98
+ z.strictObject({
99
+ scheme: z.literal("hmac-sha256"),
100
+ secret: secretName,
101
+ header: z.string().min(1),
102
+ prefix: z.string().describe("Text before the hex digest in the header, e.g. 'sha256='."),
103
+ }),
104
+ z.strictObject({ scheme: z.literal("bearer"), secret: secretName }),
105
+ ]),
106
+ })
107
+ .describe("Inbound HTTP delivery to /api/hooks/<pipeline id>/<name>. Event = {headers, body}; a null mapping ignores it."),
93
108
  ])
94
109
  .meta({ id: "Trigger" });
95
110
 
@@ -6,6 +6,7 @@ import { mkdirSync, readFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { isImplementation } from "../core/handlers.ts";
8
8
  import type { AnyStep } from "../core/steps.ts";
9
+ import type { WebhookEvent } from "../core/webhooks.ts";
9
10
  import { pipelineData, type PipelineData, type StageNode } from "../builder/pipeline.ts";
10
11
  import { createCtx, formatLog } from "../ctx/index.ts";
11
12
  import { DescribeError, describePipeline, manifestText } from "../describe/describe.ts";
@@ -162,6 +163,12 @@ async function runMode(cfg: HarnessConfig, name: string | undefined, io: Harness
162
163
  }
163
164
  }
164
165
 
166
+ function isWebhookEvent(v: unknown): boolean {
167
+ if (typeof v !== "object" || v === null || Array.isArray(v) || !("body" in v)) return false;
168
+ const headers = (v as { headers?: unknown }).headers;
169
+ return typeof headers === "object" && headers !== null && !Array.isArray(headers);
170
+ }
171
+
165
172
  async function mapTriggerMode(cfg: HarnessConfig, name: string | undefined, io: HarnessIO): Promise<number> {
166
173
  if (!name) return io.stderr(USAGE), EXIT.usage;
167
174
  const data = pipelineData(cfg.pipeline);
@@ -181,15 +188,21 @@ async function mapTriggerMode(cfg: HarnessConfig, name: string | undefined, io:
181
188
  exec = fail("input", `poll trigger '${name}': the event must be {"value": "<string>"}`, EXIT.inputInvalid);
182
189
  else if (trigger.kind === "push" && (typeof raw.value !== "object" || raw.value === null || Array.isArray(raw.value)))
183
190
  exec = fail("input", `push trigger: the event must be the webhook payload object`, EXIT.inputInvalid);
191
+ else if (trigger.kind === "webhook" && !isWebhookEvent(raw.value))
192
+ exec = fail("input", `webhook trigger '${name}': the event must be {"headers": {...}, "body": ...}`, EXIT.inputInvalid);
184
193
  else {
185
194
  let mapped: unknown;
186
195
  let threw: unknown = undefined;
187
196
  try {
188
- mapped = trigger.kind === "poll" ? await trigger.map((raw.value as { value: string }).value) : await trigger.map(raw.value);
197
+ mapped =
198
+ trigger.kind === "poll" ? await trigger.map((raw.value as { value: string }).value)
199
+ : trigger.kind === "webhook" ? await trigger.map(raw.value as WebhookEvent)
200
+ : await trigger.map(raw.value);
189
201
  } catch (e) {
190
202
  threw = e ?? new Error("undefined");
191
203
  }
192
204
  if (threw !== undefined) exec = fail("handler", `trigger '${name}' mapper threw: ${(threw as Error)?.message ?? String(threw)}`, EXIT.handlerFailed);
205
+ else if (mapped === null && trigger.kind === "webhook") exec = { envelope: { status: "skipped", reason: `trigger '${name}' ignored the delivery` }, exitCode: EXIT.ok };
193
206
  else {
194
207
  const r = parseStrict(data.input.schema, mapped, "input");
195
208
  exec = r.ok
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  export { z } from "zod";
4
4
  export { model, fileRef, optional, type Model, type AnyModel, type Infer, type InferInput, type Plain, type FileRef, type Optional } from "./core/models.ts";
5
5
  export { secret, type Secret, type DeclaredSecrets } from "./core/secrets.ts";
6
+ export { hmacSha256, githubSignature, bearer, type WebhookVerify, type WebhookEvent } from "./core/webhooks.ts";
6
7
  export { step, type Step, type AnyStep, type StepDef, type StepInput, type StepKind, type InputEntry, type InputSpec, type LibraryStep, type OutputValue } from "./core/steps.ts";
7
8
  export { implement, type Implementation, type Handler, type HandlerResult, type Skipped } from "./core/handlers.ts";
8
9
  export type { Ctx, RunInfo, Sh, ShOptions, ExecOptions, Http, HttpInit, RetryOptions, OutputFileOptions } from "./ctx/types.ts";
@@ -34,7 +34,11 @@ export interface ImageBuildDef<N extends string> {
34
34
  * A registry without GC grows with every changed layer — prune it when needed.
35
35
  */
36
36
  cacheRepo?: string;
37
- /** kaniko `--registry-map` (registry → mirror prefix); kaniko falls back to the original registry if the mirror lacks the image. */
37
+ /**
38
+ * kaniko `--registry-map` (registry → mirror prefix); kaniko falls back to the original registry if
39
+ * the mirror lacks the image. Defaults to the homelab pull-through caches (`DEFAULT_REGISTRY_MAP`);
40
+ * a map replaces the default entirely, `{}` turns mapping off.
41
+ */
38
42
  registryMap?: Record<string, string>;
39
43
  timeoutSeconds?: number;
40
44
  /**
@@ -52,6 +56,57 @@ export interface ImageBuildDef<N extends string> {
52
56
  retries?: number;
53
57
  }
54
58
 
59
+ /**
60
+ * Default `registryMap`: pull-through caches for mcr.microsoft.com and Docker Hub. kaniko under gVisor
61
+ * can stall forever pulling from mcr.microsoft.com directly.
62
+ */
63
+ export const DEFAULT_REGISTRY_MAP: Readonly<Record<string, string>> = {
64
+ "mcr.microsoft.com": "mcr-cache.ovea.pro",
65
+ "index.docker.io": "hub-cache.ovea.pro",
66
+ };
67
+
68
+ /** How long a cache registry gets to answer `GET /v2/` before the build goes without it. */
69
+ export const CACHE_PROBE_TIMEOUT_MS = 5000;
70
+
71
+ /** The registry host of an image reference or mirror prefix (`registry.ovea.pro/a/b` → `registry.ovea.pro`). */
72
+ export function registryHost(ref: string): string {
73
+ return ref.split("/")[0]!;
74
+ }
75
+
76
+ /** Whether a registry answers its API root: 200, or 401 (it wants a token). Never throws. */
77
+ export async function registryUp(http: Ctx["http"], host: string, signal?: AbortSignal, timeoutMs = CACHE_PROBE_TIMEOUT_MS): Promise<boolean> {
78
+ try {
79
+ const timeout = AbortSignal.timeout(timeoutMs);
80
+ const res = await http.fetch(`https://${host}/v2/`, { signal: signal ? AbortSignal.any([signal, timeout]) : timeout });
81
+ await res.body?.cancel().catch(() => {});
82
+ return res.status === 200 || res.status === 401;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Drops the caches that don't answer, so a cache outage (GC, maintenance) never blocks a build: an
90
+ * unreachable mirror is left out of the registry map, an unreachable `cacheRepo` means no layer cache.
91
+ */
92
+ export async function reachableCaches(
93
+ ctx: Pick<Ctx, "http" | "log" | "signal">,
94
+ caches: { registryMap: Record<string, string>; cacheRepo?: string },
95
+ ): Promise<{ registryMap: Record<string, string>; cacheRepo?: string }> {
96
+ const hosts = new Set([...Object.values(caches.registryMap), ...(caches.cacheRepo ? [caches.cacheRepo] : [])].map(registryHost));
97
+ const up = new Map(await Promise.all([...hosts].map(async (h) => [h, await registryUp(ctx.http, h, ctx.signal)] as const)));
98
+ const registryMap: Record<string, string> = {};
99
+ for (const [from, to] of Object.entries(caches.registryMap)) {
100
+ if (up.get(registryHost(to))) registryMap[from] = to;
101
+ else ctx.log(`${registryHost(to)} unreachable; pulling from ${from} directly`);
102
+ }
103
+ if (caches.cacheRepo && !up.get(registryHost(caches.cacheRepo))) {
104
+ ctx.log(`${registryHost(caches.cacheRepo)} unreachable; building without the layer cache`);
105
+ return { registryMap };
106
+ }
107
+ return { registryMap, ...(caches.cacheRepo ? { cacheRepo: caches.cacheRepo } : {}) };
108
+ }
109
+
55
110
  /** Default `retries`: a stalled or flaky build gets two fresh attempts before the run fails. */
56
111
  export const DEFAULT_RETRIES = 2;
57
112
 
@@ -82,6 +137,8 @@ export function qualifyRepository(repository: string): string {
82
137
  * Output: `BuiltImage { repository, tag, digest, tar }`.
83
138
  */
84
139
  export function imageBuild<const N extends string>(def: ImageBuildDef<N>): Step<N, ImageBuildInput, typeof BuiltImage, never> & LibraryStep {
140
+ const registryMap = def.registryMap ?? DEFAULT_REGISTRY_MAP;
141
+ const mapped = Object.keys(registryMap).length > 0;
85
142
  return makeStep(
86
143
  "imageBuild",
87
144
  { name: def.name, description: def.description, image: KANIKO_IMAGE, input, output: BuiltImage, secrets: [], timeoutSeconds: def.timeoutSeconds, retries: def.retries ?? DEFAULT_RETRIES },
@@ -89,10 +146,11 @@ export function imageBuild<const N extends string>(def: ImageBuildDef<N>): Step<
89
146
  manifestConfig: () => ({
90
147
  repository: def.repository,
91
148
  ...(def.cacheRepo ? { cacheRepo: def.cacheRepo } : {}),
92
- ...(def.registryMap ? { registryMap: def.registryMap } : {}),
149
+ ...(mapped ? { registryMap: { ...registryMap } } : {}),
93
150
  }),
94
151
  async run({ source }: { source: { tree: FileRef } }, ctx: Ctx) {
95
152
  const tag = imageTag(ctx.run);
153
+ const caches = await reachableCaches(ctx, { registryMap, cacheRepo: def.cacheRepo });
96
154
  const tree = await ctx.files.fetch(source.tree);
97
155
  await ctx.exec(["mkdir", "-p", KANIKO_CONTEXT]);
98
156
  await ctx.exec(["cp", "-r", `${tree}/.`, KANIKO_CONTEXT]);
@@ -104,7 +162,9 @@ export function imageBuild<const N extends string>(def: ImageBuildDef<N>): Step<
104
162
  const tarPath = join(out, "image.tar");
105
163
  const digestPath = join(out, "digest");
106
164
  // No --single-snapshot: it is incompatible with multi-stage Dockerfiles whose later stages need
107
- // the build context intact.
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.
108
168
  await ctx.exec([
109
169
  "/kaniko/executor",
110
170
  `--context=${KANIKO_CONTEXT}`,
@@ -113,8 +173,8 @@ export function imageBuild<const N extends string>(def: ImageBuildDef<N>): Step<
113
173
  `--tar-path=${tarPath}`,
114
174
  `--digest-file=${digestPath}`,
115
175
  `--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}`),
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}`),
118
178
  ], { idleTimeoutSeconds: def.stallTimeoutSeconds ?? DEFAULT_STALL_TIMEOUT_SECONDS });
119
179
 
120
180
  let digest: string;