@olivervea/pipelines 2.0.0-beta.3 → 2.0.0-beta.31
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 +165 -78
- package/manifest.schema.json +300 -16
- package/package.json +6 -4
- package/src/assets.d.ts +2 -2
- package/src/build/build.ts +98 -64
- package/src/build/cli.ts +33 -23
- package/src/build/elf.ts +16 -7
- package/src/build/hashes.ts +40 -51
- package/src/build/project.ts +55 -52
- package/src/builder/pipeline.ts +348 -89
- package/src/builder/refs.ts +15 -5
- package/src/core/brands.ts +6 -5
- package/src/core/config-refs.ts +103 -0
- package/src/core/config.ts +224 -0
- package/src/core/external.ts +86 -0
- package/src/core/handlers.ts +62 -15
- package/src/core/matrix.ts +150 -0
- package/src/core/models.ts +37 -15
- package/src/core/secrets.ts +2 -2
- package/src/core/steps.ts +113 -38
- package/src/core/types.ts +5 -1
- package/src/core/webhooks.ts +21 -7
- package/src/ctx/files.ts +119 -42
- package/src/ctx/index.ts +110 -39
- package/src/ctx/links.ts +30 -0
- package/src/ctx/sh.ts +106 -44
- package/src/ctx/types.ts +24 -4
- package/src/describe/config-schema.ts +98 -0
- package/src/describe/context.ts +187 -0
- package/src/describe/describe.ts +63 -355
- package/src/describe/failure-handlers.ts +34 -0
- package/src/describe/manifest-schema.ts +304 -61
- package/src/describe/mapper-source.ts +252 -105
- package/src/describe/record.ts +67 -37
- package/src/describe/schemas.ts +71 -0
- package/src/describe/stages.ts +166 -0
- package/src/describe/step.ts +544 -0
- package/src/describe/triggers.ts +72 -0
- package/src/dev/cli.ts +62 -35
- package/src/harness/env.ts +47 -25
- package/src/harness/execute.ts +74 -30
- package/src/harness/exit.ts +1 -1
- package/src/harness/index.ts +19 -8
- package/src/harness/main.ts +245 -99
- package/src/harness/mask.ts +98 -0
- package/src/harness/result.ts +45 -24
- package/src/harness/validate.ts +82 -15
- package/src/index.ts +139 -10
- package/src/library/checks.ts +11 -6
- package/src/library/compose-deploy.ts +115 -0
- package/src/library/github-source.ts +100 -47
- package/src/library/helm-deploy-ssh.ts +426 -112
- package/src/library/helm-deploy.ts +529 -0
- package/src/library/http-health.ts +7 -5
- package/src/library/image-build.ts +510 -162
- package/src/library/index.ts +48 -14
- package/src/library/models.ts +35 -14
- package/src/library/ssh.ts +44 -18
- package/src/testing/index.ts +100 -35
- package/src/util/canonical.ts +7 -4
- package/src/util/code-hash.ts +17 -0
- package/src/util/hash.ts +5 -5
- package/src/util/tar.ts +83 -38
- package/src/library/script.ts +0 -85
- package/src/util/handler-hash.ts +0 -22
package/README.md
CHANGED
|
@@ -4,18 +4,18 @@ The [Olve.Pipelines](https://github.com/OliverVea/Olve.Pipelines) v2 SDK. It has
|
|
|
4
4
|
|
|
5
5
|
- the **authoring API**: models, steps, handlers and the typed pipeline builder;
|
|
6
6
|
- the **step harness**: the runtime compiled into each pipeline's single executable (`describe`, `run`, `map-trigger`);
|
|
7
|
-
- the **build tool** (`olve-build`), which turns a pipeline directory into `step.tar.
|
|
7
|
+
- the **build tool** (`olve-build`), which turns a pipeline directory into `step.tar.zst` and `manifest.json`;
|
|
8
8
|
- the **dev runner** (`olve-dev`), which runs the same harness path from source (used by `pl dev`).
|
|
9
9
|
|
|
10
|
-
Design: [`docs/design/pipelines-v2.md`](https://github.com/OliverVea/Olve.Pipelines/blob/
|
|
10
|
+
Design: [`docs/design/pipelines-v2.md`](https://github.com/OliverVea/Olve.Pipelines/blob/main/docs/design/pipelines-v2.md). Spike findings: [`pipelines-v2-spikes.md`](https://github.com/OliverVea/Olve.Pipelines/blob/main/docs/design/pipelines-v2-spikes.md).
|
|
11
11
|
The manifest format is in [`manifest.schema.json`](manifest.schema.json), generated from [`src/describe/manifest-schema.ts`](src/describe/manifest-schema.ts).
|
|
12
12
|
|
|
13
13
|
```
|
|
14
|
-
sdk/
|
|
14
|
+
src/sdk/
|
|
15
15
|
src/index.ts authoring API (`@olivervea/pipelines`)
|
|
16
|
-
src/core/ models, secrets, steps, implement()
|
|
16
|
+
src/core/ models, secrets, config, steps, implement()
|
|
17
17
|
src/builder/ pipeline builder (types + runtime records), Ref<T>
|
|
18
|
-
src/library/ library steps (
|
|
18
|
+
src/library/ library steps (gitHubSource, imageBuild, helmDeployOverSsh, helmDeployInCluster, composeDeploy), their exported helpers (httpHealthCheck, sshSession, checkSpa) and SDK models; public API only
|
|
19
19
|
src/describe/ describe: manifest, checks, mapper recording + source analysis, manifest schema
|
|
20
20
|
src/harness/ harness CLI modes, env contract, envelope, result upload (`@olivervea/pipelines/harness`)
|
|
21
21
|
src/ctx/ the ctx handed to handlers
|
|
@@ -47,8 +47,8 @@ bunx olve-build . --out .olve/out
|
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
- The package ships TypeScript sources (Bun runs them directly; `harness.sdkHash` hashes them), so the
|
|
50
|
-
same version gives the same `sdkHash` and
|
|
51
|
-
- Inside this repo, `.pipelines/` and `.pipelines-test/` use `"@olivervea/pipelines": "file:../sdk"`.
|
|
50
|
+
same version gives the same `sdkHash` and `codeHash` from npm or from `file:../src/sdk`.
|
|
51
|
+
- Inside this repo, `.pipelines/` and `.pipelines-test/` use `"@olivervea/pipelines": "file:../src/sdk"`.
|
|
52
52
|
|
|
53
53
|
### Releases
|
|
54
54
|
|
|
@@ -59,22 +59,14 @@ skips when the version exists, or when the pipeline secret `NPM_TOKEN` is unset.
|
|
|
59
59
|
|
|
60
60
|
## Authoring
|
|
61
61
|
|
|
62
|
-
A pipeline directory (the binding path, `.pipelines/` by default)
|
|
62
|
+
A pipeline directory (the binding path, `.pipelines/` by default) is an ordinary Bun package. `main` in `package.json` is required and names the entry point, which default-exports the pipeline; every other name is yours. A step carries its implementation, `implement(step({ … }), fn)`, and is imported and staged like any value.
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
models.ts data models (contract file)
|
|
66
|
-
pipeline.ts steps + `export default pipeline(…)` (contract file)
|
|
67
|
-
handlers/*.ts one per custom step: `export default implement(step, fn)` (pairs by the step, not the file name)
|
|
68
|
-
handlers/lib/… shared helpers (not handlers)
|
|
69
|
-
scripts/*.sh embedded with `import s from "../scripts/x.sh" with { type: "file" }`
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
Contract files hold contracts only. Describe evaluates them without loading handlers, and they are kept out of every handler's hash.
|
|
64
|
+
Describe evaluates the entry point and everything it imports, so top-level code must be deterministic: no env, time or network. Scripts are embedded with `import s from "./x.sh" with { type: "file" }`.
|
|
73
65
|
|
|
74
66
|
### Models, secrets, steps
|
|
75
67
|
|
|
76
68
|
```ts
|
|
77
|
-
import { model, z, fileRef, optional, secret, step, steps, Source } from "@olivervea/pipelines";
|
|
69
|
+
import { model, z, fileRef, optional, external, secret, step, steps, Source } from "@olivervea/pipelines";
|
|
78
70
|
|
|
79
71
|
export const BuildArtifact = model("BuildArtifact", z.object({
|
|
80
72
|
version: z.string(),
|
|
@@ -87,53 +79,77 @@ export const deployBeta = step({
|
|
|
87
79
|
name: "deploy-beta", // identity: lower-case kebab, ≤ 63 chars
|
|
88
80
|
description: "Deploys to beta.", // required (may be ""); shown in the UI, not versioned
|
|
89
81
|
image: "alpine:latest", // any image; the harness brings its own runtime
|
|
90
|
-
input: { artifact: BuildArtifact }, // model | optional(model) | plain zod schema
|
|
82
|
+
input: { artifact: BuildArtifact }, // model | optional(model) | cells(model) | plain zod schema | external(model, …)
|
|
91
83
|
output: BetaDeployment, // a model, or omitted (no output)
|
|
92
84
|
secrets: [SSH_PRIVATE_KEY], // only these are mounted; ctx.secrets is typed to them
|
|
93
85
|
timeoutSeconds: 900, // optional
|
|
94
86
|
retries: 1, // optional: a failed attempt is retried (fresh pod) up to n times
|
|
95
87
|
});
|
|
96
|
-
|
|
97
|
-
import testSh from "./scripts/test.sh" with { type: "file" };
|
|
98
|
-
export const test = steps.script({ name: "test", description: "Runs the tests.", image: "…", script: testSh, input: { source: Source }, output: TestReport,
|
|
99
|
-
env: { TAG: ({ source }, run) => `${run.version}-${source.sha.slice(0, 7)}` } }); // env values may be functions of the inputs
|
|
100
88
|
```
|
|
101
89
|
|
|
102
90
|
Rules:
|
|
103
91
|
|
|
92
|
+
- `step()` is the contract. A pipeline stages `implement(step, fn)`; a staged step without an implementation is a describe error.
|
|
104
93
|
- Models are **nominal**: two models with the same shape are not interchangeable.
|
|
105
94
|
- There is **one schema per model name**. Two different schemas registered under the same name is a describe error.
|
|
106
95
|
- **Outputs are validated strictly**: unknown keys are rejected at any depth, not stripped.
|
|
107
96
|
- **Trigger mappers** produce `z.input`, so fields with defaults may be left out. **Handlers, mapper refs and `when`** see `z.output`.
|
|
108
97
|
- A **plain-schema input** has no model to match on, so the step needs a mapper.
|
|
109
98
|
- **`optional(M)`** means "maybe skipped": `M` must still have an earlier producer.
|
|
99
|
+
- **`cells(M)`** reads every cell of a matrix step whose output is `M` (see "Matrix steps").
|
|
100
|
+
- **`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.
|
|
110
101
|
|
|
111
|
-
|
|
102
|
+
### Config
|
|
112
103
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
104
|
+
Values that differ per deployment (hosts, namespaces, which identity provider) are **config**: typed entries the pipeline declares, whose values live on the instance (one YAML document per pipeline, edited with `pl config` or the dashboard), not in the repo.
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { config, secret, secretRef, step, z } from "@olivervea/pipelines";
|
|
108
|
+
|
|
109
|
+
export const DEX_CLIENT_SECRET = secret("DEX_CLIENT_SECRET", "Dex client secret.");
|
|
110
|
+
export const AUTH = config("auth", "How users authenticate.", z.discriminatedUnion("mode", [
|
|
111
|
+
z.object({ mode: z.literal("none") }),
|
|
112
|
+
z.object({ mode: z.literal("dex"), issuer: z.string().url(), clientSecret: secretRef(DEX_CLIENT_SECRET) }),
|
|
113
|
+
]));
|
|
114
|
+
export const TARGET = config("target", "Where the release is deployed.",
|
|
115
|
+
z.object({ host: z.string(), namespace: z.string().default("olve-pipelines") }));
|
|
116
|
+
|
|
117
|
+
export const deploy = step({ name: "deploy", description: "Deploys the release.", image: "alpine:3.22",
|
|
118
|
+
config: [AUTH, TARGET], // ctx.config is typed to exactly these
|
|
119
|
+
secrets: [DEX_CLIENT_SECRET] }); // a secretRef's secret must be declared too
|
|
120
|
+
```
|
|
118
121
|
|
|
119
|
-
|
|
122
|
+
```yaml
|
|
123
|
+
# the instance's config document for this pipeline
|
|
124
|
+
auth: { mode: dex, issuer: https://dex.example.com, clientSecret: { secret: DEX_CLIENT_SECRET } }
|
|
125
|
+
target: { host: deploy@box }
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
- `config(name, description, schema)` mirrors `secret(...)`. Names match `^[a-z][a-zA-Z0-9]*$` (they are YAML keys).
|
|
129
|
+
- An entry whose schema accepts `undefined` (`.optional()`, `.default(…)`) is optional; every other entry is required. Defaults are applied when config is read, never written into the document.
|
|
130
|
+
- Handlers see `ctx.config` with the output view (defaults applied); a discriminated union narrows as usual.
|
|
131
|
+
- `secretRef(S)` is a field whose value is `{ secret: "<S.name>" }`: it names a secret, never holds its value. The handler reads the value from `ctx.secrets`. A step that receives an entry containing a `secretRef` must declare that secret.
|
|
132
|
+
- Config is per **run**: the run pins a config snapshot at start; continues and step reruns reuse it. Secrets are per attempt.
|
|
133
|
+
- Library steps (and any step's `settings`) take **config references** in place of values: `host: TARGET.host`, `target: TARGET.beta`. A reference is a typed proxy (`ConfigRef<T>`, `T` = the field's output type; `Configurable<T> = T | ConfigRef<T>`), so a `number` field or an optional field can't go where a `string` is required. Fields named `kind`, `name`, `description` or `schema` (the entry's own properties) are reached through `$` (`TARGET.$.name`); `TARGET.$` and `TARGET` itself reference the whole entry. The step's `config` gets every entry referenced in its `settings` (`configEntries` in the manifest); the manifest records each reference as `{"$config": "<entry>.<path>"}` (field names must not contain `.`; literal keys starting with `$` are reserved); the handler resolves them with `resolveConfigRefs(value, ctx.config)` (defaults applied). `describe` and reconcile reject a reference to an entry the step doesn't receive or a field the entry doesn't have.
|
|
134
|
+
- `clusterAccess: true` on a step asks for the instance's step ServiceAccount token (`Kubernetes:StepServiceAccount`; manifest `clusterAccess: true`, omitted otherwise). Without the instance setting the pod gets no token, like every step.
|
|
120
135
|
|
|
121
136
|
### Library steps
|
|
122
137
|
|
|
123
|
-
Library steps carry their own implementation and image; they take typed
|
|
138
|
+
Library steps carry their own implementation and image; they take typed settings and use the SDK models `Source`, `BuiltImage`, `PushedImage` and `HelmRelease`. Each is a function returning `implement(step({ kind, settings, … }), handler)`, built on the public API only (a test enforces it), so a step package outside the SDK is written the same way (see [Reusable steps](#reusable-steps)). The set grows from real use: a step or an option exists because several pipelines use it; what one pipeline needs stays a custom step there.
|
|
124
139
|
|
|
125
140
|
| Step | Image | Input → output | Config |
|
|
126
141
|
|---|---|---|---|
|
|
127
|
-
| `steps.
|
|
128
|
-
| `steps.
|
|
129
|
-
| `steps.
|
|
130
|
-
| `steps.helmDeployOverSsh` | `alpine:3.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
- `
|
|
134
|
-
-
|
|
135
|
-
- **
|
|
136
|
-
- `
|
|
142
|
+
| `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`) |
|
|
143
|
+
| `steps.imageBuild` | `gcr.io/kaniko-project/executor:v1.24.0-debug`, pinned by digest | `{ source: Source }` → `BuiltImage { repository, tag, digest, tar }`; with `push`: `PushedImage { repository, tag, digest, cached }` | `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-<hash>` of the context and the build recipe; skip when the registry has it, with `cached: true`), `registryMap?` (kaniko `--registry-map`: registry → mirror prefix; default none), `cacheRepo?` (kaniko's layer cache repository; default `<repository>/cache` with `push`, none for a tarball; `false` = off), `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) |
|
|
144
|
+
| `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? }`) |
|
|
145
|
+
| `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; a resolved `secretRef`, `{ secret: NAME }`, becomes that secret's value, and the file holding it is deleted after the upgrade), `secrets?` (the secrets those `secretRef`s name; the step declares them), `images?`, `imageRefs?`, `deployments?` (ssh), `rolloutTimeoutSeconds?` (default 120), `healthPath?`; helmDeployInCluster declares `clusterAccess` |
|
|
146
|
+
| `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?` |
|
|
147
|
+
|
|
148
|
+
- `gitHubSource` downloads `<apiUrl>/repos/<repo>/tarball/<sha>` (retrying 5xx), strips the top-level directory and outputs it as `tree`.
|
|
149
|
+
- `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.
|
|
150
|
+
- **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. The layer cache (`cacheRepo`) is probed the same way and left out when its registry doesn't answer (`<host> unreachable; building without the layer cache`); kaniko treats a cache lookup error as a miss and a failed cache push as a warning.
|
|
151
|
+
- **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>`.
|
|
152
|
+
- `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):
|
|
137
153
|
|
|
138
154
|
```ts
|
|
139
155
|
steps.helmDeployOverSsh({ name: "deploy-beta", description: "", host: "oliver@bulwark-m2", sshKey: SSH_PRIVATE_KEY,
|
|
@@ -141,31 +157,83 @@ Library steps carry their own implementation and image; they take typed config a
|
|
|
141
157
|
images: { server: "image", web: "web.image" }, deployments: ["olve-slimerepublics-server", "olve-slimerepublics-web"] });
|
|
142
158
|
```
|
|
143
159
|
|
|
160
|
+
- **Deploy targets.** `helmTarget(defaults?, extend?)` is the `inCluster | ssh` union. `deployTarget({ namespace?, release?, extend?, composeToken, optional? })` adds `compose` (`{ deployerUrl, service, token: secretRef(composeToken), healthUrl?, …extend }`) and, with `optional: true`, `none` (`{ kind: "none" }`, no other fields; its `select` case usually has no steps). A library step that references a target narrowed to one case needs only that case's secrets: the helm steps don't declare the compose `token`.
|
|
161
|
+
- `composeDeploy` resolves its target, checks `kind` is `compose` and that `token.secret` is the declared secret, then `POST <deployerUrl>/v1/deploy` `{ service, image: "<repository>@<digest>" }` with `Authorization: Bearer <token>`. Each line of the answer's `log` is logged as `deployer: …`; anything but HTTP 2xx with `status: "deployed"` fails the step with the deployer's status and message. With `healthUrl` it then runs `httpHealthCheck` (12 × 5 s). The deployer itself (`compose/deployer`) and its API: [docs/setup/compose-deploy.md](../docs/setup/compose-deploy.md).
|
|
162
|
+
|
|
144
163
|
**Helpers.** General functions the library steps are built on, exported for custom handlers:
|
|
145
164
|
|
|
146
165
|
| Helper | What it does |
|
|
147
166
|
|---|---|
|
|
148
|
-
| `httpHealthCheck(ctx, url, { attempts?, delaySeconds?, headers? })` | GETs `url` until it returns 2xx (redirects followed), retrying network errors and non-2xx statuses; defaults 5 attempts, 5 s apart
|
|
167
|
+
| `httpHealthCheck(ctx, url, { attempts?, delaySeconds?, headers? })` | GETs `url` until it returns 2xx (redirects followed), retrying network errors and non-2xx statuses; defaults 5 attempts, 5 s apart. Throws the last error when the attempts run out. Runs in the harness, so the image needs no curl. `helmDeployOverSsh`'s `healthUrl` uses it. |
|
|
149
168
|
| `serviceUrl(release)` | `http://<release>.<namespace>.svc.cluster.local` for a `HelmRelease` (charts that name the Service after the release). |
|
|
150
169
|
| `checkSpa(ctx, url)` | `GET <url>/` must reference an `/assets/*.js` bundle (Vite's layout) and the bundle must be served as JavaScript; returns `{ html, bundle }` for app-specific checks. |
|
|
151
|
-
| `sshSession(ctx, { host, key })` | Installs openssh-client (apk), writes the key to `<tmpDir>/ssh/id` (trailing newline, mode 600; host key not verified) and returns `{ run(remote), text(remote), copy(local, remote), shell(remote) }`; `shell` is the quoted ssh command for pipes (`ctx.sh(`printf '%s' "$V" \| ${s.shell("kubectl apply -f -")}`)`). `helmDeployOverSsh` uses it.
|
|
170
|
+
| `sshSession(ctx, { host, key })` | Installs openssh-client when the image has no ssh (apk or apt-get), writes the key to `<tmpDir>/ssh/id` (trailing newline, mode 600; host key not verified) and returns `{ run(remote, { tty? }), text(remote), copy(local, remote), shell(remote) }`; `shell` is the quoted ssh command for pipes (`ctx.sh(`printf '%s' "$V" \| ${s.shell("kubectl apply -f -")}`)`). `helmDeployOverSsh` uses it. |
|
|
152
171
|
|
|
153
172
|
```ts
|
|
154
|
-
import { httpHealthCheck, implement } from "@olivervea/pipelines";
|
|
155
|
-
export
|
|
173
|
+
import { httpHealthCheck, implement, step } from "@olivervea/pipelines";
|
|
174
|
+
export const smoke = implement(step({ name: "smoke", description: "", image: "alpine:3.22" }), async (_, ctx) => { await httpHealthCheck(ctx, "http://app.ns.svc.cluster.local/api/health", { attempts: 10 }); });
|
|
156
175
|
```
|
|
157
176
|
|
|
177
|
+
### Reusable steps
|
|
178
|
+
|
|
179
|
+
A step several pipelines use is a function returning `implement(step(…), handler)`, staged directly. The library steps are written this way, so a module in a repo or a package of its own works the same.
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
import { implement, resolveConfigRefs, serviceUrl, step, HelmRelease, type Configurable, type LiteralName } from "@olivervea/pipelines";
|
|
183
|
+
|
|
184
|
+
export function smokeTest<const N extends string>(def: { name: LiteralName<N>; description: string; path: Configurable<string> }) {
|
|
185
|
+
return implement(
|
|
186
|
+
step({ name: def.name, description: def.description, kind: "smokeTest", image: "alpine:3.22", input: { release: HelmRelease }, settings: { path: def.path } }),
|
|
187
|
+
async ({ release }, ctx) => void (await ctx.http.expectOk(`${release.url ?? serviceUrl(release)}${resolveConfigRefs(def.path, ctx.config)}`)),
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
- **`kind`**: a label, `custom` unless the step names one. Prefix a package's kinds (`acme/smokeTest`).
|
|
193
|
+
- **`settings`**: what the step was set up with, recorded as the step's manifest `config` (so part of its version). JSON values and config references; the step receives every entry its settings reference.
|
|
194
|
+
- `implement` refuses a step that already carries an implementation.
|
|
195
|
+
|
|
196
|
+
### Matrix steps
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
import { cascadeFailure, cells, implement, step } from "@olivervea/pipelines";
|
|
200
|
+
|
|
201
|
+
export const buildCli = implement(step({
|
|
202
|
+
name: "build-cli", description: "", image: "oven/bun:1.4.3-slim",
|
|
203
|
+
input: { source: Source }, output: BuiltBinary,
|
|
204
|
+
matrix: {
|
|
205
|
+
axes: { os: ["linux", "windows", "macos"], arch: ["x64", "arm64"] }, // key order is the axis order
|
|
206
|
+
exclude: [{ os: "windows", arch: "arm64" }], // names one or more axes
|
|
207
|
+
failure: cascadeFailure(), // default keepRunning()
|
|
208
|
+
maxParallel: 2, // optional
|
|
209
|
+
},
|
|
210
|
+
}), async ({ source }, ctx) => {
|
|
211
|
+
const { os, arch } = ctx.cell; // { os: "linux" | "windows" | "macos"; arch: "x64" | "arm64" }
|
|
212
|
+
// …
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
export const publish = implement(step({
|
|
216
|
+
name: "publish", description: "", image: "alpine", input: { binaries: cells(BuiltBinary) },
|
|
217
|
+
}), async ({ binaries }) => { for (const { cell, output } of binaries) { /* … */ } });
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
- **Cells.** The step runs once per cell: the cross product of the axes (first axis outermost) minus the excludes. 1 to 4 axes, 1 to 64 cells. An axis name matches `^[a-z][a-zA-Z0-9]{0,30}$`, a value `^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$`, values unique per axis. Exclude keys and values are type-checked against the axes.
|
|
221
|
+
- **`ctx.cell`** is the cell, typed from the axes' literals. It exists only on a matrix step's ctx.
|
|
222
|
+
- **A cell's name** is `build-cli[os=linux,arch=x64]`: `ctx.run.step` and the `step` of the cell's file references hold it.
|
|
223
|
+
- **Failure.** `keepRunning()` lets every cell run to the end; `cascadeFailure()` cancels the cells still running or waiting once a cell has failed its last allowed attempt.
|
|
224
|
+
- **Reading a matrix step.** A later step reads it only through `cells(M)`, matched by model like other inputs or wired whole with a mapper (`[publish, from => ({ binaries: from["build-cli"] })]`; a field of a matrix step is a type error). The value is `{ cell, output }[]` in cell order, one entry per cell with an output; `cell` is `Readonly<Record<string, string>>`. A matrix step can't be read as one output, and a matrix step inside a `when` can't be read by `cells` (it may be skipped).
|
|
225
|
+
- **Failure handlers** can't be matrix steps.
|
|
226
|
+
|
|
158
227
|
### Handlers
|
|
159
228
|
|
|
160
229
|
```ts
|
|
161
|
-
|
|
162
|
-
import
|
|
163
|
-
import { deployBeta } from "../pipeline";
|
|
164
|
-
import deployScript from "../scripts/deploy.sh" with { type: "file" };
|
|
230
|
+
import { implement, step } from "@olivervea/pipelines";
|
|
231
|
+
import deployScript from "./scripts/deploy.sh" with { type: "file" };
|
|
165
232
|
|
|
166
|
-
export
|
|
233
|
+
export const deployBeta = implement(step({ name: "deploy-beta", /* … */ }), async ({ artifact }, ctx) => {
|
|
167
234
|
await ctx.sh(deployScript, { env: { IMAGE_TAR: await ctx.files.fetch(artifact.image), CHART_DIR: await ctx.files.fetch(artifact.chart) } });
|
|
168
235
|
await ctx.retry({ attempts: 5, delaySeconds: 5 }, () => ctx.http.expectOk("https://pipelines-beta.ovea.pro/api/health"));
|
|
236
|
+
ctx.link("https://pipelines-beta.ovea.pro", "beta"); // shown on the step card and in pl run get
|
|
169
237
|
return { release: "olve-pipelines", version: artifact.version, url: "https://pipelines-beta.ovea.pro" }; // or ctx.skip("reason")
|
|
170
238
|
});
|
|
171
239
|
```
|
|
@@ -180,9 +248,11 @@ export default implement(deployBeta, async ({ artifact }, ctx) => {
|
|
|
180
248
|
| `http.fetch`, `http.getJson(url, { headers })`, `http.expectOk(url, { method, headers })` | Bound to `ctx.signal`. A non-2xx status throws a readable error, with the URL redacted after `?`. |
|
|
181
249
|
| `retry({ attempts, delaySeconds, backoff? }, fn)` | Retries `fn`. Can be cancelled. |
|
|
182
250
|
| `secrets.NAME` | Typed to the step's declared secrets. Read from the env var `NAME`. Throws if the variable is missing. |
|
|
251
|
+
| `config.<entry>` | Typed to the step's declared config entries (output view, defaults applied). From input.json's `$config`. An optional entry that is not set is `undefined`. |
|
|
183
252
|
| `log(message, fields?)` | Writes a `message k=v …` line to stderr. |
|
|
184
253
|
| `signal` | Aborted on SIGTERM/SIGINT. Running commands are sent SIGTERM. |
|
|
185
|
-
| `run` | `{ pipeline, id, number, attempt, step, version }` (from the env, see below). |
|
|
254
|
+
| `run` | `{ pipeline, id, number, attempt, step, version }` (from the env, see below). For a matrix step `step` is the cell's name. |
|
|
255
|
+
| `cell` | Matrix steps only: the cell being run, in axis order (from `OLVE_CELL`). |
|
|
186
256
|
| `tmpDir` | A writable scratch directory (`$OLVE_DIR/tmp`). Kaniko has no `/tmp`. |
|
|
187
257
|
| `skip(reason)` | Produce no output (prefer `when`). |
|
|
188
258
|
|
|
@@ -206,19 +276,23 @@ export default pipeline({ name: "Olve.Pipelines", description: "…", input: M.D
|
|
|
206
276
|
- **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.
|
|
207
277
|
- **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}`.
|
|
208
278
|
- **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.
|
|
279
|
+
- **`select` (branch on config).** `.select(TARGET.prod, { ssh: (p, prod) => p.stage(…), inCluster: (p, prod) => p.stage(…) })`: the first argument references a `z.discriminatedUnion` in a config entry (`DiscriminatorOf<U>` is its one literal field; a union with no or several literal fields is a type error). Cases are exhaustive and checked at compile time; each gets the reference narrowed to its variant (`ConfigNode<VariantOf<U, D, V>>`), recorded as `{"$config": "target.prod", "$case": "target.prod.kind=ssh"}` and usable only inside that case. The manifest records `{select, discriminator, cases}`; the service resolves it per run from the pinned config snapshot (`when` is per run input). The same step name may appear in several cases (same output model; not two different custom steps, since handlers pair by name); a step every case has is definitely available afterwards, one only some have is maybe-available. The harness picks the variant of a same-named step from `OLVE_CASES` (a JSON array of the case conditions the step sits in, set by the service).
|
|
280
|
+
- **Retention.** `pipeline({ …, retention: { keepLatestRuns?, minRunAgeDays?, buildGraceDays? } })` overrides the instance's retention thresholds for this pipeline's runs and binaries, field by field (non-negative integers; unset = the instance's value). Recorded on the manifest root as `retention`; the service applies the active version's.
|
|
209
281
|
- **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.
|
|
210
282
|
|
|
211
283
|
### Testing handlers
|
|
212
284
|
|
|
213
285
|
```ts
|
|
214
286
|
import { testHandler, testFileRef } from "@olivervea/pipelines/testing";
|
|
215
|
-
import deployBeta from "../
|
|
287
|
+
import { deployBeta } from "../steps/deploy-beta";
|
|
216
288
|
|
|
217
|
-
const r = await testHandler(deployBeta, { artifact }, { secrets: { SSH_PRIVATE_KEY: "k" }, ctx: { http: fakeHttp }, run: { number: 412 } });
|
|
289
|
+
const r = await testHandler(deployBeta, { artifact }, { secrets: { SSH_PRIVATE_KEY: "k" }, config: { target: { host: "h" } }, ctx: { http: fakeHttp }, run: { number: 412 } });
|
|
218
290
|
expect(r).toMatchObject({ status: "succeeded", output: { release: "olve-pipelines" } }); // r.filesDir, r.logs, r.exitCode
|
|
219
291
|
```
|
|
220
292
|
|
|
221
|
-
|
|
293
|
+
A matrix step takes the cell to run: `testHandler(buildCli, { source }, { cell: { os: "linux", arch: "x64" } })`; without one it fails with `kind: "input"` and exit code 65, like a pod without `OLVE_CELL`.
|
|
294
|
+
|
|
295
|
+
`testHandler` uses the same validation path as a real run. Invalid input gives `status: "failed"` with `kind: "input"`; an invalid return value gives `kind: "output"`. Any part of `ctx` can be replaced; whatever you don't replace is the real implementation. `testHandler` takes any implementation: a custom step, a library step or a reusable step.
|
|
222
296
|
|
|
223
297
|
## Harness contract (service ⇄ step binary)
|
|
224
298
|
|
|
@@ -233,7 +307,7 @@ This section is everything the C# side needs in order to run the binary.
|
|
|
233
307
|
| Mode | Reads | Writes | Notes |
|
|
234
308
|
|---|---|---|---|
|
|
235
309
|
| `describe [--sentinels]` | the binary itself | stdout: the manifest (canonical JSON) | With `--sentinels`, stdout gets the manifest between `---OLVE-MANIFEST-BEGIN---` and `---OLVE-MANIFEST-END---`. Needs no env and no files. |
|
|
236
|
-
| `run <step>` | `$OLVE_DIR/input.json` | `$OLVE_DIR/work/output.json`, `$OLVE_DIR/work/files/…` | Steps in stages and failure-handler steps. |
|
|
310
|
+
| `run <step>` | `$OLVE_DIR/input.json` | `$OLVE_DIR/work/output.json`, `$OLVE_DIR/work/files/…` | Steps in stages and failure-handler steps. A matrix step's cell runs as `run <step>` (the step's own name) with `OLVE_CELL`. |
|
|
237
311
|
| `map-trigger <trigger>` | `$OLVE_DIR/input.json` (the event) | `$OLVE_DIR/work/output.json` | `<trigger>` is the trigger's `name` from the manifest. |
|
|
238
312
|
|
|
239
313
|
`run` and `map-trigger` also print the envelope on stdout, as one compact JSON line between `---OLVE-RESULT-BEGIN---` and `---OLVE-RESULT-END---`. The block always comes last. Use it as a fallback channel from the pod log. User output and stderr logs also go to the pod log, so never parse anything outside the sentinels.
|
|
@@ -242,7 +316,7 @@ This section is everything the C# side needs in order to run the binary.
|
|
|
242
316
|
|
|
243
317
|
| Path | Who | What |
|
|
244
318
|
|---|---|---|
|
|
245
|
-
| `input.json` | service (via olve-init) | **run**: a JSON object with exactly the step's input keys, built from the manifest's `input` mappings. Leave out a key whose ref root step was skipped; this is allowed only for `optional` inputs. Unknown keys → exit 65. **map-trigger push**: `{"ref": "refs/heads/<branch>", "after": "<sha>", "repository": {"full_name": "<owner>/<repo>"}}` (`PushEvent`). **map-trigger poll**: `{"value": "<extracted value as string>"}`. **map-trigger webhook**: `{"headers": {…}, "body": …}` (`WebhookEvent`). |
|
|
319
|
+
| `input.json` | service (via olve-init) | **run**: a JSON object with exactly the step's input keys, built from the manifest's `input` mappings. Leave out a key whose ref root step was skipped; this is allowed only for `optional` inputs. Unknown keys → exit 65. For a step with `configEntries`, the reserved key `"$config"` holds `{ <entry>: <value> }` from the run's config (entries that are not set are left out); each entry is parsed with its schema, and a missing required entry or an invalid value → exit 65 (`config.<entry>…`). Other steps ignore `$config`. A `cells` input is a list in cell order, one entry per cell whose latest successful attempt has an output: `[{"cell": {"os": "linux", "arch": "x64"}, "output": {…}}, …]`. **map-trigger push**: `{"ref": "refs/heads/<branch>", "after": "<sha>", "repository": {"full_name": "<owner>/<repo>"}}` (`PushEvent`). **map-trigger poll**: `{"value": "<extracted value as string>"}`. **map-trigger webhook**: `{"headers": {…}, "body": …}` (`WebhookEvent`). |
|
|
246
320
|
| `work/output.json` | harness | The envelope. Any stale copy is deleted at start, and the new file is written atomically. |
|
|
247
321
|
| `work/files/<object path>` | harness | Output files and directory tars (see "File references"). |
|
|
248
322
|
| `tmp/` | harness | Scratch space. Point `TMPDIR` here. |
|
|
@@ -259,13 +333,18 @@ This section is everything the C# side needs in order to run the binary.
|
|
|
259
333
|
| `OLVE_RUN` | run | Run number (integer ≥ 0). Written into produced file refs. Default 0. |
|
|
260
334
|
| `OLVE_ATTEMPT` | run | Attempt number (integer ≥ 1). Written into produced file refs. Default 1. |
|
|
261
335
|
| `OLVE_VERSION_STAMP` | run | Derived version, e.g. `2026.10.21.412` → `ctx.run.version`. |
|
|
336
|
+
| `OLVE_CELL` | run | Only for a matrix step: the cell, as compact JSON in axis order (`{"os":"linux","arch":"x64"}`) → `ctx.cell`. Missing, or not one of the step's cells → exit 65. `ctx.run.step` and produced file refs carry the cell's name, `build-cli[os=linux,arch=x64]`. |
|
|
337
|
+
| `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. |
|
|
262
338
|
| `<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. |
|
|
263
339
|
| `OLVE_RESULT_URL` | optional | Pre-signed **PUT** for `result.tar.gz` (see below). Unset means no upload. |
|
|
340
|
+
| `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. |
|
|
264
341
|
| `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). |
|
|
265
342
|
| `OLVE_FILES_BASE_URL` (+ `OLVE_FILES_TOKEN`) | optional | Fallback: `GET <base>/<run>/<step>/<attempt>/<object path>` with an optional `Authorization: Bearer <token>`. |
|
|
266
343
|
| `OLVE_FILES_DIR` | dev | Local pool laid out as `<run>/<step>/<attempt>/files/<object path>`. |
|
|
267
344
|
|
|
268
|
-
`OLVE_RESULT_URL`, `OLVE_FILE_URLS` and `
|
|
345
|
+
`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 `?`.
|
|
346
|
+
|
|
347
|
+
**Secret masking** (`run`): every value of the step's declared secrets is replaced with `***` in what the step writes: `ctx.log`, `console`, anything written to stdout or stderr, the output of `ctx.exec` and `ctx.sh` commands (piped through the harness when the step has a secret; inherited otherwise), and the envelope's failure message or skip reason. Output is masked a line at a time; a multi-line value is masked line by line, and values (or lines) shorter than 4 characters are left alone. Outputs and files are data and are not masked. A process that writes to the pod's stdout around the harness (a file descriptor it opened itself) is not masked.
|
|
269
348
|
|
|
270
349
|
`ctx.files.fetch` looks for a file in this order:
|
|
271
350
|
|
|
@@ -284,6 +363,8 @@ It retries network errors, 5xx, 408 and 429. It always verifies `sha256`.
|
|
|
284
363
|
{ "status": "failed", "error": { "kind": "handler" | "input" | "output", "message": "…" } }
|
|
285
364
|
```
|
|
286
365
|
|
|
366
|
+
Any of the three may carry `"links": [{ "url": "https://…", "label": "…" }]`, what the handler passed to `ctx.link` (absolute http(s) URLs of at most 2048 characters, labels of at most 100, at most 10). The key is left out when there are none. The server refuses an envelope whose links break these limits.
|
|
367
|
+
|
|
287
368
|
For `map-trigger`, `output` is the pipeline input after it has been parsed by the input model, so defaults are applied. Validate it again server-side.
|
|
288
369
|
|
|
289
370
|
### Exit codes
|
|
@@ -293,10 +374,10 @@ For `map-trigger`, `output` is the pipeline input after it has been parsed by th
|
|
|
293
374
|
| 0 | ok (succeeded or skipped) | yes |
|
|
294
375
|
| 1 | the handler or trigger mapper threw | failed / `handler` |
|
|
295
376
|
| 64 | usage: unknown mode, step or trigger | none |
|
|
296
|
-
| 65 | `input.json` missing, not JSON, or invalid for the step (or event) | failed / `input` |
|
|
377
|
+
| 65 | `input.json` missing, not JSON, or invalid for the step (or event), including its `$config`; a matrix step's `OLVE_CELL` missing or not one of its cells | failed / `input` |
|
|
297
378
|
| 66 | the return value failed strict validation, or an own file ref is missing or changed | failed / `output` |
|
|
298
379
|
| 74 | the `result.tar.gz` upload failed after retries. Used only when the step itself succeeded; otherwise the step's own code wins. | yes (printed) |
|
|
299
|
-
| 78 | the pipeline is invalid (describe problems, or a
|
|
380
|
+
| 78 | the pipeline is invalid (describe problems, or a step without an implementation) | none |
|
|
300
381
|
| 130 / 143 | the handler failed after SIGINT / SIGTERM (cancellation, `activeDeadlineSeconds`) | failed / `handler` |
|
|
301
382
|
|
|
302
383
|
### Result upload (`OLVE_RESULT_URL`)
|
|
@@ -314,6 +395,8 @@ The service unpacks the archive into `runs/<p>/<run>/<step>/<attempt>/`.
|
|
|
314
395
|
{ "run": 412, "step": "build", "attempt": 1, "path": "chart/", "sha256": "<64 hex>" }
|
|
315
396
|
```
|
|
316
397
|
|
|
398
|
+
- `step` is the step's name, or a matrix cell's name (`build-cli[os=linux,arch=x64]`).
|
|
399
|
+
|
|
317
400
|
- In JSON Schema, a reference is marked `"x-olve-kind": "fileRef"`.
|
|
318
401
|
- `path` is relative, with no `.`/`..` segments, no `\`, no NUL and no leading `/`. The pattern is in the schema. A **trailing `/` means a directory**.
|
|
319
402
|
- **Storage rule:** the object path is `path` for files. For directories, it is `path` without the trailing `/`, plus `.tar`: `chart/` → `chart.tar`, an uncompressed deterministic tar.
|
|
@@ -322,11 +405,12 @@ The service unpacks the archive into `runs/<p>/<run>/<step>/<attempt>/`.
|
|
|
322
405
|
|
|
323
406
|
## Manifest
|
|
324
407
|
|
|
325
|
-
Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-12, `$defs` `Manifest`, `Step`, `Stage`, `Mapping`, `Trigger`, `InputType`, `ModelSchemas`). The text is canonical JSON: sorted keys, 2-space indent, a trailing newline, and no `$schema` in the model schemas. Hash the text as printed.
|
|
408
|
+
Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-12, `$defs` `Manifest`, `Step`, `Stage`, `Mapping`, `Trigger`, `InputType`, `ModelSchemas`, `Matrix`). The text is canonical JSON: sorted keys, 2-space indent, a trailing newline, and no `$schema` in the model schemas. Hash the text as printed.
|
|
326
409
|
|
|
327
410
|
- **`apiVersion`** is `"2.0"`.
|
|
328
411
|
- **`harness`** is `{ bun, sdk, sdkHash }`.
|
|
329
412
|
- **`models[name]`** is `{ output, input? }`.
|
|
413
|
+
- **Step `kind`** is `custom` by default, or what a reusable step names (`imageBuild`, `composeDeploy`, a package's `acme/…`). To the service it is a label: any non-empty kind is accepted, so a new library or package step needs no service change.
|
|
330
414
|
- `output` is the `z.output` view, with `additionalProperties: false` on every object. It validates outputs, the pool and trigger results.
|
|
331
415
|
- `input` is the `z.input` view (defaults optional). Use it for trigger forms and manual input. It is emitted only when it differs from `output` beyond `additionalProperties`.
|
|
332
416
|
- **Step `input`** maps each input key to a mapping:
|
|
@@ -335,45 +419,49 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
|
|
|
335
419
|
- a nested object of mappings.
|
|
336
420
|
|
|
337
421
|
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"}`).
|
|
338
|
-
-
|
|
339
|
-
- **`
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
- Because `harness` is part of both hashes, a Bun or SDK bump changes every handler.
|
|
422
|
+
- **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`.
|
|
423
|
+
- **`inputTypes`** declares each input key: `{model, optional}`, `{model, optional: false, cells: true}` for a `cells` input (mapped with a whole-step ref to a matrix step whose output is that model), or `{schema: {output, input?}}` for a plain schema.
|
|
424
|
+
- **Step `matrix`** (matrix steps only, never a failure handler) is `{axes: [{name, values}…], exclude?: [{axis: value…}…], failure: {kind: "keepRunning" | "cascadeFailure"}, maxParallel?}`. `axes` is a list because canonical JSON sorts keys; `exclude` is omitted when empty; `failure.kind` is always written. All of it is part of what the step declares.
|
|
425
|
+
- **`codeHash`** is the build's identity: `sha256(canonical {bundle, harness})`. `bundle` hashes the entry point bundled with everything it imports, with `@olivervea/pipelines` external. It is tree-shaken and includes embedded scripts and dependencies, so any change to code in use changes it, and with it the manifest, which makes a new pipeline version. A step version covers what the step declares: image, `inputTypes`/model versions, `output` model version, `secrets`, `configEntries` (and those entries' schemas), `input` mappings and `config`.
|
|
343
426
|
- **`stages`** is an array whose items are either `{steps: [Step…]}` (parallel, with a barrier) or `{when: {field: [values]}, stages: [...]}`.
|
|
344
427
|
- **`triggers`**:
|
|
345
428
|
- `{kind: "push", name: "push"}`;
|
|
346
429
|
- `{kind: "poll", name, url, headers, secrets, valuePath, intervalSeconds}`, where header values may contain `$SECRET:NAME`;
|
|
347
430
|
- `{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).
|
|
348
431
|
- **`failureHandlers`** is a list of `{step: Step, steps: [names] | null}`. Each handler's inputs are all FailureContext (`{"ref": "failure"}`).
|
|
432
|
+
- **`retention`** (when present) is `{keepLatestRuns?, minRunAgeDays?, buildGraceDays?}`, non-negative integers that replace the instance's thresholds for this pipeline. Omitted when the definition sets none.
|
|
349
433
|
- **`secrets`** lists every secret used by steps, polls and webhooks, sorted, as `{name, description}`.
|
|
434
|
+
- **`config`** lists every config entry steps declare, sorted by name, as `{name, description, required, schema}`. `schema` is the `z.input` view (defaults listed); it validates the entry's value in the config document. `required` is false when the schema accepts a missing value. A `secretRef` field is marked `"x-olve-kind": "secretRef"`, with the secret's name as `properties.secret.const`. Omitted when there are no entries.
|
|
435
|
+
- **Step `configEntries`** names the entries the step receives (sorted; omitted when none). It is part of the step's content, like `secrets`. The step key `config` is the step's `settings`.
|
|
350
436
|
|
|
351
437
|
Describe runs these checks; reconcile should re-run the manifest-level ones:
|
|
352
438
|
|
|
353
439
|
- step name format and reserved names (`input`, `failure`), and duplicate step names;
|
|
354
440
|
- one schema per model name, and JSON-representable models;
|
|
355
|
-
-
|
|
441
|
+
- a staged step without an implementation, and an empty step kind;
|
|
356
442
|
- availability, ambiguity, maybe-available as required, `optional` without a producer, plain schema without a mapper;
|
|
357
443
|
- mapper source analysis and recording, unknown fields in refs, mapper keys that are missing or extra;
|
|
358
444
|
- `when` on an unknown or non-literal field, or with an invalid value;
|
|
359
445
|
- a poll header referencing an undeclared secret, duplicate triggers;
|
|
360
446
|
- conflicting secret descriptions;
|
|
361
|
-
-
|
|
447
|
+
- config names, one description and schema per config name, a step receiving a `secretRef` without declaring its secret, and input keys starting with `$` (reserved);
|
|
448
|
+
- an `onFailure` scope naming an unknown step, and failure-handler inputs;
|
|
449
|
+
- a matrix's axes, values, excludes, cell count, failure strategy and `maxParallel`, a matrix failure handler, and reading a matrix step other than whole through a `cells` input (or a `cells` input reading anything else).
|
|
362
450
|
|
|
363
451
|
## Build tool
|
|
364
452
|
|
|
365
453
|
```
|
|
366
454
|
olve-build <pipelineDir> --out <dir> [--target bun-linux-x64|bun-linux-x64-musl] [--upload-url <presigned PUT>] [--sentinels]
|
|
367
|
-
olve-build <pipelineDir> --hash-only # print
|
|
455
|
+
olve-build <pipelineDir> --hash-only # print the code hash (diagnostics)
|
|
368
456
|
```
|
|
369
457
|
|
|
370
|
-
1.
|
|
371
|
-
2. Generate `<pipelineDir>/.olve/gen/
|
|
458
|
+
1. Read the entry point from `main` in `package.json` (a missing `main`, or a `main` whose file is missing, is an error) and hash the build (see `codeHash`).
|
|
459
|
+
2. Generate `<pipelineDir>/.olve/gen/entry.ts`, which imports the pipeline and calls `runHarness` with it, the code hash and the harness info. Add `.olve/` to `.gitignore`.
|
|
372
460
|
3. Run `bun build --compile --target=bun-linux-x64 --no-compile-autoload-dotenv --no-compile-autoload-bunfig --bytecode --format=esm` → `<out>/step`. The output is never minified, because mapper source analysis needs real source.
|
|
373
461
|
4. Check that `PT_INTERP` is `/lib64/ld-linux-x86-64.so.2`. On alpine, Bun silently produces a musl binary; the check fails it.
|
|
374
|
-
5. Run `<out>/step describe` → `<out>/manifest.json`. A describe problem is a build failure with exit 78. **A
|
|
375
|
-
6. Write `<out>/step.tar.
|
|
376
|
-
7. With `--upload-url`, PUT `step.tar.
|
|
462
|
+
5. Run `<out>/step describe` → `<out>/manifest.json`. A describe problem is a build failure with exit 78. **A step with no implementation is an error.**
|
|
463
|
+
6. Write `<out>/step.tar.zst` (a deterministic tar, `step` at the root, mode 0755, zstd level 3: it unpacks in the step pod far faster than gzip; harness-base also still unpacks the gzip archives of older SDKs; from 2.0.0-beta.7 the SDK needs an instance whose harness-base unpacks zstd, i.e. one deployed on or after 2026-10-25) and `<out>/build.json` (diagnostics).
|
|
464
|
+
7. With `--upload-url`, PUT `step.tar.zst`, using the same retry rules as the result upload. With `--sentinels`, print the manifest between the manifest sentinels.
|
|
377
465
|
|
|
378
466
|
Exit codes: 0 ok, 1 build failure, 64 usage, 78 pipeline invalid.
|
|
379
467
|
|
|
@@ -385,11 +473,11 @@ bun install --frozen-lockfile
|
|
|
385
473
|
bunx olve-build .pipelines --out /work/out --upload-url "$STEP_PUT_URL" --sentinels
|
|
386
474
|
```
|
|
387
475
|
|
|
388
|
-
From npm, nothing else is needed. A local `file:`/`link:` dependency (`"@olivervea/pipelines": "file:../sdk"`, as Olve.Pipelines' own `.pipelines/` does) resolves to its **source directory**, not the copy in `node_modules`, so its own dependencies must be installed there too: the build Job runs `bun install --frozen-lockfile --production` in every local dependency of the binding's `package.json` that is inside the repo, before building.
|
|
476
|
+
From npm, nothing else is needed. A local `file:`/`link:` dependency (`"@olivervea/pipelines": "file:../src/sdk"`, as Olve.Pipelines' own `.pipelines/` does) resolves to its **source directory**, not the copy in `node_modules`, so its own dependencies must be installed there too: the build Job runs `bun install --frozen-lockfile --production` in every local dependency of the binding's `package.json` that is inside the repo, before building.
|
|
389
477
|
|
|
390
478
|
The repo depends on the SDK as a normal package. It resolves through `exports` (`@olivervea/pipelines`, `@olivervea/pipelines/harness`, `@olivervea/pipelines/testing`), and the tests cover this with a `file:` install. `bun` must be on `PATH`, because the bins use `#!/usr/bin/env bun`. A consumer `tsconfig.json` has to include the dot-directory explicitly (`"include": [".pipelines/**/*.ts"]`), and should exclude `.pipelines/.olve`.
|
|
391
479
|
|
|
392
|
-
How the service runs it (reconcile, `BuildJobBuilder`): one Job per (pipeline, config commit), named `olve-build-<pipeline12>-<sha12>`. The repo tarball comes from `codeload.github.com/<repo>/tar.gz/<sha>` anonymously (public repos), or from the GitHub API tarball endpoint with the binding's credentials key from the pipeline Secret. The
|
|
480
|
+
How the service runs it (reconcile, `BuildJobBuilder`): one Job per (pipeline, config commit), named `olve-build-<pipeline12>-<sha12>`. The repo tarball comes from `codeload.github.com/<repo>/tar.gz/<sha>` anonymously (public repos), or from the GitHub API tarball endpoint with the binding's credentials key from the pipeline Secret. The archive is PUT to `versions/<pipeline>/builds/<sha>/step.tar.gz` (the key predates zstd; the init container tells the formats apart by content), and a version added from that build keeps this key as its `BinaryKey`. The in-repo fixture `.pipelines-test/` (bound on v2 beta) uses exactly this layout.
|
|
393
481
|
|
|
394
482
|
The manifest is the text between the sentinels in the pod log. The manifest text is identical across checkouts. The binary and archive bytes are identical for the same checkout layout.
|
|
395
483
|
|
|
@@ -397,11 +485,11 @@ The manifest is the text between the sentinels in the pod log. The manifest text
|
|
|
397
485
|
|
|
398
486
|
```
|
|
399
487
|
olve-dev describe <dir> [--sentinels] # same bytes as the compiled binary's describe
|
|
400
|
-
olve-dev run <dir> <step> [--input-file f] [--olve-dir d] [--secret NAME=value]...
|
|
488
|
+
olve-dev run <dir> <step> [--input-file f] [--config-file f] [--olve-dir d] [--secret NAME=value]...
|
|
401
489
|
olve-dev map-trigger <dir> <trigger> --event-file f [--olve-dir d]
|
|
402
490
|
```
|
|
403
491
|
|
|
404
|
-
`olve-dev` runs handlers from source, through the same validation path as the binary. `$OLVE_DIR` defaults to `<dir>/.olve`, and outputs land in `<dir>/.olve/work/`. Set `OLVE_FILES_DIR` to a local pool to fetch file refs.
|
|
492
|
+
`olve-dev` runs handlers from source, through the same validation path as the binary. `$OLVE_DIR` defaults to `<dir>/.olve`, and outputs land in `<dir>/.olve/work/`. Set `OLVE_FILES_DIR` to a local pool to fetch file refs. `--config-file` (a JSON object of config entries) becomes the input's `$config`. A matrix step's cell comes from `OLVE_CELL` in the environment, as in the pod.
|
|
405
493
|
|
|
406
494
|
## Development
|
|
407
495
|
|
|
@@ -409,7 +497,6 @@ olve-dev map-trigger <dir> <trigger> --event-file f [--olve-dir d]
|
|
|
409
497
|
bun install
|
|
410
498
|
bunx tsc --noEmit -p . # type-check (TS 7)
|
|
411
499
|
bun scripts/check-compile-fail.ts # compile-fail cases (also run by bun test)
|
|
412
|
-
TSC=/path/to/tsc-5.9/bin/tsc bun scripts/check-compile-fail.ts # the other TS line
|
|
413
500
|
bun test # runtime + build/e2e tests
|
|
414
501
|
bun scripts/gen-manifest-schema.ts # after changing src/describe/manifest-schema.ts
|
|
415
502
|
```
|