@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 +9 -7
- package/manifest.schema.json +72 -0
- package/package.json +1 -1
- package/src/builder/pipeline.ts +16 -2
- package/src/core/webhooks.ts +30 -0
- package/src/describe/describe.ts +8 -1
- package/src/describe/manifest-schema.ts +15 -0
- package/src/harness/main.ts +14 -1
- package/src/index.ts +1 -0
- package/src/library/image-build.ts +65 -5
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
|
|
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,
|
|
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` (
|
|
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
|
|
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
|
|
package/manifest.schema.json
CHANGED
|
@@ -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
package/src/builder/pipeline.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/describe/describe.ts
CHANGED
|
@@ -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
|
|
package/src/harness/main.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
/**
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
117
|
-
...Object.entries(
|
|
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;
|