@olivervea/pipelines 2.0.0-beta.15 → 2.0.0-beta.17
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 +6 -5
- package/manifest.schema.json +57 -10
- package/package.json +1 -1
- package/src/core/external.ts +26 -1
- package/src/ctx/index.ts +9 -0
- package/src/ctx/types.ts +5 -0
- package/src/describe/describe.ts +10 -1
- package/src/describe/manifest-schema.ts +15 -1
- package/src/harness/env.ts +15 -1
- package/src/harness/main.ts +2 -2
- package/src/index.ts +1 -1
- package/src/library/image-build.ts +2 -8
- package/src/testing/index.ts +3 -0
package/README.md
CHANGED
|
@@ -103,7 +103,7 @@ Rules:
|
|
|
103
103
|
- **Trigger mappers** produce `z.input`, so fields with defaults may be left out. **Handlers, mapper refs and `when`** see `z.output`.
|
|
104
104
|
- A **plain-schema input** has no model to match on, so the step needs a mapper.
|
|
105
105
|
- **`optional(M)`** means "maybe skipped": `M` must still have an earlier producer.
|
|
106
|
-
- **`external(M, { title, description?, from? })`** is answered from outside the run (`from
|
|
106
|
+
- **`external(M, { title, description?, from? })`** is answered from outside the run; `from` lists `callback()` (another system POSTs to the URL an earlier step gets from `ctx.inputUrl(step, input)`), `poll({ url, valuePath, intervalSeconds? })` and `person()` (the default). It needs no producer and no mapper entry; the step waits until it is answered, then the value (validated against `M`) is in its input like any other. Failure handlers can't have one.
|
|
107
107
|
|
|
108
108
|
### Config
|
|
109
109
|
|
|
@@ -146,14 +146,14 @@ Library steps carry their own implementation and image; they take typed settings
|
|
|
146
146
|
| Step | Image | Input → output | Config |
|
|
147
147
|
|---|---|---|---|
|
|
148
148
|
| `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` when set; unset = anonymous; becomes the step's secret), `apiUrl?` (default `https://api.github.com`) |
|
|
149
|
-
| `steps.imageBuild` | `gcr.io/kaniko-project/executor:debug` | `{ source: Source }` → `BuiltImage { repository, tag, digest, tar }`; with `push`: `PushedImage { repository, tag, digest }` | `repository` (image name; tag = run version, `-attempt<n>` on reruns), `context?` (dir in the source tree with the `Dockerfile`; default the root), `push?` (push to `repository` instead of a tarball), `contentTag?` (with `push`: tag `src-<context hash>`, skip when the registry has it), `registryMap?` (kaniko `--registry-map`: registry → mirror prefix; default
|
|
149
|
+
| `steps.imageBuild` | `gcr.io/kaniko-project/executor:debug` | `{ source: Source }` → `BuiltImage { repository, tag, digest, tar }`; with `push`: `PushedImage { repository, tag, digest }` | `repository` (image name; tag = run version, `-attempt<n>` on reruns), `context?` (dir in the source tree with the `Dockerfile`; default the root), `push?` (push to `repository` instead of a tarball), `contentTag?` (with `push`: tag `src-<context hash>`, skip when the registry has it), `registryMap?` (kaniko `--registry-map`: registry → mirror prefix; default none), `stallTimeoutSeconds?` (default 300, 0 = off), `retries?` (default 2), `registryAuth?` (`{ username, password: Secret }`: log in to the `repository` registry; or a config reference to `{ username, password: { secret: NAME } }`, with `NAME` in `secrets`), `insecureRegistry?` (that registry is plain HTTP), `secrets?` (declared secrets a config `registryAuth` may name), `enabled?` (`false` skips the step) |
|
|
150
150
|
| `steps.helmDeployOverSsh` | `alpine:3.22` (+ `apk add openssh-client` at run time) | `{ source: Source, <key>: BuiltImage }` per `images` (`PushedImage` with `fromRegistry`) → `HelmRelease { release, namespace, image? }` (`image`: the first image; absent without images) | `host` (`user@host`), `sshKey: Secret`, `release`, `namespace`, `chart` (dir in the source), `values?` (chart-relative `-f`), `set?`, `images?` (input key → values prefix; default `{ image: "image" }`; `{}` = the chart alone), `fromRegistry?`, `imageRefs?` (`PushedImage` input key → values prefix: set as `<prefix>.repository`/`.tag`/`.digest`, not pulled; works next to tarball `images`), `deployments?` (default `[release]`, none without images), `rolloutTimeoutSeconds?` (default 120), `dependencies?` (pull `Chart.yaml`'s dependencies on the host), `helmWait?` / `atomic?` (`helm --wait` / `--atomic`), `healthUrl?` (URL or `{ url, attempts?, delaySeconds? }`) |
|
|
151
151
|
| `steps.helmDeployInCluster` (and the `target` form of `helmDeployOverSsh`) | `alpine/helm:3.16.2` (ssh: `alpine:3.22`) | `{ source: Source, <key>: PushedImage }` per `images`, `<key>: optional(PushedImage)` per `imageRefs` → `HelmRelease` (`image`: `repository:tag@digest` of the first image) | `target` (a `select` case's reference to a `helmTarget()` variant: `{ kind: "inCluster", namespace, release, values, set, url? }` for helmDeployInCluster, the same plus `host` with `kind: "ssh"` for helmDeployOverSsh; `url` is where step pods reach the release, default its in-cluster Service URL, and is passed on in `HelmRelease.url`), `sshKey: Secret` (ssh only), `chart`, `inlineValues?` (objects written as values files after the target's), `images?`, `imageRefs?`, `deployments?` (ssh), `rolloutTimeoutSeconds?` (default 120), `healthPath?`; helmDeployInCluster declares `clusterAccess` |
|
|
152
152
|
| `steps.composeDeploy` | `alpine:3.22` (work happens in the harness) | `{ image: PushedImage }` → `HelmRelease { release: <service>, namespace: "compose", image: <repository>@<digest> }` | `target` (a `select` case's reference to the compose variant of `deployTarget()`: `{ kind: "compose", deployerUrl, service, token: { secret }, healthUrl? }`), `token: Secret` (must be the secret the target names; the step's only secret), `timeoutSeconds?` |
|
|
153
153
|
|
|
154
154
|
- `gitHubSource` downloads `<apiUrl>/repos/<repo>/tarball/<sha>` (retrying 5xx), strips the top-level directory and outputs it as `tree`.
|
|
155
155
|
- `imageBuild` copies the tree to `/kaniko/build-context` (kaniko wipes other directories between multi-stage builds), builds `--context=/kaniko/build-context[/<context>] --dockerfile=<that>/Dockerfile` with `--no-push --tar-path --digest-file`, and outputs the tarball. A missing context or Dockerfile fails the step before kaniko starts. `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.
|
|
156
|
-
- **Caches never block a build.** `registryMap`
|
|
156
|
+
- **Caches never block a build.** `registryMap` maps registries to pull-through caches (e.g. `{ "mcr.microsoft.com": "mcr-cache.example.com" }`; kaniko under gVisor can stall pulling from mcr.microsoft.com directly, a nearby cache doesn't). There is none by default. Before kaniko starts, every mirror host gets a 5 s `GET https://<host>/v2/` (200 or 401 = up); one that doesn't answer is dropped with a log line (`<cache> unreachable; pulling from <registry> directly`). 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 cache that dies mid-download (after the manifest came from it) fails or stalls the attempt; `retries` starts a fresh one, which probes again.
|
|
157
157
|
- **Registry mode** (`push: true`, for images too big for the step's ~1 GiB scratch space and Garage): kaniko runs without `--no-push`/`--tar-path`, pushes to `repository` (it must start with a registry host; anonymous unless `registryAuth`) and the output is a `PushedImage` with the digest from `--digest-file`. With `contentTag`, the tag is `src-` + the first 12 hex of the sha256 of the context directory's deterministic tar (the bytes `ctx.output.dir` would write), computed from the fetched tree; a `HEAD https://<host>/v2/<path>/manifests/<tag>` (answering a Basic or Bearer challenge: anonymous token, or the `registryAuth` login; an anonymous lookup that is denied counts as "not there") that answers a `Docker-Content-Digest` skips kaniko and outputs that digest. With `registryAuth`, the step declares the password secret and writes `$DOCKER_CONFIG/config.json` (`/kaniko/.docker` in the kaniko image) for that registry before kaniko runs, so pushes and base-image pulls from it are logged in. `insecureRegistry` makes the lookups plain HTTP and adds kaniko `--insecure --insecure-registry=<host>`.
|
|
158
158
|
- `helmDeployOverSsh` gets each image into the host's **k3s** containerd: a `BuiltImage` tar is streamed in (`nerdctl --address /run/k3s/containerd/containerd.sock --namespace k8s.io load`), a `PushedImage` (`fromRegistry`) is pulled there (`… pull -q`, so the rollout doesn't wait on the pull). It copies the chart to `/tmp/<release>-helm-<namespace>`, runs `helm upgrade --install` from inside it (values, then `set`, then per image `<prefix>.repository`/`.tag`/`.pullPolicy` — `Never` for tarballs, `IfNotPresent` for registry images — which always win), waits for `kubectl rollout status` of each of `deployments` in order (`deploy/<name>` for a bare name), and with `healthUrl` polls it (5 × 5 s unless given) from the harness. `images: {}` deploys the chart alone: the step's only input is `source`, no image is imported and no image flag set, and nothing is waited on unless `deployments` names it. `dependencies: true` runs `helm pull <name> --repo <repository> --version <version>` (or the `oci://` reference) into the copied chart's `charts/` for each dependency in `Chart.yaml`; `helmWait` / `atomic` add `--wait` / `--atomic` with `--timeout=<rolloutTimeoutSeconds>s` and make `deployments` default to none. Several images (one chart, two Deployments):
|
|
159
159
|
|
|
@@ -309,11 +309,12 @@ This section is everything the C# side needs in order to run the binary.
|
|
|
309
309
|
| `OLVE_CASES` | run | Only for a step inside a `select`: a JSON array of the case conditions it sits in (`["target.prod.kind=ssh"]`); `run <step>` uses it to pick the variant of a name several cases use. |
|
|
310
310
|
| `<SECRET_NAME>` | per step | Each declared secret as an env var of the **same name**: `secretKeyRef` key = name. Mount only the step's `secrets` (for poll triggers, the poll's `secrets`). Child processes inherit them. The mount is optional: an unset secret leaves the variable unset and `ctx.secrets.<NAME>` throws on access, so a handler can check `process.env.<NAME>` first and skip. |
|
|
311
311
|
| `OLVE_RESULT_URL` | optional | Pre-signed **PUT** for `result.tar.gz` (see below). Unset means no upload. |
|
|
312
|
+
| `OLVE_CALLBACK_URLS` | optional | JSON object mapping `<step>/<input>` → this run's callback URL of that external input (`ctx.inputUrl`). Set when the pipeline has `callback()` inputs and the instance has a public base URL. |
|
|
312
313
|
| `OLVE_FILE_URLS` | optional | JSON object mapping file key → pre-signed **GET** URL, one entry per file ref in the input. The key is `<run>/<step>/<attempt>/<path>` (`path` exactly as in the ref, including a trailing `/` for directories). The URL points at the stored object (see the storage rule). |
|
|
313
314
|
| `OLVE_FILES_BASE_URL` (+ `OLVE_FILES_TOKEN`) | optional | Fallback: `GET <base>/<run>/<step>/<attempt>/<object path>` with an optional `Authorization: Bearer <token>`. |
|
|
314
315
|
| `OLVE_FILES_DIR` | dev | Local pool laid out as `<run>/<step>/<attempt>/files/<object path>`. |
|
|
315
316
|
|
|
316
|
-
`OLVE_RESULT_URL`, `OLVE_FILE_URLS` and `
|
|
317
|
+
`OLVE_RESULT_URL`, `OLVE_FILE_URLS`, `OLVE_FILES_TOKEN` and `OLVE_CALLBACK_URLS` are removed from the environment of child processes. URLs are never logged past `?`.
|
|
317
318
|
|
|
318
319
|
`ctx.files.fetch` looks for a file in this order:
|
|
319
320
|
|
|
@@ -384,7 +385,7 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
|
|
|
384
385
|
- a nested object of mappings.
|
|
385
386
|
|
|
386
387
|
An object whose only key is `ref` (with a string value) or `const` is a tag. Describe never emits a nested object with exactly that key set. Model-matched inputs are emitted already resolved (`{"ref": "build"}`).
|
|
387
|
-
- **Step `externalInputs`** (when present) maps each external input key to `{model, title, description, from
|
|
388
|
+
- **Step `externalInputs`** (when present) maps each external input key to `{model, title, description, from}`, `from` being `{kind: "person"}`, `{kind: "callback"}` or `{kind: "poll", url, valuePath, intervalSeconds}`. Those keys are not in `input` or `inputTypes`; the service merges the answers into `input.json`.
|
|
388
389
|
- **`inputTypes`** declares each input key: `{model, optional}`, or `{schema: {output, input?}}` for a plain schema.
|
|
389
390
|
- **`handlerHash`** is the step's implementation identity. A step version can be computed from image + `inputTypes`/model versions + `output` model version + `secrets` + `configEntries` (and those entries' schemas) + `input` mappings + `handlerHash`. Stage placement, `when` and the step's `description` are not part of it.
|
|
390
391
|
- Custom steps: `sha256(canonical {bundle, harness})`. `bundle` hashes the handler bundled on its own, with `pipeline.ts`, `models.ts` and `@olivervea/pipelines` external. It is tree-shaken and includes embedded scripts, so it changes only when that handler's code (or code it uses) changes.
|
package/manifest.schema.json
CHANGED
|
@@ -411,17 +411,64 @@
|
|
|
411
411
|
"from": {
|
|
412
412
|
"description": "Who or what may answer.",
|
|
413
413
|
"items": {
|
|
414
|
-
"
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
"
|
|
418
|
-
|
|
414
|
+
"oneOf": [
|
|
415
|
+
{
|
|
416
|
+
"additionalProperties": false,
|
|
417
|
+
"properties": {
|
|
418
|
+
"kind": {
|
|
419
|
+
"const": "person",
|
|
420
|
+
"type": "string"
|
|
421
|
+
}
|
|
422
|
+
},
|
|
423
|
+
"required": [
|
|
424
|
+
"kind"
|
|
425
|
+
],
|
|
426
|
+
"type": "object"
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
"additionalProperties": false,
|
|
430
|
+
"properties": {
|
|
431
|
+
"kind": {
|
|
432
|
+
"const": "callback",
|
|
433
|
+
"type": "string"
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
"required": [
|
|
437
|
+
"kind"
|
|
438
|
+
],
|
|
439
|
+
"type": "object"
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
"additionalProperties": false,
|
|
443
|
+
"properties": {
|
|
444
|
+
"intervalSeconds": {
|
|
445
|
+
"maximum": 9007199254740991,
|
|
446
|
+
"minimum": 10,
|
|
447
|
+
"type": "integer"
|
|
448
|
+
},
|
|
449
|
+
"kind": {
|
|
450
|
+
"const": "poll",
|
|
451
|
+
"type": "string"
|
|
452
|
+
},
|
|
453
|
+
"url": {
|
|
454
|
+
"description": "GET while waiting; {run.id} and {run.number} are filled in.",
|
|
455
|
+
"type": "string"
|
|
456
|
+
},
|
|
457
|
+
"valuePath": {
|
|
458
|
+
"description": "Dot path to the value; '.' is the whole body.",
|
|
459
|
+
"minLength": 1,
|
|
460
|
+
"type": "string"
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
"required": [
|
|
464
|
+
"kind",
|
|
465
|
+
"url",
|
|
466
|
+
"valuePath",
|
|
467
|
+
"intervalSeconds"
|
|
468
|
+
],
|
|
469
|
+
"type": "object"
|
|
419
470
|
}
|
|
420
|
-
|
|
421
|
-
"required": [
|
|
422
|
-
"kind"
|
|
423
|
-
],
|
|
424
|
-
"type": "object"
|
|
471
|
+
]
|
|
425
472
|
},
|
|
426
473
|
"minItems": 1,
|
|
427
474
|
"type": "array"
|
package/package.json
CHANGED
package/src/core/external.ts
CHANGED
|
@@ -1,13 +1,38 @@
|
|
|
1
1
|
import { isModel, type AnyModel } from "./models.ts";
|
|
2
2
|
|
|
3
3
|
/** Who or what may supply an external input's value. */
|
|
4
|
-
export type ExternalSource =
|
|
4
|
+
export type ExternalSource =
|
|
5
|
+
| { readonly kind: "person" }
|
|
6
|
+
| { readonly kind: "callback" }
|
|
7
|
+
| { readonly kind: "poll"; readonly url: string; readonly valuePath: string; readonly intervalSeconds: number };
|
|
5
8
|
|
|
6
9
|
/** A signed-in user answers, in the UI or with `pl input`. */
|
|
7
10
|
export function person(): ExternalSource {
|
|
8
11
|
return Object.freeze({ kind: "person" as const });
|
|
9
12
|
}
|
|
10
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Another system answers by POSTing to this run's callback URL for the input, which an earlier step hands it
|
|
16
|
+
* with `ctx.inputUrl(step, input)`. The body is the value as JSON, or `{"$decline": "reason"}`.
|
|
17
|
+
*/
|
|
18
|
+
export function callback(): ExternalSource {
|
|
19
|
+
return Object.freeze({ kind: "callback" as const });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PollOptions {
|
|
23
|
+
/** GET while the step waits; `{run.id}` and `{run.number}` are filled in. */
|
|
24
|
+
url: string;
|
|
25
|
+
/** Dot path to the value in the JSON response; `.` is the whole body. */
|
|
26
|
+
valuePath: string;
|
|
27
|
+
/** Default 30; at least 10. */
|
|
28
|
+
intervalSeconds?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The service polls `url` while the step waits and answers with the value at `valuePath` once it validates. */
|
|
32
|
+
export function poll(options: PollOptions): ExternalSource {
|
|
33
|
+
return Object.freeze({ kind: "poll" as const, url: options.url, valuePath: options.valuePath, intervalSeconds: options.intervalSeconds ?? 30 });
|
|
34
|
+
}
|
|
35
|
+
|
|
11
36
|
/**
|
|
12
37
|
* A step input whose value comes from outside the run. The step waits until it is answered (the value is
|
|
13
38
|
* validated against `model`), then runs with the value in its input like any other.
|
package/src/ctx/index.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface CtxOptions {
|
|
|
18
18
|
signal: AbortSignal;
|
|
19
19
|
/** Where log lines go (default stderr). */
|
|
20
20
|
logSink?: (line: string) => void;
|
|
21
|
+
/** `<step>/<input>` → callback URL (`ctx.inputUrl`). */
|
|
22
|
+
callbackUrls?: Readonly<Record<string, string>>;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export function formatLog(message: string, fields?: Record<string, unknown>): string {
|
|
@@ -118,5 +120,12 @@ export function createCtx(o: CtxOptions): Ctx {
|
|
|
118
120
|
run: Object.freeze({ ...o.run }),
|
|
119
121
|
tmpDir: o.paths.tmp,
|
|
120
122
|
skip: skipped,
|
|
123
|
+
inputUrl: (step, input) => {
|
|
124
|
+
const name = typeof step === "string" ? step : step.name;
|
|
125
|
+
const url = o.callbackUrls?.[`${name}/${input}`];
|
|
126
|
+
if (!url)
|
|
127
|
+
throw new Error(`no callback URL for input '${input}' of step '${name}': it must list callback() in from, and the instance needs a public base URL`);
|
|
128
|
+
return url;
|
|
129
|
+
},
|
|
121
130
|
};
|
|
122
131
|
}
|
package/src/ctx/types.ts
CHANGED
|
@@ -113,4 +113,9 @@ export interface Ctx<Sec extends string = string, Cfg extends AnyConfigEntry = n
|
|
|
113
113
|
readonly tmpDir: string;
|
|
114
114
|
/** Runtime escape hatch: produce no output. Prefer `when` blocks. */
|
|
115
115
|
skip(reason: string): Skipped;
|
|
116
|
+
/**
|
|
117
|
+
* This run's callback URL for an external input with `from: [callback()]` (of any step of the pipeline):
|
|
118
|
+
* hand it to the system that will answer. POST the value as JSON, or `{"$decline": "reason"}`.
|
|
119
|
+
*/
|
|
120
|
+
inputUrl(step: string | { readonly name: string }, input: string): string;
|
|
116
121
|
}
|
package/src/describe/describe.ts
CHANGED
|
@@ -197,11 +197,20 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
197
197
|
if (failureHandler) problems.push(`${at}: failure handlers run unattended and cannot wait for input ('${key}')`);
|
|
198
198
|
if (typeof e.title !== "string" || e.title.trim() === "") problems.push(`${at} external input '${key}': title must not be empty`);
|
|
199
199
|
if (e.from.length === 0) problems.push(`${at} external input '${key}': from must list at least one source`);
|
|
200
|
+
if (new Set(e.from.map((f) => f.kind)).size !== e.from.length) problems.push(`${at} external input '${key}': each source kind may be listed once`);
|
|
201
|
+
if (e.from.some((f) => f.kind === "callback") && !/^[A-Za-z0-9_-]+$/.test(key))
|
|
202
|
+
problems.push(`${at} external input '${key}': a callback input's key must be letters, digits, '_' or '-' (it is part of the URL)`);
|
|
203
|
+
for (const f of e.from) {
|
|
204
|
+
if (f.kind !== "poll") continue;
|
|
205
|
+
if (!/^https?:\/\//.test(f.url)) problems.push(`${at} external input '${key}': poll url must be an absolute http(s) URL`);
|
|
206
|
+
if (!Number.isInteger(f.intervalSeconds) || f.intervalSeconds < 10) problems.push(`${at} external input '${key}': poll intervalSeconds must be an integer of at least 10`);
|
|
207
|
+
if (f.valuePath.trim() === "") problems.push(`${at} external input '${key}': poll valuePath must not be empty`);
|
|
208
|
+
}
|
|
200
209
|
externalInputs[key] = {
|
|
201
210
|
model: useModel(e.model, `${at} external input '${key}'`),
|
|
202
211
|
title: e.title,
|
|
203
212
|
description: e.description,
|
|
204
|
-
from: e.from.map((f) => ({
|
|
213
|
+
from: e.from.map((f) => ({ ...f })),
|
|
205
214
|
};
|
|
206
215
|
}
|
|
207
216
|
const entries = allEntries.filter(([, e]) => !isExternal(e));
|
|
@@ -79,7 +79,21 @@ export const StepDescriptor = z
|
|
|
79
79
|
model: modelName.describe("The value is validated against this model's input view."),
|
|
80
80
|
title: z.string().min(1).describe("What is asked, shown to whoever answers."),
|
|
81
81
|
description: z.string(),
|
|
82
|
-
from: z
|
|
82
|
+
from: z
|
|
83
|
+
.array(
|
|
84
|
+
z.discriminatedUnion("kind", [
|
|
85
|
+
z.strictObject({ kind: z.literal("person") }),
|
|
86
|
+
z.strictObject({ kind: z.literal("callback") }),
|
|
87
|
+
z.strictObject({
|
|
88
|
+
kind: z.literal("poll"),
|
|
89
|
+
url: z.string().describe("GET while waiting; {run.id} and {run.number} are filled in."),
|
|
90
|
+
valuePath: z.string().min(1).describe("Dot path to the value; '.' is the whole body."),
|
|
91
|
+
intervalSeconds: z.int().min(10),
|
|
92
|
+
}),
|
|
93
|
+
]),
|
|
94
|
+
)
|
|
95
|
+
.min(1)
|
|
96
|
+
.describe("Who or what may answer."),
|
|
83
97
|
}),
|
|
84
98
|
)
|
|
85
99
|
.optional()
|
package/src/harness/env.ts
CHANGED
|
@@ -13,6 +13,8 @@ export const ENV = {
|
|
|
13
13
|
resultUrl: "OLVE_RESULT_URL",
|
|
14
14
|
/** JSON object: file key (`<run>/<step>/<attempt>/<path>`) → pre-signed GET URL. Optional. */
|
|
15
15
|
fileUrls: "OLVE_FILE_URLS",
|
|
16
|
+
/** JSON object: `<step>/<input>` → callback URL of that external input in this run (`ctx.inputUrl`). Optional. */
|
|
17
|
+
callbackUrls: "OLVE_CALLBACK_URLS",
|
|
16
18
|
/** Base URL for file downloads: GET `<base>/<run>/<step>/<attempt>/<object path>`. Optional. */
|
|
17
19
|
filesBaseUrl: "OLVE_FILES_BASE_URL",
|
|
18
20
|
/** Bearer token sent with OLVE_FILES_BASE_URL requests. Optional. */
|
|
@@ -22,7 +24,19 @@ export const ENV = {
|
|
|
22
24
|
} as const;
|
|
23
25
|
|
|
24
26
|
/** Env vars never passed to child processes (they carry credentials for the harness only). */
|
|
25
|
-
export const PRIVATE_ENV: readonly string[] = [ENV.resultUrl, ENV.fileUrls, ENV.filesToken];
|
|
27
|
+
export const PRIVATE_ENV: readonly string[] = [ENV.resultUrl, ENV.fileUrls, ENV.filesToken, ENV.callbackUrls];
|
|
28
|
+
|
|
29
|
+
/** `OLVE_CALLBACK_URLS`, parsed; empty when unset or malformed. */
|
|
30
|
+
export function callbackUrlsFromEnv(): Record<string, string> {
|
|
31
|
+
const raw = process.env[ENV.callbackUrls];
|
|
32
|
+
if (!raw) return {};
|
|
33
|
+
try {
|
|
34
|
+
const parsed: unknown = JSON.parse(raw);
|
|
35
|
+
return parsed && typeof parsed === "object" ? (parsed as Record<string, string>) : {};
|
|
36
|
+
} catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
26
40
|
|
|
27
41
|
export const olveDir = () => process.env[ENV.dir] || "/olve";
|
|
28
42
|
export const paths = () => {
|
package/src/harness/main.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { pipelineData, type PipelineData, type StageNode } from "../builder/pipe
|
|
|
12
12
|
import { createCtx, formatLog } from "../ctx/index.ts";
|
|
13
13
|
import { DescribeError, describePipeline, manifestText } from "../describe/describe.ts";
|
|
14
14
|
import type { HarnessInfo } from "../util/handler-hash.ts";
|
|
15
|
-
import { ENV, paths, runInfoFromEnv } from "./env.ts";
|
|
15
|
+
import { callbackUrlsFromEnv, ENV, paths, runInfoFromEnv } from "./env.ts";
|
|
16
16
|
import { EXIT } from "./exit.ts";
|
|
17
17
|
import { executeStep, type Envelope, type Execution, type StepFunction } from "./execute.ts";
|
|
18
18
|
import { MANIFEST_BEGIN, MANIFEST_END, packResult, removeStale, sentinelBlock, uploadFile, writeEnvelope } from "./result.ts";
|
|
@@ -183,7 +183,7 @@ async function runMode(cfg: HarnessConfig, name: string | undefined, io: Harness
|
|
|
183
183
|
exec = { envelope: { status: "failed", error: { kind: "input", message: raw.message } }, exitCode: EXIT.inputInvalid };
|
|
184
184
|
} else {
|
|
185
185
|
const run = runInfoFromEnv(step.name);
|
|
186
|
-
const ctx = createCtx({ run, secrets: step.secrets, paths: { root: p.root, workFiles: p.files, tmp: p.tmp }, signal: cancel.signal, logSink: io.stderr });
|
|
186
|
+
const ctx = createCtx({ run, secrets: step.secrets, paths: { root: p.root, workFiles: p.files, tmp: p.tmp }, signal: cancel.signal, logSink: io.stderr, callbackUrls: callbackUrlsFromEnv() });
|
|
187
187
|
exec = await executeStep({ step, fn, rawInput: raw.value, ctx, workFiles: p.files, log: io.stderr });
|
|
188
188
|
}
|
|
189
189
|
return await finish(exec, io);
|
package/src/index.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { secret, type Secret, type DeclaredSecrets } from "./core/secrets.ts";
|
|
|
6
6
|
export { config, secretRef, isConfigRef, type ConfigEntry, type AnyConfigEntry, type ConfigValues, type ConfigRef, type ConfigNode, type Configurable, type DiscriminatorOf, type VariantOf } from "./core/config.ts";
|
|
7
7
|
export { hmacSha256, githubSignature, bearer, type WebhookVerify, type WebhookEvent } from "./core/webhooks.ts";
|
|
8
8
|
export { resolveConfigRefs, isLiteral, type Resolved } from "./core/config-refs.ts";
|
|
9
|
-
export { external, person, type External, type ExternalOptions, type ExternalSource } from "./core/external.ts";
|
|
9
|
+
export { callback, external, person, poll, type External, type ExternalOptions, type ExternalSource, type PollOptions } from "./core/external.ts";
|
|
10
10
|
export { step, type Step, type AnyStep, type StepDef, type StepInput, type StepKind, type ConfigOf, type InputEntry, type InputSpec, type LiteralName, type OutputValue } from "./core/steps.ts";
|
|
11
11
|
export type { Simplify } from "./core/types.ts";
|
|
12
12
|
export { implement, type Implementation, type Handler, type HandlerResult, type Skipped } from "./core/handlers.ts";
|
|
@@ -102,14 +102,8 @@ export interface ImageBuildPushDef<N extends string, K extends string = never> e
|
|
|
102
102
|
contentTag?: boolean;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
/**
|
|
106
|
-
|
|
107
|
-
* can stall forever pulling from mcr.microsoft.com directly.
|
|
108
|
-
*/
|
|
109
|
-
export const DEFAULT_REGISTRY_MAP: Readonly<Record<string, string>> = {
|
|
110
|
-
"mcr.microsoft.com": "mcr-cache.ovea.pro",
|
|
111
|
-
"index.docker.io": "hub-cache.ovea.pro",
|
|
112
|
-
};
|
|
105
|
+
/** Default `registryMap`: none, kaniko pulls from the registries themselves. */
|
|
106
|
+
export const DEFAULT_REGISTRY_MAP: Readonly<Record<string, string>> = {};
|
|
113
107
|
|
|
114
108
|
/** How long a cache registry gets to answer `GET /v2/` before the build goes without it. */
|
|
115
109
|
export const CACHE_PROBE_TIMEOUT_MS = 5000;
|
package/src/testing/index.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface TestHandlerOptions<S extends AnyStep> {
|
|
|
23
23
|
dir?: string;
|
|
24
24
|
/** Abort signal handed to ctx.signal. */
|
|
25
25
|
signal?: AbortSignal;
|
|
26
|
+
/** `<step>/<input>` → URL returned by `ctx.inputUrl`. */
|
|
27
|
+
callbackUrls?: Record<string, string>;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export type TestResult<S extends AnyStep> = {
|
|
@@ -62,6 +64,7 @@ export async function testHandler<S extends AnyStep>(
|
|
|
62
64
|
paths: { root: dir, workFiles: filesDir, tmp: join(dir, "tmp") },
|
|
63
65
|
signal: opts.signal ?? new AbortController().signal,
|
|
64
66
|
logSink: (l) => logs.push(l),
|
|
67
|
+
callbackUrls: opts.callbackUrls,
|
|
65
68
|
});
|
|
66
69
|
const ctx = { ...real, ...opts.ctx } as Ctx;
|
|
67
70
|
// Round-trip through JSON like input.json does (drops undefined, rejects non-JSON values early).
|